Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04ca74c365 | ||
|
|
e47cd3d400 | ||
|
|
4d222a7eba | ||
|
|
6f2a8dff09 | ||
|
|
3befc420da |
+386
@@ -19,6 +19,392 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.5 — 2026-09-29
|
||||
|
||||
The third release from the audit: housekeeping. Nothing a player can see changes; what changes is
|
||||
what the repository says about itself and what the build refuses.
|
||||
|
||||
### The playtest line is retired
|
||||
|
||||
The 0.4.9 line — the build handed to playtesters since August, ported by hand from `main` and
|
||||
diverging structurally by 0.7 — is no longer maintained (Jesse, 2026-09-29). Its worktree and
|
||||
branches are deleted, the "deploy from the right line" rule is gone with it, and #85 (the line
|
||||
being behind on a ruling) is moot. The public site serves the last 0.4.9h build until `npm run
|
||||
deploy:web` is run once from `main`.
|
||||
|
||||
### The table test is closed
|
||||
|
||||
#39, #35, #42a and #40 — the Yard Office offer, the Red Flag hold, the loaded Extra, extended play
|
||||
through two browsers, the setup screen, the save-file sentence — were all met at a table. The
|
||||
section stays in `TODO.md` for the one measurement it holds: which interruptions fire on their own
|
||||
and which have to be set up.
|
||||
|
||||
### #46, and the flag that keeps it at zero
|
||||
|
||||
The unused-declaration count had gone 29 → 40 → 36 across six weeks because nothing ran the flag.
|
||||
All 36 are gone and `noUnusedLocals` + `noUnusedParameters` are on in `tsconfig.json`. Two were dead
|
||||
bot functions from rejected candidates the round said it had deleted (`wouldBuryTheEngine`,
|
||||
`strandedWantedCars`); `isLegal` and `restoreRng` had no callers; `trayySeat` was a typo; the ten
|
||||
in `sim/replay.ts` were imports, so #48 (what that viewer is for) is untouched.
|
||||
|
||||
### What the documents said that was not so
|
||||
|
||||
`README.md`, `compare.ts`, `save-replay.ts` and the bot all taught `trainCapSlack`, a knob deleted
|
||||
weeks ago — `parseTweaks` throws on it. The README also said a flag is deleted when adopted while
|
||||
the bot's own doc block says every flag is a permanent ablation of an adopted heuristic; the bot was
|
||||
right, and had two doc blocks back to back, the first attached to nothing. `card-reference.md` sent
|
||||
readers to `as-built.md`, deleted in 0.8.2; three `TODO.md` entries and two architecture pages said
|
||||
the same. `game-state.md` modelled `officeType`, which the engine calls `tier`; `protocol.md`
|
||||
described a `collisionOccurred` event the engine has never emitted (`trainsDestroyed`). `design.md`
|
||||
carried its print-and-play paragraph twice. Five playtest saves — seeds, names, an August ruleset —
|
||||
sat committed in `docs/` against the repository's own rule; they are in `playtests/` now, ignored.
|
||||
|
||||
### The audit's leftovers, written down
|
||||
|
||||
Everything the four reviewers found that was real and is not fixed is in `TODO.md` §11 as #112-#117,
|
||||
each with its reason. #112 is `docs/plans/structure.md`: the route table for `http.ts`, five
|
||||
extractions for `main.ts`, `check` per phase — each naming the test it would make possible. #117 is
|
||||
the one that needs a conversation: `/api/save` hands a seat the seed mid-game, and a save without a
|
||||
seed is not a save.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.4 — 2026-09-29
|
||||
|
||||
The second release from the audit: the multiplayer transport, server and browser. Every fault here
|
||||
was invisible in solitaire, and four of the five server faults were in the one file no test had
|
||||
ever stood up — `http.ts` now has an end-to-end suite that binds a real port.
|
||||
|
||||
### A player who left could play the seat the next arrival took
|
||||
|
||||
Leaving a lobby freed the chair and kept the token: it stayed in memory and on disk, and the next
|
||||
player to join was given the vacated chair. So once the game began, the leaver's browser still held
|
||||
a token for that seat — `/api/stream` served it the newcomer's hand and menu, `/api/intent` let it
|
||||
move for them, and its stream connection displaced theirs. `lobby-and-sessions.md` had said all
|
||||
along that Leave "drops the token"; the code did not. It does now, for a player's own leave and for
|
||||
the host's remove alike, in memory and in `sessions.json`, before the chair is offered to anyone.
|
||||
|
||||
### The first move after a reload could be silently swallowed
|
||||
|
||||
The browser numbered its intents from 1 on every page load; the server remembers a seat's last
|
||||
accepted number for the life of the game and answers a repeat with "already applied". A seat that
|
||||
had made one move, reloaded, and clicked again sent `seq: 1` twice — ok, nothing happened, nothing
|
||||
pushed, the click looked dead. The connect push now carries the server's count (`lastSeq`) and the
|
||||
client continues from it, never backwards.
|
||||
|
||||
### Two moves at once could tear the save, and a torn save took every game down
|
||||
|
||||
Nothing serialised moves within a game: the handler awaits the disk write between applying and
|
||||
answering, and two moves arriving together interleaved across it — both applied in memory, both
|
||||
writing the same `game.json.tmp`. Measured at 200 rounds of two concurrent writes: every round lost
|
||||
one to `rename` ENOENT, six left the file as invalid JSON. And the boot did a bare `JSON.parse` on
|
||||
each save at the top level, so one such file was a crash loop with every game on the server
|
||||
unreachable. Three fixes, each pinned: every write to a path queues behind the one before it with a
|
||||
unique temp name; each game's moves run one at a time through apply, persist, answer, broadcast; and
|
||||
an unreadable save is logged and skipped rather than fatal, as is one whose replay throws.
|
||||
|
||||
### One error after the SSE head was sent was a whole-server crash
|
||||
|
||||
The handler's one `catch` answered every error with a JSON 500 — and on a response whose head was
|
||||
already written (both streams, static files) `writeHead` throws inside the catch, with nothing above
|
||||
it. Node exits on an unhandled rejection. `sendJson` now ends such a response instead; the lobby's
|
||||
host-reassignment write and the static file stream have their own error paths; and the 500 no longer
|
||||
echoes the error's message, which for a disk error carried the data directory's absolute path.
|
||||
|
||||
### Anyone could exhaust the process's memory with one POST
|
||||
|
||||
Request bodies were buffered whole, with no cap, before any secret was checked. A 64 KiB limit —
|
||||
the largest body any route has a use for is a few hundred bytes — answers 413; a body that is not a
|
||||
JSON object answers 400 rather than surfacing as a 500.
|
||||
|
||||
### In the browser
|
||||
|
||||
**A double-click did the thing twice.** The page redraws the same menu the instant a submit is sent,
|
||||
so a second click before the round trip posted a second, fresh `seq` for the same option, and the
|
||||
server applied it again: two cards drawn, two Moves spent, two cars coupled. One submit in flight at
|
||||
a time now; a click that lands during one is dropped, and the push is milliseconds away. A network
|
||||
failure or a non-JSON answer is `false` from `submit` rather than an unhandled rejection.
|
||||
|
||||
**The documentation renderer flattened nested bullets.** The comment said nesting was rendered by
|
||||
recursion; the code appended the nested bullet to its parent as text, and the published home-deck
|
||||
page read "…knows. - ABS Signals is the exception…" with a literal dash mid-sentence. Real nesting
|
||||
now, and the test renders the real document to prove the dash is gone.
|
||||
|
||||
**"Still needs 2 boxcar" behind a caboose.** The make-up panel counted by category and promised cars
|
||||
the engine would refuse: nothing couples behind a caboose (§A.3), so it printed a need while no yard
|
||||
chip lit and the only offer was to send the train out as it stands. It asks `acceptsCar` per
|
||||
category now, the way the engine's own make-up report does, and says why when nothing more couples.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.3 — 2026-09-29
|
||||
|
||||
The first of three releases from a code audit (engine, server, client, tests and hygiene, each read
|
||||
by a separate reviewer and every finding re-verified against the code before it was acted on). This
|
||||
one is the engine: seven rules faults and one dealing fault, each pinned by a test written to fail
|
||||
first. **Every save on the test server was replayed under this build before release** — and that is
|
||||
how the dealing fault was found, because under 0.8.2 none of them replayed at all.
|
||||
|
||||
### 0.8.2 stranded every game on the server, and said it stranded ten
|
||||
|
||||
The 0.8.2 notes said three of thirteen saves would resume. The server's own boot log, read for this
|
||||
release, refused all thirteen at **move 3**, `CARD_NOT_IN_HAND` — including the game saved by 0.8.1.0
|
||||
the notes had counted as safe. A refusal at move 3 is not a rule; it is a different deal.
|
||||
|
||||
The **Second Section card went into the deck in 0.8.2** (Q9, one copy — it had been defined and
|
||||
never dealt), after that release's save check had been run and without a line in its notes. A deck
|
||||
one card larger shuffles into a different order from the same seed, so every save older than the
|
||||
card was replaying a different railroad from intent one. `withSavedOpening` could not help: it names
|
||||
the opening, and the opening was not the problem.
|
||||
|
||||
Now `withSavedDeal`, and it names both. `secondSectionCard` is a house rule with no dial — a fact
|
||||
about how a game was dealt, kept for the same reason `startingOffice` is — set false for a saved
|
||||
config that predates the setting and true for everything dealt since. Under this build the thirteen
|
||||
replay exactly as 0.8.2's notes described: **three resume, ten refuse, the same ten at the same moves
|
||||
for the same rules.** The process rule this breaks is already written down ("say which way games in
|
||||
progress go, every time"); what it adds is that the save check has to be the LAST thing before the
|
||||
tag, not a thing done during the work.
|
||||
|
||||
### A player could switch a rival's train, and the rival paid for it
|
||||
|
||||
`check` resolved a switching tray with no seat test at all. The legal-move generator filtered trays
|
||||
by seat; `check` did not; the server validates with `check` alone. Every district opens on the same
|
||||
coordinates, so a destination legal for your own tray at (0,0) was "legal" for a rival's tray at
|
||||
THEIR (0,0) — and the Moves came off the rival's turn, because `trayMoved` charges whoever sits in
|
||||
the district the tray is in. All four switching intents now refuse a tray outside the actor's own
|
||||
district with `NO_SUCH_TRAY`. Invisible in solitaire, which is why it lasted.
|
||||
|
||||
### A rival's crew blocked your own track
|
||||
|
||||
`occupancyFor().trayAt` matched on coordinates alone, so a crew standing at seat 0's (0,2) was
|
||||
"another train standing here" at seat 1's (0,2) — a phantom that refused Moves and closed the Yard
|
||||
Office walk in any game with more than one seat. It asks the seat now.
|
||||
|
||||
### Drawing the last card off a Department could duplicate it and destroy the refill
|
||||
|
||||
When a Department draw emptied the Home Office deck, the reshuffle was computed from the table
|
||||
BEFORE the draw and the refill had been reduced: it swept up the card being drawn and missed the
|
||||
refill card. The reducers then dealt the drawn card into the new deck while the refill card, moved
|
||||
onto a pile the reshuffle wiped a moment later, left the game. Proven by counting — 47 cards in, 46
|
||||
distinct out — and fixed by describing the sweep as the table will be after the events ahead of it.
|
||||
|
||||
### The unjam cleared the first load, not the one you named
|
||||
|
||||
`facilityUnjammed` carried no index, so the reducer cleared the FIRST load on MEN | AT | WORK and the
|
||||
first car of the named type in a box. With an inbound tank load on MEN and a stranded outbound
|
||||
hopper on WORK, unjamming the hopper deleted the tank load, sent a loaded hopper to the yard and
|
||||
left the jam. The event carries the index now; events are regenerated on replay, so no save changes.
|
||||
|
||||
### The collision floor could not fire in Stage 12
|
||||
|
||||
`shiftChange` reset the Day's collision count at the rollover and only then judged it, so a breach
|
||||
reached in the last Stage of a Day read as zero. The limits are judged on the Stage just played,
|
||||
before the Day rolls over. `docs/architecture/game-state.md` had said the check was immediate; it
|
||||
never was, and it now says what happens.
|
||||
|
||||
### The Expedite fault was charged once per question
|
||||
|
||||
The Mainline phase is re-entered from the top after every clearance, Yard Office and Red Flag
|
||||
ruling, and the Q3 fault loop at the top ran unguarded — an Expedited train left on a siding was
|
||||
fined once per interruption. Once per phase now. The rules document had also still described the
|
||||
v0.4.8 reading of Expedite (departs at Shift Change), corrected in the code in v0.4.9; both passages
|
||||
now describe the rule as built.
|
||||
|
||||
### A train held at the Limits was only ever released by another arrival
|
||||
|
||||
`arriveAtOffice` promised "held at the Limits until an A/D track frees up", and the only release
|
||||
was inside `arriveAtOffice` for a DIFFERENT train. An Office that emptied by departures kept its
|
||||
held train at the Limits for the rest of the game, invisible — no transit, no A/D track, no part in
|
||||
the clearance check. At the end of every Mainline phase, held trains now take any free tracks in
|
||||
the order they were held, and the history says a departure freed the track rather than naming an
|
||||
arriving train that does not exist.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -84,7 +84,7 @@ station-master/
|
||||
├── CHANGELOG.md ← what changed and why, in detail, commit to commit
|
||||
├── TODO.md ← open questions, provisional numbers, things to come back to
|
||||
├── docs/
|
||||
│ ├── rules/ ← the ruleset, card reference, glossary, decision record
|
||||
│ ├── rules/ ← the ruleset, glossary, decision record (the card tables are in docs/*-deck.md)
|
||||
│ ├── architecture/ ← how it is built, and what the pieces are
|
||||
│ ├── plans/ ← worked plans for a single change, kept for the reasoning
|
||||
│ └── design/ ← board layout studies and rendering samples
|
||||
@@ -124,7 +124,7 @@ syntax**: no `enum`, no parameter properties, no namespaces. `tsconfig.json` enf
|
||||
|
||||
```sh
|
||||
node src/sim/harness.ts 200 # how the bot does, with the funnel
|
||||
node src/sim/compare.ts 1600 trainCapSlack=1 # one change, paired against the current bot
|
||||
node src/sim/compare.ts 1600 noValueLays=1 # one ablation, paired against the current bot
|
||||
```
|
||||
|
||||
**Never judge a heuristic on an unpaired run.** Revenue has σ ≈ 9 across games, so two runs of the
|
||||
@@ -134,10 +134,11 @@ standard error at ±0.13, in under two minutes. Keep a change at **t ≥ 3**, an
|
||||
better/worse/identical split beside the mean: a gain carried by a few rescued games is a different
|
||||
claim from one spread across the field.
|
||||
|
||||
Variants come from `makeDeveloperBot(tweaks)`. A tweak is **temporary** — when it measures well it
|
||||
becomes the default and the flag is deleted in the same commit; when it measures badly it is deleted
|
||||
with the finding recorded in `CHANGELOG.md`. A bot that accumulates switches nobody can account for
|
||||
is the thing this machinery exists to prevent.
|
||||
Variants come from `makeDeveloperBot(tweaks)`. Every flag is an **ablation**: it turns OFF a
|
||||
heuristic that is now the bot's default play (`noPlanSwitching`, `noValueLays`, …), so an adopted
|
||||
heuristic can be re-measured when the deck or the rules move under it. A candidate that measures
|
||||
badly is deleted, with the finding recorded in `CHANGELOG.md` — a switch nobody turns on is a switch
|
||||
nobody maintains. `compare.ts` lists the flags it accepts and refuses any other name.
|
||||
|
||||
## Design notes worth knowing
|
||||
|
||||
|
||||
@@ -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`;
|
||||
@@ -31,8 +54,13 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
||||
- **Commits and tags are GPG-signed and I cannot make them.** Stage the work, write the message to
|
||||
a file, hand over a `!` command. Tags must be `git tag -s` — a bare `git tag` makes a lightweight
|
||||
tag and `git push --follow-tags` skips it *without any error*.
|
||||
- **Deploy from the right line.** `npm run deploy:web` defaults to the same destination on both
|
||||
lines, so deploying from `station-master/` silently replaces the playtesters' build with main's.
|
||||
- **There is one line now.** The 0.4.9 playtest line was retired on 2026-09-29 (Jesse: "no longer
|
||||
being maintained"), so `npm run deploy:web` from this directory is the deploy. The public site
|
||||
serves the last 0.4.9h build until that is run once from `main`.
|
||||
- **The save check is the LAST thing before the tag, not a thing done during the work.** 0.8.2
|
||||
replayed every server save, wrote "three resume, ten refuse" into its notes, and then put a card
|
||||
into the deck — and shipped with zero of thirteen resuming. Replay the server's saves against the
|
||||
exact tree being tagged (`tryResumeSession`, not `fromSave`).
|
||||
- **A failing test written before the fix is the only thing that proves a fix.** Three releases
|
||||
(v0.7.5 through v0.7.8) each reported the same bug fixed, and each fixed something real that was
|
||||
not the reported fault, because every verification read what the SERVER served rather than
|
||||
@@ -74,7 +102,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
||||
|
||||
## Sections
|
||||
|
||||
1. **Play it at a table** — #39 #35 #42a #40
|
||||
1. **Play it at a table** — CLOSED 2026-09-29: #39 #35 #42a #40 all confirmed at a table
|
||||
2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #75
|
||||
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
|
||||
4. **The screen** — #44 #81 #33 #36
|
||||
@@ -83,7 +111,8 @@ 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
|
||||
11. **The 2026-09-29 audit — what it found and did not fix** — #112 #113 #114 #115 #116 #117
|
||||
|
||||
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
|
||||
@@ -93,6 +122,10 @@ ruling or a lesson).
|
||||
|
||||
## Play it at a table
|
||||
|
||||
**CLOSED 2026-09-29.** Jesse: "The table test was completed." Every item and every checklist line
|
||||
below was met at a table; the section is kept because the measurement in *Preparing the session*
|
||||
(which interruptions fire by themselves and which have to be set up) is the only record of it.
|
||||
|
||||
The largest gap in the project, and none of it is a coding gap. Features are shipped, packed,
|
||||
running on `phoenix.local` — and the items below name the ones no person has met at a board.
|
||||
Everything else in this file waits behind a release; this waits behind an afternoon.
|
||||
@@ -165,41 +198,41 @@ developer present; what it needs is somebody writing down what they saw.
|
||||
|
||||
**Game A — `days: 1`, two humans, two browsers.** Reaches the extension vote in one Day.
|
||||
|
||||
- [ ] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is
|
||||
- [x] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is
|
||||
the exact shape of the bug v0.7.9 fixed).
|
||||
- [ ] While one player has not voted, the other's turn chart says **who** it is waiting on (#35).
|
||||
- [ ] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and
|
||||
- [x] While one player has not voted, the other's turn chart says **who** it is waiting on (#35).
|
||||
- [x] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and
|
||||
does "waiting on Carol" still read once Carol is gone? (#35 — never tested.)
|
||||
- [ ] Reopen it. The history panel comes back **populated**, not empty, and the board is current
|
||||
- [x] Reopen it. The history panel comes back **populated**, not empty, and the board is current
|
||||
(the v0.7.9.5 reconnect fix, never seen by a person).
|
||||
- [ ] Vote yes. The extra Day begins and the official result is **unchanged** from when the
|
||||
- [x] Vote yes. The extra Day begins and the official result is **unchanged** from when the
|
||||
timetable ran out (#35).
|
||||
|
||||
**Game B — ordinary length, two humans, bots to fill.** Everything else.
|
||||
|
||||
- [ ] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer
|
||||
- [x] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer
|
||||
interrupts the Mainline Phase and asks a question mid-thought — is it legible, and does it say
|
||||
which train? (#39)
|
||||
- [ ] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would
|
||||
- [x] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would
|
||||
collide. The hold is offered out of phase (#39).
|
||||
- [ ] A **loaded Extra** is made up and run (#39 — the third of its three).
|
||||
- [ ] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye,
|
||||
- [x] A **loaded Extra** is made up and run (#39 — the third of its three).
|
||||
- [x] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye,
|
||||
does the caption say who and what? (v0.8.0)
|
||||
- [ ] Find the speed that suits you and say what it is — it becomes the committed default.
|
||||
- [ ] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all.
|
||||
- [ ] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot
|
||||
- [x] Find the speed that suits you and say what it is — it becomes the committed default.
|
||||
- [x] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all.
|
||||
- [x] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot
|
||||
contradict itself (v0.8.0.2).
|
||||
|
||||
**Solitaire, five minutes, alone.**
|
||||
|
||||
- [ ] Click through **every field** on the setup screen and confirm the dealt game matches what was
|
||||
- [x] Click through **every field** on the setup screen and confirm the dealt game matches what was
|
||||
chosen (#42a).
|
||||
|
||||
**Whatever else happens.** The two bugs that came out of the 0.7.4-0.7.9 runs were both things
|
||||
nobody set out to test. Write down anything that reads wrong, even where the rule underneath is
|
||||
right — most of this release's defects were legible-but-wrong rather than broken.
|
||||
|
||||
- [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
|
||||
- [x] **#39** — **CONFIRMED at a table, 2026-09-29.** Originally: **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
|
||||
hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end,
|
||||
packed, and running on `phoenix.local` — and nobody has met any of them at a board. **Two are
|
||||
interruptions that stop the Mainline Phase and put a question in front of somebody
|
||||
@@ -207,21 +240,20 @@ right — most of this release's defects were legible-but-wrong rather than brok
|
||||
happens by itself — 0/10 games. See Preparing the session above for what to set up.** See
|
||||
**Reference · #39**.
|
||||
|
||||
- [ ] **#35** — **Extended play has never been played at a real table.** It was verified over the HTTP
|
||||
- [x] **#35** — **CONFIRMED at a table, 2026-09-29.** Originally: **Extended play has never been played at a real table.** It was verified over the HTTP
|
||||
API, which renders no dialog — and when a human first reached it in a browser it was unusable
|
||||
(fixed in v0.7.9). The multiplayer vote has still never been driven through two browsers: what a
|
||||
second player sees while waiting, and whether "waiting on Carol" reads once Carol has closed her
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -404,8 +436,9 @@ need RAR or Jesse rather than code.**
|
||||
Gitea#14 closed with those ten listed on the issue so they do not vanish with it. See
|
||||
**Reference · #83**.
|
||||
|
||||
- [ ] **#85** — The 0.4.9 playtest line is behind on a rules ruling, and that was checked rather than
|
||||
assumed. See **Reference · #85**.
|
||||
- [x] **#85** — **MOOT 2026-09-29** — the 0.4.9 playtest line is retired, so it is behind on every ruling
|
||||
since and that no longer matters. Originally: behind on a rules ruling, checked rather than assumed.
|
||||
See **Reference · #85**.
|
||||
|
||||
- [x] **#107** — **May a Small Yard put cars on the NOSE of the engine? YES** — raised by Jesse
|
||||
2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says
|
||||
@@ -424,7 +457,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.
|
||||
|
||||
@@ -556,9 +591,11 @@ non-player advantage — it reads the board, never the deck.
|
||||
Dead code, untrustworthy tests, and things carried but not used. Individually small; the reason they
|
||||
are one section is that each one found the next.
|
||||
|
||||
- [ ] **#46** — 29 unused declarations across 14 files, and the build does not run the flag that finds
|
||||
them. **The flag matters more than the 29** — two are Gitea#18 leftovers in one file, one found
|
||||
by hand and the other missed. Do #48 first; it settles ten of them. See **Reference · #46**.
|
||||
- [x] **#46** — **DONE 2026-09-29 (v0.8.5).** The 36 it had regrown to are gone and
|
||||
`noUnusedLocals` + `noUnusedParameters` are on in `tsconfig.json`, so the list cannot regrow.
|
||||
The ten in `sim/replay.ts` were unused IMPORTS, removed without deciding #48 — that question is
|
||||
untouched. Two dead bot functions (`wouldBuryTheEngine`, `strandedWantedCars`) were rejected
|
||||
candidates left behind; `isLegal` and `restoreRng` had no callers. See **Reference · #46**.
|
||||
|
||||
- [ ] **#84** — Five test fixtures pinned a seed and meant "a game like this". All five broke on
|
||||
Gitea#14 for that reason. See **Reference · #84**.
|
||||
@@ -582,27 +619,89 @@ 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.
|
||||
- [ ] **#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**.
|
||||
|
||||
**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.
|
||||
## The 2026-09-29 audit — what it found and did not fix
|
||||
|
||||
**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.
|
||||
Four reviewers read the engine, the server, the browser client and the sim/tests/hygiene, and every
|
||||
finding was re-verified against the code before anything was acted on. v0.8.3 (engine), v0.8.4
|
||||
(transport) and v0.8.5 (housekeeping) took the faults; these are the findings that were real and
|
||||
were NOT fixed, each with the reason, so nothing quietly evaporates.
|
||||
|
||||
- [ ] **#112** — **The structure proposal.** `docs/plans/structure.md`: a route table with auth
|
||||
wrappers for `http.ts`; five extractions and a `Selection` value for `main.ts`; `check` and
|
||||
`reduce` split per phase; one `carCategory`; one `Push` type. Each names the test it makes
|
||||
possible. Ordered by payoff; the first two are afternoons. Do the `http.ts` table before the
|
||||
next route (#20's display stream).
|
||||
|
||||
- [ ] **#113** — **Server faults left as found.** (a) A second SSE connection from the same seat
|
||||
shadows the first without ending it, and the old socket's close then broadcasts "disconnected"
|
||||
for a seat that is still there — end the old response on replace, and only announce a close
|
||||
when the closing response is the live one. (b) Seat tokens travel in URLs on `/api/intent`,
|
||||
`/api/save`, `/api/session`, and the JOIN SECRET on `/api/lobby/preview?secret=` — every
|
||||
reverse proxy's access log holds them; `lobby-and-sessions.md` §1 says keep them out. Move to a
|
||||
header or the body (EventSource forces the two stream routes). (c) `gameCodes` is not seeded
|
||||
from running games on boot, so a game that survived a restart finishes with `gameCode: ''` in
|
||||
the index and the admin listing, and `freshGameCode` can reissue its code. (d) `/api/lobby/start`
|
||||
mutates memory and tells every lobby watcher the game began BEFORE the writes; a failed write
|
||||
resurrects the lobby on restart with a fresh seed. (e) Secret comparisons are `!==`;
|
||||
`timingSafeEqual` costs nothing. (f) An admin can mint a claim for a lobby seat that `/api/claim`
|
||||
then cannot redeem. (g) `everConnected` is never pruned on delete. (h) `body.config` from the
|
||||
host is never shape-checked — a bad one wedges the lobby at Start with a 500 each time.
|
||||
|
||||
- [ ] **#114** — **Client faults left as found.** (a) Rules refusals and transport failures are
|
||||
invisible: every `void session.submit(...)` discards the `false`, and `lobby.ts`'s `postJson`
|
||||
has no catch, so a host who presses Start while the server restarts sits on "Starting…" until
|
||||
a reload. (b) `lobby.ts` reads `localStorage` bare (four sites) where `main.ts` guards every
|
||||
access — a browser with site storage blocked throws before any button is wired. (c) `claimSeat`
|
||||
awaits with no try: a 502 leaves the lobby doors drawn and dead. (d) `build-web.ts` stamps
|
||||
`sha-dirty` for every dirty build of one commit, so two dirty deploys publish byte-identical
|
||||
module URLs and a returning browser serves stale modules against new HTML. (e) "New game" on
|
||||
the results screen does `location.search = ''`, which the code elsewhere asserts is a no-op
|
||||
when the search is already empty — the save is wiped and the player stays on the finished
|
||||
board; `commitNewGame` has the `reload()` fallback, this button does not. (f) The animation
|
||||
loop outlives the session: `leavegame` does not reset the step queue, so rejoining another game
|
||||
runs the old game's steps against the new session until the first push. (g) Seven independent
|
||||
HTML-escape helpers with differing coverage, none escaping `'`; no test feeds a display name
|
||||
containing `<` or `"`. No XSS was found; the risk is the next helper.
|
||||
|
||||
- [ ] **#115** — **Engine drift left as found.** (a) `maneuver.flyingSwitch` is a weaker copy of
|
||||
`switch.dropCars` — no `switchingRefusal`, no `engineAt` clamp, no `standingWest` handling — latent
|
||||
at 0 copies, wrong the day the card is dealt. (b) Car category is spelled three times
|
||||
(`acceptsCar`, `newTrainPhase`, `isFreight`/`carriesLoad`). (c) `redFlag.play` emits a
|
||||
`phaseEnded` the reducer ignores — a no-op intent offered whenever the Emergency Toolbox is on;
|
||||
either the toolbox or the intent is vestigial. **Needs Jesse.** (d) The hand limit is enforced
|
||||
only on `draw.end`; `switch.end` and `freightAgent.end` let a `sixRandom` hand stay at six all
|
||||
game, and `card.discard` is ungated by option. (e) `freightAgent.unjam` from an inbound box
|
||||
returns the car `pooled()` but loaded — the same "coach that can never unload again" 0.8.1.0
|
||||
fixed for `clearInbound`. (f) `mainlinePhase` iterates a snapshot of trays after `collide`
|
||||
deletes some, so later per-train logic in that loop reads a dead tray; benign today. (g) A
|
||||
`Map`-order dependence in candidate ordering that would not survive deserialising state from
|
||||
JSON with a different key order — worth one comment in `legal.ts`.
|
||||
|
||||
- [ ] **#116** — **Test-suite faults left as found.** (a) `test/card-reference.test.ts` REWRITES
|
||||
`docs/home-deck.md` and `mainline-deck.md` and then compares — when they are stale the test is
|
||||
red AND the diff to inspect is already gone. Generate to a string and compare. (b)
|
||||
`test/track.test.ts` asserts on wall-clock elapsed time (`< 5000 ms`) in the default suite.
|
||||
(c) `test/sim.test.ts` pins 300 games to `seed: 1000 + i*7919` to reach the one where a tank
|
||||
car is first dropped (#84's shape; it is the seven-minute suite that breaks). (d)
|
||||
`test/web.test.ts` slices `src/server/http.ts`'s source text between two constant names. (e)
|
||||
`multiplayer.test.ts` still hard-codes seed 4242 where the playtest line had a seed search.
|
||||
(f) `package.json`'s `test/**/*.test.ts` only works because dash has no globstar and there is
|
||||
exactly one nesting level; spell it `test/*/*.test.ts`.
|
||||
|
||||
- [ ] **#117** — **`/api/save` hands every seat the seed mid-game — NEEDS JESSE.** The seat's own
|
||||
save download returns `seed` while the game is active; in Competitive that is every rival's
|
||||
hand and the deck order, the exact leak `game.ts` strips from the log. But a save without the
|
||||
seed cannot replay, which is the whole point of a save. Jesse (2026-09-29): "We should discuss
|
||||
before making changes on this one." Options on the table: serve the seat's save only once the
|
||||
game is finished; serve it mid-game without the seed (a receipt, not a replay) and with the seed
|
||||
once finished; or accept the leak in Co-op only. See **Reference · #117**.
|
||||
|
||||
---
|
||||
|
||||
@@ -2039,13 +2138,16 @@ 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.
|
||||
|
||||
**BUILT 2026-09-07 in v0.7.9.2, completed in v0.7.9.3, as the first of the two options** — a build step writing Markdown,
|
||||
`scripts/build-card-reference.ts` → `docs/rules/as-built.md`, with `test/card-reference.test.ts`
|
||||
`scripts/build-card-reference.ts` → `docs/rules/as-built.md` (since 0.8.2 the tables are written
|
||||
into `docs/home-deck.md` and `docs/mainline-deck.md` instead), with `test/card-reference.test.ts`
|
||||
re-running the generator and failing when the checked-in file disagrees. All six sections are
|
||||
there, and so is the honesty column: every Enhancement carries its `live` / `dormantSolo` /
|
||||
`unbuilt` status, and the opponent-directed cards say plainly that none of them is dealt.
|
||||
@@ -2099,13 +2201,104 @@ deliberately did NOT rewrite them, on the grounds that changing a Laborer count
|
||||
decision rather than a documentation one. **That reasoning still stands and nothing was changed in
|
||||
the engine.**
|
||||
|
||||
What changed is that `card-reference.md` is no longer where anyone looks. `docs/rules/as-built.md`
|
||||
is generated from `content.ts` and carries the industry table the game actually runs; the old file
|
||||
What changed is that `card-reference.md` is no longer where anyone looks. The card tables (in
|
||||
`docs/home-deck.md` and `docs/mainline-deck.md` since 0.8.2; `docs/rules/as-built.md` before) are
|
||||
generated from `content.ts` and carries the industry table the game actually runs; the old file
|
||||
keeps its SUPERSEDED banner, now pointing forward, and its numbers are read as what the v0.4.5
|
||||
placeholder said. **The balance question the entry was really guarding is #70** (the rolling stock
|
||||
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.
|
||||
|
||||
|
||||
### The 2026-09-29 audit
|
||||
|
||||
#### #117 — `/api/save` and the seed
|
||||
|
||||
Found by the server reviewer, verified: `http.ts`'s `/api/save` returns `session.exportSave()` —
|
||||
seed, config, names, full history — to any valid seat token while `status === 'active'`. The comment
|
||||
above the route says "every one of those moves is already on this player's screen", which is true
|
||||
of the moves and false of the seed. `game.ts` strips the seed from the shared log for exactly this
|
||||
reason ("handed each of them the whole future of the deal"), `test/redaction.test.ts` pins that a
|
||||
Frame never carries it, and `/api/lobby/preview` refuses to send it. Jesse's question, which is the
|
||||
right one: a save without the seed is not a save. The finished-game-only answer keeps the download
|
||||
button honest (it works; it just waits for the end); the receipt answer keeps a mid-game download
|
||||
possible but replays nothing; the Co-op answer accepts that a co-operative table has nothing to hide.
|
||||
|
||||
## Done
|
||||
|
||||
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
|
||||
@@ -2416,7 +2609,8 @@ the numbers stay so cross-references above and below still resolve.
|
||||
it describes the v0.4.5 deck, where 3/4 is a Mail-Express with three coaches against a `content.ts`
|
||||
whose train 3 is the Express with two freight cars. **The fix is not a rewritten table** — every
|
||||
file in `docs/rules/` is a deliberate historical record and worth more intact than patched. A new
|
||||
`as-built.md` is GENERATED from the same catalogues the engine instantiates from, by
|
||||
`as-built.md` (folded into `docs/home-deck.md` and `docs/mainline-deck.md` in 0.8.2) is
|
||||
GENERATED from the same catalogues the engine instantiates from, by
|
||||
`scripts/build-card-reference.ts` (`npm run build:cards`), and `test/card-reference.test.ts`
|
||||
re-runs the generator and fails if the checked-in file disagrees. **Worth knowing:** a
|
||||
hand-written replacement would have drifted the same way and for the same reason — nothing fails
|
||||
|
||||
@@ -60,7 +60,9 @@ is what makes the whole system testable without a server.
|
||||
|
||||
- **Where:** Engine · **When:** loaded once at startup, immutable thereafter
|
||||
- **Does:** the 52-card deck composition, freight and passenger facility profiles, Modifier effects,
|
||||
train consist specs, track geometries. Source: [`../rules/card-reference.md`](../rules/card-reference.md)
|
||||
train consist specs, track geometries. Source: `docs/Deck cards5.xlsx` and the PDFs, transcribed
|
||||
into `content.ts`; the generated tables in [`../home-deck.md`](../home-deck.md) and
|
||||
[`../mainline-deck.md`](../mainline-deck.md) are what it deals today
|
||||
- **MVP: S** · **Final: S** · ~200 → ~350 LOC
|
||||
- **Size driver:** it is data, not logic. Grows only if card variants are added.
|
||||
- **Note:** every number here is provisional and will be retuned repeatedly. Keep it as data files,
|
||||
|
||||
@@ -131,24 +131,24 @@ Office; everything else is Secondary Track.
|
||||
```
|
||||
OfficeArea
|
||||
owner : PlayerIndex
|
||||
officeType : whistlePost | depot | station | terminal
|
||||
tier : whistlePost | depot | station | terminal -- `OfficeTier`; `officeType` in early drafts
|
||||
grid : Map<GridCoord, TrackCard> -- sparse; cards are placed during play
|
||||
officeCoord : GridCoord -- where the Office card sits
|
||||
runningRow : integer -- the grid row that is the Running Track
|
||||
limitsWest : GridCoord -- moves outward as the Running Track grows
|
||||
limitsEast : GridCoord
|
||||
adOccupancy : CrewTrayId[] -- length ≤ adTrackCount(officeType)
|
||||
adOccupancy : CrewTrayId[] -- length ≤ adTrackCount(tier)
|
||||
```
|
||||
|
||||
```
|
||||
adTrackCount: whistlePost 1 | depot 2 | station 3 | terminal 4 -- §11.1
|
||||
isControlPoint = officeType != whistlePost
|
||||
isPassengerFacility = officeType != whistlePost
|
||||
isControlPoint = tier != whistlePost
|
||||
isPassengerFacility = tier != whistlePost
|
||||
```
|
||||
|
||||
**Office cards are geometrically interchangeable** (§11.3). All four tiers carry the same track
|
||||
footprint — a through track plus a plain junction stub above and below — and differ *only* in the
|
||||
three properties above. An upgrade therefore changes `officeType` and nothing else; it must never
|
||||
three properties above. An upgrade therefore changes `tier` and nothing else; it must never
|
||||
touch `grid`, `connections`, or anything attached to the Office card. The junction stubs carry no
|
||||
directional restriction: §A.1's turnout rule governs drawn turnout cards only.
|
||||
|
||||
@@ -187,7 +187,7 @@ These are the ones that will be got wrong if they are not written down explicitl
|
||||
9. **An Office upgrade must preserve every connection** (§11.3). All four tiers share identical
|
||||
geometry precisely so the upgrade is a property change, not a card swap. An implementation that
|
||||
models the upgrade as "remove old card, place new card" will silently orphan any Secondary Track
|
||||
hanging off the Office — mutate `officeType` in place instead.
|
||||
hanging off the Office — mutate `tier` in place instead.
|
||||
10. **The Office card's stubs are not turnouts.** Do not route them through the §A.1 directional
|
||||
logic; a train may pass between the Running Track and either Secondary row freely.
|
||||
|
||||
@@ -258,8 +258,9 @@ Facility
|
||||
modifiers : ModifierRef[] -- adjacent cards raising capacity, track length or workers
|
||||
```
|
||||
|
||||
All of these are populated from [`../rules/card-reference.md`](../rules/card-reference.md), which is
|
||||
the authoritative per-card catalogue.
|
||||
All of these are populated from `content.ts`, transcribed from the catalogue spreadsheets; the
|
||||
generated tables in [`../home-deck.md`](../home-deck.md) and [`../mainline-deck.md`](../mainline-deck.md)
|
||||
are the per-card reference (`card-reference.md` is the superseded placeholder).
|
||||
|
||||
**`industryTrack` is where cars are spotted for loading and unloading**, and it is the field that
|
||||
stops being Operational Rail while `menAtWork` holds any load (constraint 4 below). Its `length` is
|
||||
@@ -275,8 +276,9 @@ the car (§9.3). A Porter earns a point in one action. This asymmetry is deliber
|
||||
per Stage, and stocking a green box or clearing a red one consumes the whole of it (§6.3). That
|
||||
one-action-per-Stage budget is what actually limits Revenue — roughly one point per action, ceiling
|
||||
12 per Day. Worker counts mostly determine how often a facility idles. See
|
||||
[`../rules/card-reference.md`](../rules/card-reference.md#7-economy-summary) for the full model; it is
|
||||
what §3's targets are calibrated against.
|
||||
[`../rules/card-reference.md`](../rules/card-reference.md#7-economy-summary) for the model as it was
|
||||
first worked out (that document is otherwise superseded); the figures §3's targets were calibrated
|
||||
against are the measurements in `TODO.md`'s Play balance section.
|
||||
|
||||
---
|
||||
|
||||
@@ -328,8 +330,10 @@ Outcome
|
||||
|
||||
Termination checks, in the order they must be evaluated:
|
||||
|
||||
1. **Collision floor** (Competitive only) — `collisionsToday >= 3` ends the game immediately, all
|
||||
players lose (§3.4). Checked the moment a collision resolves, not at end of Stage.
|
||||
1. **Collision floor** (every mode) — `collisionsToday >= maxCollisionsPerDay` or
|
||||
`collisionsTotal >= maxCollisionsTotal` ends the game, all players lose (§3.4). Judged at the
|
||||
Shift Change that closes the Stage, on the Stage just played and before the Day rolls over — so
|
||||
Load/Unload still scores in the Stage of the breach, and a breach in Stage 12 counts.
|
||||
2. **Target reached** (firstToTarget) — checked whenever Revenue increases.
|
||||
3. **Days elapsed** (highestAfterDays) — at the end of the final Day, apply the collective Revenue
|
||||
floor `3 × players × Days` for Competitive (§3.5), or the mode target for Solitaire and Co-op
|
||||
|
||||
@@ -107,7 +107,9 @@ clashing join is refused (`NAME_TAKEN`, compared trimmed and case-insensitively)
|
||||
suffixed: a player should play under the name they chose, or be asked for another.
|
||||
|
||||
**Anybody may leave, and the host may clear a chair.** `Lobby.Leave` frees the seat, drops the token
|
||||
from `joinOrder`, and passes host rights on exactly as a dropped connection does. Naming somebody
|
||||
from `joinOrder`, **revokes it** — in memory and in `sessions.json`, since v0.8.4; until then the
|
||||
leaver's token still opened the seat the next arrival took — and passes host rights on exactly as a
|
||||
dropped connection does. Naming somebody
|
||||
else's `seat` is host-only. When the last human leaves, the lobby is deleted outright — code, file and
|
||||
index row — rather than left as a table of bots waiting for a host who no longer exists. Before this
|
||||
existed a mis-join or a player who wandered off wedged the whole table, since Start needs every chair
|
||||
|
||||
@@ -96,7 +96,8 @@ or a replay viewer has to reconstruct the whole board to draw one frame.
|
||||
|
||||
Two consequences worth knowing before adding an event:
|
||||
|
||||
- **`collisionOccurred` carries `faultPlayer` explicitly** rather than leaving clients to derive it.
|
||||
- **`trainsDestroyed` carries the player at fault (`player`) explicitly** rather than leaving clients to
|
||||
derive it (the event was called `collisionOccurred` in this design; the engine emits `trainsDestroyed`).
|
||||
Fault depends on *where* the wreck happened — Superintendent for a Mainline card, the local player
|
||||
between their Limits (§10) — and getting it wrong misattributes a −5 and, in Competitive, feeds a
|
||||
floor that ends the game.
|
||||
@@ -151,8 +152,14 @@ multiplayer work: everything else degrades gracefully, a redaction bug hands a p
|
||||
- Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on
|
||||
a gap rather than guessing.
|
||||
- Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a
|
||||
reconnecting client can safely resend anything it is unsure about.
|
||||
reconnecting client can safely resend anything it is unsure about. **The count is the server's,
|
||||
for the life of the game**: the connect push carries the seat's last accepted `seq` (`lastSeq`)
|
||||
and the client continues from it, never from 1 — a page that restarted its own count after a
|
||||
reload re-sent a number the server had already applied, and the move was silently swallowed as
|
||||
a resend (v0.8.4).
|
||||
- **The server never applies two intents concurrently within a game.** A per-game queue is sufficient
|
||||
and there is nothing cleverer to do at this scale. Note this is a serialisation rule, not a
|
||||
and there is nothing cleverer to do at this scale. It is a real queue (`http.ts`'s `inTurn`), not a
|
||||
reliance on the single thread: the handler awaits the disk write between applying and answering,
|
||||
and two moves arriving together used to interleave across that await (v0.8.4). Note this is a serialisation rule, not a
|
||||
one-actor-at-a-time rule: per-player turn state (`turns: Map<PlayerIndex, TurnState>`) means several
|
||||
players may hold an open turn at once, and their intents still land one at a time.
|
||||
|
||||
+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.5** · 2026-09-29
|
||||
|
||||
**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
-8
@@ -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]`.
|
||||
@@ -120,6 +120,3 @@ writes a self-contained HTML file — open it in any browser and step through th
|
||||
Division, the Office Area grid, every facility's boxes and `MEN|AT|WORK` track, plain-English
|
||||
narration of each event, and a **Blocked** panel explaining why nothing is moving.
|
||||
|
||||
Running alongside, and independent of all of it: **print-and-play components.** `card-reference.md`
|
||||
specifies every card face, so layout and art are the only remaining work before a table playtest —
|
||||
which answers the one question simulation cannot, whether it is fun.
|
||||
|
||||
+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.5** · 2026-09-29
|
||||
|
||||
**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 until the track frees.
|
||||
|
||||
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.5** · 2026-09-29
|
||||
|
||||
**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.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Structure — a proposal (2026-09-29)
|
||||
|
||||
What the 2026-09-29 audit found about the SHAPE of the code, as distinct from its faults, and what
|
||||
to do about it. Nothing here changes behaviour; each item is a seam that would let the next fault be
|
||||
found by a test instead of by a reviewer reading three thousand lines. Ordered by payoff.
|
||||
|
||||
The faults the audit fixed in 0.8.3 and 0.8.4 were nearly all in the three largest functions or in
|
||||
the one file no test stood up. That is the argument: the structure is where the bugs were.
|
||||
|
||||
## 1. `http.ts` — a route table, and one place for authentication
|
||||
|
||||
**Today.** `startServer` is one 600-line async closure of `if (pathname === … && method === …)`
|
||||
chains. The token → session → lobby-or-game resolution is copy-pasted eight times; the admin-secret
|
||||
check, the join-secret check and the host check are each inline where they are needed. The catch at
|
||||
the bottom is the only error path, and until 0.8.4 it was itself a crash.
|
||||
|
||||
**Proposed.** A table of routes, each `{ method, path, auth, handler }`, with three auth wrappers:
|
||||
|
||||
```
|
||||
withToken(handler) // resolves { ps, lobby?, game? } from the token, 404s if none
|
||||
withHost(handler) // withToken, then refuses anyone but lobby.hostToken
|
||||
withAdmin(handler) // the x-admin-secret gate; the whole prefix is absent when unset
|
||||
```
|
||||
|
||||
Handlers become ten to thirty lines each and take a typed context. The one dispatcher owns
|
||||
`readJson`, `sendJson`, the `HttpError` catch and the `headersSent` guard — one place, so 0.8.4's
|
||||
crash fix cannot be forgotten by the next route. The per-game `inTurn` queue becomes a property of
|
||||
the game context rather than something a handler has to remember to call.
|
||||
|
||||
**Cost.** A day. Every route is exercised by `test/server/http.test.ts` now, so the move is
|
||||
mechanical and verifiable. Do it before adding the next route (the seatless display stream, #20).
|
||||
|
||||
## 2. `main.ts` — five extractions, and a `Selection` value
|
||||
|
||||
**Today.** 3200 lines, ~21 module-level `let`s (`session`, `selected`, `mode`, `pendingAt`,
|
||||
`selectedCrew`, `peekPlayer`, `zoom`, `soundOn`, `gameCode`, `remoteToken`, …), and `start()` runs at
|
||||
import. `render()` is ~470 lines; `renderActions` ~360. `test/web.test.ts` has to stub the DOM before
|
||||
importing, can never build two pages, and cannot call `render()` with a `Frame` of its choosing —
|
||||
which is why every screen fault in the audit was found by reading, not by a test.
|
||||
|
||||
**Proposed, in the order they pay:**
|
||||
|
||||
1. **`web/remote-store.ts`** — `RemoteRecord`, `readStore`/`writeStore`, `loadRemote`/`saveRemote`/
|
||||
`forgetRemote`/`knownRemote`, and `lobbyHandlers`. Pure functions over `localStorage`; testable
|
||||
with a Map. Today they are untestable except through the page.
|
||||
2. **`web/url-options.ts`** — `RULE_PARAMS`/`VICTORY_PARAMS`/`OPTIONAL_PARAMS`, `gameOptionsFromUrl`,
|
||||
`solitaireDefaults`, `rulesToUrl`. A pure round trip; the audit found the `location.search = ''`
|
||||
no-op assumption (#114) by reading this code, and a test on the round trip would have found it.
|
||||
3. **`web/setup-screen.ts`** — `wireGameTypeBlock`, `commitNewGame`, `runSolitaireSetup`.
|
||||
4. **A `Selection` object** replacing the five `let`s (`selected`, `mode`, `pendingAt`,
|
||||
`selectedCrew`, `peekPlayer`) that every click handler mutates. One value, one `reset()`, passed
|
||||
to `render` rather than read from module scope — which is what lets a test call `render(frame,
|
||||
selection)` and assert on the HTML.
|
||||
5. **`web/game-shell.ts`** — session routing: `beginRemote`, `abandonRemote`, `claimSeat`, `start`,
|
||||
`applyCapabilities`, the handoff. This is where the animation loop outliving the session (#114)
|
||||
lives, and it is easier to see once it is not surrounded by rendering.
|
||||
|
||||
Keep `render()` in `main.ts` but split the board-and-hand wiring (~240 lines of `addEventListener`)
|
||||
into `wireBoard(selection)` so what is DRAWN and what is CLICKABLE are separate functions.
|
||||
|
||||
**Cost.** Two to three days across the five, each its own commit; the first two are an afternoon
|
||||
each and carry no risk.
|
||||
|
||||
## 3. The engine — `check` per phase, and one car category
|
||||
|
||||
**Today.** `check` (~600 lines) and `reduce` (~700) are single switches mixing every phase;
|
||||
`moveTrain` nests four position kinds by three decision kinds. Car category (coach / caboose /
|
||||
freight) is computed in three places with three spellings. The seat guard 0.8.3 added to the four
|
||||
switching intents is the same four lines four times.
|
||||
|
||||
**Proposed.**
|
||||
|
||||
- Split `check` by phase — `checkLocalOps`, `checkNewTrain`, `checkLoadUnload`, `checkMainline` —
|
||||
each a switch over its own intents, dispatched by `inPhase`. The shared guards (`ownTray`, the
|
||||
option-chosen test, the Moves test) become the first lines of `checkLocalOps` rather than four
|
||||
repeats. `reduce` splits the same way.
|
||||
- One exported `carCategory(type)` in `content.ts`, used by `acceptsCar`, `newTrainPhase`,
|
||||
`consistNeeds` and `isFreight`/`carriesLoad`.
|
||||
- `maneuver.flyingSwitch` is a weaker copy of `switch.dropCars` (no `switchingRefusal`, no
|
||||
`engineAt` clamp, no `standingWest` handling). It should CALL the dropCars path with a flag,
|
||||
or be deleted until the card is dealt (it is at 0 copies). Deciding is #115's job; the structure
|
||||
point is that there must be one cut-dropping reducer.
|
||||
|
||||
**Cost.** The `check`/`reduce` split is a day and is pure motion — every test in `apply.test.ts`
|
||||
and `advance.test.ts` runs unchanged. `carCategory` is an hour.
|
||||
|
||||
## 4. One `Push` type
|
||||
|
||||
`web/session.ts` mirrors `server/session.ts`'s `Push` by hand so the browser bundle never imports
|
||||
from `src/server/`. Every envelope change (0.8.4 added `lastSeq`) is two edits. Move the wire types
|
||||
— `Push`, `LobbyPush`, `LobbyPreview` — into `src/sim/wire.ts`, which both sides already import
|
||||
from, and delete the mirror.
|
||||
|
||||
**Cost.** An hour.
|
||||
|
||||
## 5. Tests that would then exist
|
||||
|
||||
Each extraction above names the test it makes possible:
|
||||
|
||||
| after | test |
|
||||
| --- | --- |
|
||||
| route table | one test per route for the 404/403/400 answers, from a table |
|
||||
| `remote-store.ts` | the `RemoteRecord` round trip, the "seat tied to this browser" notice (#33) |
|
||||
| `url-options.ts` | `rulesToUrl(gameOptionsFromUrl(x)) === x`, and the `search = ''` case |
|
||||
| `Selection` | `render(frame, selection)` snapshot tests for each screen state |
|
||||
| `check` per phase | the seat guard once, as a property over every switching intent |
|
||||
|
||||
## What not to do
|
||||
|
||||
- Do not refactor `render()` and fix a screen bug in the same commit. The audit's value was that
|
||||
each finding could be verified against unchanged code.
|
||||
- Do not introduce a framework or a runtime dependency to do any of this. The zero-dependency rule
|
||||
is what makes the package a 63 MB `.s9pk` and the site a static upload.
|
||||
+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.5** · 2026-09-29
|
||||
|
||||
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) |
|
||||
|
||||
+52
-34
@@ -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.5** · 2026-09-29
|
||||
|
||||
**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:**
|
||||
|
||||
@@ -188,7 +184,7 @@ Each of 12 Stages follows this sequence:
|
||||
**"Proceeding left" is seat order, west to east**, which is how the Division map draws it — the
|
||||
screen says "eastward" for that reason, because a table has no shared left.
|
||||
|
||||
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. At Day end, dispatch-device use resets, collision count resets, the Day and Stage roll over, and victory is checked.
|
||||
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. The collision limits are judged on the Stage just played, before anything else. At Day end, dispatch-device use resets, collision count resets, the Day and Stage roll over, and victory is checked.
|
||||
|
||||
### 4.2 Local Operations: choose one option
|
||||
|
||||
@@ -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.
|
||||
**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 — the one you name, when a box holds more than one — 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.
|
||||
|
||||
@@ -269,9 +261,9 @@ point. On a normal card, a train must check the entire next Subdivision before e
|
||||
**Uncontrolled Siding** is not a passing card: a train arriving to find it occupied takes the
|
||||
siding a region behind, which costs it the extra Stage instead of a collision.
|
||||
|
||||
An Office arrival normally takes a free A/D track. If the Office is full, the inbound train collides and the local Office player loses 5 Revenue; Interlocking instead holds it at the Limits. A coachless inbound train may divert to a Yard Office. Cars fouling the Running Track at the Office also cause a collision.
|
||||
An Office arrival normally takes a free A/D track. If the Office is full, the inbound train collides and the local Office player loses 5 Revenue; Interlocking instead holds it at the Limits. A held train takes the first A/D track that frees — whether another train's arrival or a departure freed it — ahead of anything arriving after it, and no later than the end of the Mainline phase in which the track became free. A coachless inbound train may divert to a Yard Office. Cars fouling the Running Track at the Office also cause a collision.
|
||||
|
||||
An expedited train remains available for this Stage’s Load/Unload work after arriving, then attempts to depart at Shift Change. A non-expedited arrival waits until a later Mainline phase. A train departing an Office must be correctly made up: engine at one end, caboose at the far end if present.
|
||||
An expedited train is released by the ordinary rules like any other; what Expedite restricts is where it may be left. If it is standing anywhere in the district but the Office when a Mainline phase begins, its owner is fined once for that phase. An arrival waits until a later Mainline phase. A train departing an Office must be correctly made up: engine at one end, caboose at the far end if present.
|
||||
|
||||
When a train leaves the far Division Point, its cars are returned to yards, its Crew Tray becomes free, and each player receives the configured train-transit Revenue (zero by default).
|
||||
|
||||
@@ -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:
|
||||
@@ -415,24 +406,25 @@ The Office had no free A/D track and no Interlocking. A full Office is an automa
|
||||
|
||||
It may be on Secondary Track rather than the Office, or be badly made up: its engine is between cars or its caboose is not at the far end.
|
||||
|
||||
### Why did an expedited train leave after passenger/freight work?
|
||||
### Why was I fined for an expedited train?
|
||||
|
||||
Expedite means it departs in the Stage it arrived, but the departure waits until Shift Change so the
|
||||
train is still present for that Stage's Load/Unload phase.
|
||||
Expedite does not change when a train leaves — it leaves when the Mainline rules release it, like any
|
||||
other. It changes where the train may be left: if a Mainline phase begins with it standing on any
|
||||
track in the district but the Office, the owner is fined. The fine is charged once per Mainline
|
||||
phase, however many clearance questions that phase asks.
|
||||
|
||||
### 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 +434,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 +448,29 @@ 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. A track
|
||||
freed by a departure counts too: the held train takes it at the end of that Mainline phase.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
> 2026-07-30 in `docs/Deck cards2.xlsx`, `Trains3.pdf` and `Mainline Cards.pdf`, and is transcribed
|
||||
> in `src/engine/content.ts`. See [`implications.md`](implications.md) for the full comparison.
|
||||
>
|
||||
> **For what the cards say today, read [`as-built.md`](as-built.md)** — generated from
|
||||
> `content.ts` and checked against it by the test suite, so it cannot fall behind the way this file
|
||||
> did. For several releases `content.ts` named *this* page as the current reference while the banner
|
||||
> **For what the cards say today, read the generated tables in [`home-deck.md`](../home-deck.md)
|
||||
> and [`mainline-deck.md`](../mainline-deck.md)** — written by `npm run build:cards` from
|
||||
> `content.ts` and checked against it by the test suite, so they cannot fall behind the way this
|
||||
> file did. For several releases `content.ts` named *this* page as the current reference while the banner
|
||||
> here said otherwise, and a reader following the code landed on the v0.4.5 deck.
|
||||
>
|
||||
> Kept for the reasoning it records — the economy analysis in §7 was how we knew what questions to
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
{
|
||||
"seed": 116956197,
|
||||
"history": [
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c27"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c30"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c4"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c2"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 2
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c197",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": -1
|
||||
},
|
||||
"variant": 1
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 2
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c163",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": -1
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c41",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c127",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": 1
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c183",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": 2
|
||||
},
|
||||
"variant": 1
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c150",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": 2
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 1
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c136",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": 1
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.discard",
|
||||
"cardId": "c77",
|
||||
"toSlot": 1
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.discard",
|
||||
"cardId": "c171",
|
||||
"toSlot": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "boxcar",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "boxcar",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.discard",
|
||||
"cardId": "c153",
|
||||
"toSlot": 1
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c9"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "coach",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "coach",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"startingHand": "sixRandom",
|
||||
"revenue": {
|
||||
"passengerPerCoach": 1,
|
||||
"freightPerLoad": 1,
|
||||
"trainPerTransit": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,714 +0,0 @@
|
||||
{
|
||||
"seed": 116956197,
|
||||
"history": [
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c27"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c30"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c4"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c2"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 2
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c197",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": -1
|
||||
},
|
||||
"variant": 1
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 2
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c163",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": -1
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c41",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c127",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": 1
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c183",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": 2
|
||||
},
|
||||
"variant": 1
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c150",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": 2
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 1
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c136",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": 1
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.discard",
|
||||
"cardId": "c77",
|
||||
"toSlot": 1
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.discard",
|
||||
"cardId": "c171",
|
||||
"toSlot": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "boxcar",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "boxcar",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.discard",
|
||||
"cardId": "c153",
|
||||
"toSlot": 1
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c9"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "coach",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "coach",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": -2
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray3",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": -2
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.end"
|
||||
},
|
||||
{
|
||||
"type": "laborer.beginUnload",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"carIndex": 0
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.discard",
|
||||
"cardId": "c162",
|
||||
"toSlot": 2
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "porter.detrain",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "porter.detrain",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"box": 2
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.clearInbound",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"box": 1
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.clearInbound",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"box": 0
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.stockOutbound",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"carType": "boxcar"
|
||||
},
|
||||
{
|
||||
"type": "laborer.startLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.stockOutbound",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"carType": "coach"
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "boxcar",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "boxcar",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "boxcar",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "caboose",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.clearInbound",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"box": 0
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.stockOutbound",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"carType": "coach"
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"box": 1
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.discard",
|
||||
"cardId": "c50",
|
||||
"toSlot": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"box": 2
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": -2
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray2",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 3
|
||||
},
|
||||
"reverse": false,
|
||||
"via": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 3
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 1
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray2",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 3
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "tank",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "tank",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray2",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 3
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": -2
|
||||
},
|
||||
"reverse": true,
|
||||
"via": {
|
||||
"row": 0,
|
||||
"col": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray2",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.end"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"startingHand": "sixRandom",
|
||||
"revenue": {
|
||||
"passengerPerCoach": 1,
|
||||
"freightPerLoad": 1,
|
||||
"trainPerTransit": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,656 +0,0 @@
|
||||
{
|
||||
"seed": 493290760,
|
||||
"history": [
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c138",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": -1
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c144",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": 1
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c11"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c24"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 1
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c31"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.stockOutbound",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"carType": "coach"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.stockOutbound",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"carType": "coach"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 1
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c182",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": 1
|
||||
},
|
||||
"variant": 1
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c159",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": 1
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c49",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c5"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "tank",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "tank",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "caboose",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.end"
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "coach",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "coach",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "mainline.clearance",
|
||||
"allow": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 2
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.end"
|
||||
},
|
||||
{
|
||||
"type": "porter.detrain",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "porter.board",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 2
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": -1
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray3",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 2
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray3",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 2
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": -1
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.stockOutbound",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"carType": "tank"
|
||||
},
|
||||
{
|
||||
"type": "laborer.startLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 0
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c194",
|
||||
"placement": {
|
||||
"row": 0,
|
||||
"col": -2
|
||||
},
|
||||
"variant": 1
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"box": 0
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.stockOutbound",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
},
|
||||
"carType": "coach"
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"box": 1
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.end"
|
||||
},
|
||||
{
|
||||
"type": "laborer.advanceLoad",
|
||||
"at": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"box": 2
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromDepartment",
|
||||
"slot": 1
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c154",
|
||||
"placement": {
|
||||
"row": 1,
|
||||
"col": -2
|
||||
},
|
||||
"variant": 0
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "card.play",
|
||||
"cardId": "c102",
|
||||
"node": 1
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.end"
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "boxcar",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "tank",
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray3",
|
||||
"carType": "caboose",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "draw"
|
||||
},
|
||||
{
|
||||
"type": "draw.fromHomeOffice"
|
||||
},
|
||||
{
|
||||
"type": "draw.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "freightAgent"
|
||||
},
|
||||
{
|
||||
"type": "freightAgent.end"
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "coach",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "newTrain.placeCar",
|
||||
"trayId": "tray2",
|
||||
"carType": "coach",
|
||||
"loaded": true
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 1,
|
||||
"col": -2
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray3",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray3",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 2
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 1,
|
||||
"col": 0
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": 2
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 1,
|
||||
"col": -2
|
||||
},
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray3",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"type": "switch.dropCars",
|
||||
"trayId": "tray3",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"type": "switch.end"
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
},
|
||||
{
|
||||
"type": "switch.end"
|
||||
},
|
||||
{
|
||||
"type": "porter.detrain",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "porter.board",
|
||||
"at": {
|
||||
"row": 0,
|
||||
"col": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "localOps.choose",
|
||||
"option": "switch"
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 0,
|
||||
"col": -1
|
||||
},
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"type": "switch.move",
|
||||
"trayId": "tray3",
|
||||
"to": {
|
||||
"row": 1,
|
||||
"col": -2
|
||||
},
|
||||
"reverse": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.1.0",
|
||||
"version": "0.8.5",
|
||||
"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
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,286 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* One list at one indent level. An item is its first line plus any lines indented under it; the
|
||||
* more-indented BULLETS among those are the item's own nested list and render recursively, while
|
||||
* plain indented lines are wrapped continuations of its text.
|
||||
*/
|
||||
function listHtml(block: string[], text: (s: string) => string): string {
|
||||
const first = /^(\s*)([-*+]|\d+[.)])\s+/.exec(block.find((l) => l.trim() !== '') ?? '');
|
||||
if (!first) return '';
|
||||
const ordered = /\d/.test(first[2]!);
|
||||
const base = first[1]!.length;
|
||||
const items: { text: string[]; sub: string[] }[] = [];
|
||||
for (const l of block) {
|
||||
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l);
|
||||
const current = items[items.length - 1];
|
||||
if (m && m[1]!.length <= base) {
|
||||
items.push({ text: [m[3]!], sub: [] });
|
||||
} else if (!current) {
|
||||
continue;
|
||||
} else if (current.sub.length > 0 || (m && m[1]!.length > base)) {
|
||||
// Once a nested list has begun, everything further belongs to it, wrapped lines included.
|
||||
current.sub.push(l);
|
||||
} else if (l.trim() !== '') {
|
||||
current.text.push(l.trim());
|
||||
}
|
||||
}
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
`<${tag}>` +
|
||||
items.map((it) => `<li>${text(it.text.join(' '))}${it.sub.length > 0 ? listHtml(it.sub, text) : ''}</li>`).join('') +
|
||||
`</${tag}>`
|
||||
);
|
||||
}
|
||||
|
||||
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, and
|
||||
// a more-indented bullet under an item is a nested list, rendered by recursion (v0.8.4 — the
|
||||
// comment said so before and the code appended the nested bullet to its parent as text, so the
|
||||
// published home-deck page carried a literal "- " mid-sentence).
|
||||
const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
||||
if (bullet) {
|
||||
const baseIndent = bullet[1]!.length;
|
||||
const block: string[] = [];
|
||||
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;
|
||||
block.push('');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const m = /^(\s*)([-*+]|\d+[.)])\s+/.exec(l);
|
||||
if ((m && m[1]!.length <= baseIndent) || (m && m[1]!.length > baseIndent) || /^\s{2,}\S/.test(l)) {
|
||||
block.push(l);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
ln(listHtml(block, text));
|
||||
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 };
|
||||
}
|
||||
+162
-31
@@ -469,7 +469,13 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
* there, not a one-time slip. A train the ordinary §8.1 rules are holding at the Office itself is
|
||||
* unaffected — this only bites when the train is not even in the queue to leave.
|
||||
*/
|
||||
for (const [, tray] of order) {
|
||||
/**
|
||||
* ONCE PER PHASE, NOT ONCE PER QUESTION (v0.8.3). This function is re-entered from the top after
|
||||
* every clearance, Yard Office and Red Flag ruling, and the loop below ran unguarded — so a train
|
||||
* left on a siding was fined once per interruption. A pending answer is the mark of a resumption:
|
||||
* it is set by the ruling and consumed further down, inside the move it belongs to.
|
||||
*/
|
||||
for (const [, tray] of s.clock.decisionAnswer === null ? order : []) {
|
||||
if (!isExpedited(tray) || tray.position.at !== 'grid') continue;
|
||||
const area = areaAtSeat(s, tray.position.seat);
|
||||
const { coord } = tray.position;
|
||||
@@ -555,10 +561,41 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
s.movedThisPhase.add(id);
|
||||
}
|
||||
|
||||
releaseHeldAtLimits(s, events);
|
||||
s.movedThisPhase = new Set();
|
||||
return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* A TRAIN HELD AT THE LIMITS TAKES A TRACK THAT FREES — whoever freed it (v0.8.3).
|
||||
*
|
||||
* `arriveAtOffice` promises "held at the Limits until an A/D track frees up", and until now the
|
||||
* only release was inside `arriveAtOffice` itself, for a DIFFERENT train arriving. An Office that
|
||||
* emptied by departures alone kept its held train at the Limits for the rest of the game — with no
|
||||
* transit, no place in `adOccupancy` and no part in the clearance check, so nothing on the board or
|
||||
* in the rules could see it. Every train has now attempted its move for this Phase, so any track
|
||||
* still free is genuinely free, and the held trains take them in the order they were held.
|
||||
*/
|
||||
function releaseHeldAtLimits(s: GameState, events: GameEvent[]): void {
|
||||
for (const area of s.officeAreas.values()) {
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
while (area.heldAtLimits.length > 0 && area.adOccupancy.length < capacity) {
|
||||
const id = area.heldAtLimits.shift()!;
|
||||
const held = s.trays.get(id);
|
||||
if (!held) continue;
|
||||
area.adOccupancy.push(id);
|
||||
held.position = { at: 'grid', seat: area.seat, coord: area.officeCoord };
|
||||
events.push({
|
||||
type: 'trainReleasedFromLimits',
|
||||
trainNumber: held.trainNumber ?? 0,
|
||||
office: officeProfile(area.tier).name,
|
||||
owner: playerAtSeat(s, area.seat),
|
||||
freedBy: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MoveOutcome = 'moved' | 'held' | 'needsClearance';
|
||||
|
||||
/**
|
||||
@@ -693,7 +730,6 @@ function entryConflict(
|
||||
|
||||
/** Puts a train onto a Mainline card with its crossing time already computed. */
|
||||
function enterMainline(
|
||||
s: GameState,
|
||||
node: Extract<DivisionNode, { kind: 'mainline' }>,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
@@ -825,7 +861,7 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
if (conflict === 'held') return 'held';
|
||||
if (conflict === 'collided') return 'moved';
|
||||
|
||||
enterMainline(s, node, id, tray, target);
|
||||
enterMainline(node, id, tray, target);
|
||||
const dp = s.division.nodes[dpIndex];
|
||||
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
|
||||
events.push({
|
||||
@@ -889,7 +925,7 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
// The wreck's A/D track is released by `collide` itself, which is why it has to be.
|
||||
if (conflict === 'collided') return 'moved';
|
||||
|
||||
enterMainline(s, node, id, tray, target);
|
||||
enterMainline(node, id, tray, target);
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
events.push({
|
||||
type: 'trainHighballed',
|
||||
@@ -961,7 +997,7 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
if (conflict === 'collided') return 'moved';
|
||||
|
||||
node.holding = node.holding.filter((t) => t !== id);
|
||||
enterMainline(s, node, id, tray, index, true);
|
||||
enterMainline(node, id, tray, index, true);
|
||||
events.push({
|
||||
type: 'trainHighballed',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
@@ -1101,9 +1137,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 +1185,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 +1237,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';
|
||||
}
|
||||
|
||||
@@ -1311,7 +1399,7 @@ function redFlagStop(
|
||||
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||
});
|
||||
if (!holdsFlag) return 'proceed';
|
||||
if (!arrivalWouldCollide(s, id, tray, dest.seat)) return 'proceed';
|
||||
if (!arrivalWouldCollide(s, dest.seat)) return 'proceed';
|
||||
|
||||
s.clock.pendingDecision = { kind: 'redFlag', train: id, seat: dest.seat, from };
|
||||
return 'ask';
|
||||
@@ -1324,7 +1412,7 @@ function redFlagStop(
|
||||
* if these two ever diverge, the prompt offers a flag against a collision that will not happen, or
|
||||
* stays silent before one that will.
|
||||
*/
|
||||
function arrivalWouldCollide(s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex): boolean {
|
||||
function arrivalWouldCollide(s: GameState, seat: SeatIndex): boolean {
|
||||
const area = areaAtSeat(s, seat);
|
||||
const hasInterlocking = [...area.grid.values()].some((c) => c.enhancements.includes('interlocking'));
|
||||
const full = area.adOccupancy.length >= officeProfile(area.tier).adTracks;
|
||||
@@ -1495,12 +1583,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);
|
||||
|
||||
@@ -1712,31 +1839,16 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
}
|
||||
}
|
||||
|
||||
if (s.clock.stage >= STAGES_PER_DAY) {
|
||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
|
||||
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
|
||||
s.collisionsPrevDay = s.collisionsToday;
|
||||
s.collisionsToday = 0;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||
rotateSeats(s, events);
|
||||
const finished = checkVictory(s, events);
|
||||
if (finished) return { events, needsInput: false };
|
||||
} else {
|
||||
s.clock.stage += 1;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
/**
|
||||
* §3.4 — EVERY MODE, SOLITAIRE INCLUDED: a Day's collisions against `maxCollisionsPerDay` and the
|
||||
* game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not scaled
|
||||
* by player count — Jesse's call, 2026-08-20: more players is more independent chances to collide,
|
||||
* not a bigger shared budget.
|
||||
*
|
||||
* JUDGED BEFORE THE DAY ROLLS OVER (v0.8.3). This block sat below the rollover, which resets
|
||||
* `collisionsToday` — so a breach reached in Stage 12 was read as zero and the last Stage of every
|
||||
* Day was the one Stage the floor could not fire in. Pinned in `advance.test.ts`.
|
||||
*
|
||||
* SOLITAIRE WAS EXCLUDED UNTIL 2026-08-30 and nothing said so. The gate here read `mode ===
|
||||
* 'competitive' || mode === 'coop'`, while `SOLO_CONFIG` carried both limits and the New Game
|
||||
* dialog offered them as live settings — so a solitaire player could set a collision limit, read
|
||||
@@ -1769,6 +1881,25 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
}
|
||||
}
|
||||
|
||||
if (s.clock.stage >= STAGES_PER_DAY) {
|
||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
|
||||
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
|
||||
s.collisionsPrevDay = s.collisionsToday;
|
||||
s.collisionsToday = 0;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||
rotateSeats(s, events);
|
||||
const finished = checkVictory(s, events);
|
||||
if (finished) return { events, needsInput: false };
|
||||
} else {
|
||||
s.clock.stage += 1;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
return { events: [...events, ...enterPhase(s, 'localOps')], needsInput: false };
|
||||
}
|
||||
|
||||
|
||||
+237
-41
@@ -16,7 +16,6 @@
|
||||
|
||||
import {
|
||||
FREIGHT_PROFILES,
|
||||
LABORER_ACTIONS_PER_LOAD,
|
||||
MAX_CONSIST,
|
||||
REALIGNMENTS,
|
||||
consistSize,
|
||||
@@ -65,7 +64,6 @@ import {
|
||||
pooled,
|
||||
railFacingOf,
|
||||
seatOf,
|
||||
spaceOn,
|
||||
standingSides,
|
||||
trackOrder,
|
||||
turnOf,
|
||||
@@ -125,6 +123,22 @@ function trayCoord(s: GameState, trayId: TrayId): GridCoord | null {
|
||||
return tray.position.coord;
|
||||
}
|
||||
|
||||
/**
|
||||
* A TRAY THE PLAYER MAY SWITCH: one standing in THEIR OWN district. Null for a tray that does not
|
||||
* exist, is off the grid, or is standing in somebody else's Office Area.
|
||||
*
|
||||
* Until v0.8.3 the switching intents resolved a tray with no seat test at all. `legal.ts`'s
|
||||
* generator filtered by seat, `check` did not — and the server validates with `check` alone. Every
|
||||
* district opens on the same coordinates, so a destination legal for your own tray at (0,0) was
|
||||
* "legal" for a rival's tray at THEIR (0,0), and `trayMoved` then charged the Moves to the rival's
|
||||
* turn. Invisible in solitaire, where there is nobody else's district to reach into.
|
||||
*/
|
||||
function ownTray(s: GameState, player: PlayerIndex, trayId: TrayId): CrewTray | null {
|
||||
const tray = s.trays.get(trayId);
|
||||
if (!tray || tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) return null;
|
||||
return tray;
|
||||
}
|
||||
|
||||
/** A tray sitting on the Office card occupies an A/D track (§2.1). */
|
||||
/**
|
||||
* Exported for `advance.ts`'s Yard Office walk (Gitea#5), which has to ask the SAME occupancy
|
||||
@@ -133,10 +147,19 @@ function trayCoord(s: GameState, trayId: TrayId): GridCoord | null {
|
||||
*/
|
||||
export function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
|
||||
const area = areaOf(s, player);
|
||||
const seat = seatOf(s, player);
|
||||
return {
|
||||
// IN THIS DISTRICT. Every Office Area is laid out on the same coordinates, so a coordinate match
|
||||
// alone found a rival's crew standing "here" — a phantom that blocked Moves and the Yard Office
|
||||
// walk in any game with more than one seat (v0.8.3).
|
||||
trayAt: (c) => {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at === 'grid' && tray.position.coord.row === c.row && tray.position.coord.col === c.col) {
|
||||
if (
|
||||
tray.position.at === 'grid' &&
|
||||
tray.position.seat === seat &&
|
||||
tray.position.coord.row === c.row &&
|
||||
tray.position.coord.col === c.col
|
||||
) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -352,6 +375,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.
|
||||
*
|
||||
@@ -593,7 +645,7 @@ function passengerWork(
|
||||
* crew is unaffected by all of them.
|
||||
*/
|
||||
/** Which district a tray is standing in; 0 when it is out on the Division. */
|
||||
function trayySeat(tray: CrewTray): SeatIndex {
|
||||
function traySeat(tray: CrewTray): SeatIndex {
|
||||
return tray.position.at === 'grid' ? tray.position.seat : 0;
|
||||
}
|
||||
|
||||
@@ -866,7 +918,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const tray = ownTray(s, player, i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const from = trayCoord(s, i.trayId);
|
||||
if (!from) return 'ILLEGAL_MOVE';
|
||||
@@ -899,8 +951,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';
|
||||
@@ -911,7 +977,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
case 'switch.dropCars': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const tray = ownTray(s, player, i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
@@ -960,7 +1026,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const tray = ownTray(s, player, i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
@@ -1082,7 +1148,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const tray = ownTray(s, player, i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'CANNOT_DROP_HERE';
|
||||
@@ -1224,6 +1290,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 +1508,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 +1547,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': {
|
||||
@@ -1474,7 +1576,7 @@ function checkPlay(
|
||||
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, card.kind.key, placement);
|
||||
return checkEnhancementPlacement(area, card.kind.key, placement);
|
||||
}
|
||||
|
||||
case 'mainlineModifier':
|
||||
@@ -1483,7 +1585,7 @@ function checkPlay(
|
||||
// copy uses the enhancement's grid placement rather than going onto a Mainline card.
|
||||
if (card.kind.key === 'facingPointLocksMainline') {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
|
||||
return checkEnhancementPlacement(area, 'facingPointLocks', placement);
|
||||
}
|
||||
// The rest are laid on a Mainline card, which is not a grid coordinate — see
|
||||
// `mainline.modify`.
|
||||
@@ -1644,6 +1746,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()] };
|
||||
}
|
||||
|
||||
@@ -1687,7 +1828,6 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const from = trayCoord(s, i.trayId)!;
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
const dest = selectDestination(dests, i.to, i.via)!;
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
// The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the
|
||||
// cut it recouples on the way out is the cut the walk counted.
|
||||
const facingNow = facingPort(s, i.trayId);
|
||||
@@ -1872,10 +2012,22 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
// empty spot." Only when taking the last card actually empties the pile; refilling on every
|
||||
// draw would grow the Departments without limit and drain the Home Office deck into them.
|
||||
const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1];
|
||||
if (pile.length === 1 && refill) {
|
||||
events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
|
||||
const sweep = reshuffleIfDepleted(s, 1);
|
||||
if (sweep) events.push(sweep);
|
||||
if (pile.length === 1) {
|
||||
if (refill) events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
|
||||
/**
|
||||
* THE SWEEP DESCRIBES THE TABLE AFTER THE DRAW AND THE REFILL, not before (v0.8.3).
|
||||
*
|
||||
* This used to call `reshuffleIfDepleted` on the state as it stood, so the sweep collected
|
||||
* the card being drawn — still on its pile — and missed the refill card — still on the
|
||||
* deck. The reducers then dealt the drawn card into the new deck while the refill card,
|
||||
* moved onto a pile the reshuffle wiped a moment later, left the game: one card in two
|
||||
* places, one card in none. Proven by counting, and pinned in `apply.test.ts`.
|
||||
*/
|
||||
const deckAfter = s.decks.homeOffice.length - (refill ? 1 : 0);
|
||||
if (deckAfter <= 0) {
|
||||
const sweep = sweepDeck(s, { exclude: [pile[pile.length - 1]!], include: refill ? [refill] : [] });
|
||||
if (sweep) events.push(sweep);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -2001,7 +2153,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
i.from === 'menAtWork'
|
||||
? { type: workTrack(f)[i.index]!.type, loaded: true }
|
||||
: (i.from === 'outbound' ? f.outboundBox : f.inboundBox)[i.index]!;
|
||||
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
|
||||
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, index: i.index, stock }];
|
||||
}
|
||||
|
||||
case 'newTrain.startExtra': {
|
||||
@@ -2049,7 +2201,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 [
|
||||
@@ -2291,7 +2451,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
* front of `stock` is a drop being undone, so it is refunded on the square it was left on
|
||||
* instead of being charged again at the far end.
|
||||
*/
|
||||
const owner = playerAtSeat(s, trayySeat(tray));
|
||||
const owner = playerAtSeat(s, traySeat(tray));
|
||||
const taken = e.recoupled ? e.stock.slice(e.recoupled.stock.length) : e.stock;
|
||||
spendFreightBudget(s, owner, tray, e.at, taken);
|
||||
if (e.recoupled) refundFreightBudget(s, owner, tray, e.recoupled.at, e.recoupled.stock);
|
||||
@@ -2314,7 +2474,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
*/
|
||||
tray.engineAt = e.engineAt;
|
||||
// "Spends one move in the yard" — the sort costs a Move.
|
||||
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
|
||||
const sorter = turnOf(s, playerAtSeat(s, traySeat(tray)));
|
||||
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
|
||||
break;
|
||||
}
|
||||
@@ -2361,7 +2521,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
// cars set out to the east leave it where it was.
|
||||
card.standingWest = onWestSide ? k + cut.length : k;
|
||||
}
|
||||
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
|
||||
spendFreightBudget(s, playerAtSeat(s, traySeat(tray)), tray, e.at, e.stock);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2589,12 +2749,20 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
|
||||
case 'facilityUnjammed': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
/**
|
||||
* THE BOX THE PLAYER NAMED (v0.8.3). The event used to carry no index, so this cleared the
|
||||
* FIRST load on MEN | AT | WORK and the first car of the right type in a box — the same
|
||||
* "westmost car" fault `unloadBegan` once had. With an inbound tank on MEN and a stranded
|
||||
* hopper on WORK, unjamming the hopper deleted the tank load and sent a loaded hopper to
|
||||
* the yard, and the jam stayed. Events are regenerated on replay, so old saves carry it.
|
||||
*/
|
||||
if (e.from === 'menAtWork') {
|
||||
const idx = workTrack(f).findIndex((l) => l !== null);
|
||||
if (idx >= 0) workTrack(f)[idx] = null;
|
||||
const track = workTrack(f);
|
||||
const idx = track[e.index] ? e.index : track.findIndex((l) => l !== null);
|
||||
if (idx >= 0) track[idx] = null;
|
||||
} else {
|
||||
const box = e.from === 'outbound' ? f.outboundBox : f.inboundBox;
|
||||
const idx = box.findIndex((c) => c.type === e.stock.type);
|
||||
const idx = box[e.index]?.type === e.stock.type ? e.index : box.findIndex((c) => c.type === e.stock.type);
|
||||
if (idx >= 0) box.splice(idx, 1);
|
||||
}
|
||||
s.yards.classificationYard.push(pooled(e.stock));
|
||||
@@ -2621,6 +2789,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 +3116,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;
|
||||
}
|
||||
@@ -3004,7 +3181,6 @@ export function watertowersRemovable(area: OfficeArea): GridCoord[] {
|
||||
* - ABS Signals → a Mainline card, not the Office Area
|
||||
*/
|
||||
export function checkEnhancementPlacement(
|
||||
s: GameState,
|
||||
area: OfficeArea,
|
||||
key: string,
|
||||
placement: GridCoord,
|
||||
@@ -3075,6 +3251,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);
|
||||
@@ -3114,11 +3298,23 @@ function isSpentTimetabledTrain(s: GameState, id: CardId): boolean {
|
||||
|
||||
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
|
||||
if (s.decks.homeOffice.length > taking) return null;
|
||||
return sweepDeck(s, { exclude: [], include: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.2's reshuffle: the Salvage Yard and the three Departments come back as one deck.
|
||||
*
|
||||
* `exclude` names cards the events queued ahead of this one are taking OFF the Departments (a card
|
||||
* being drawn), `include` the ones they are putting ON (a refill from the deck) — so the sweep
|
||||
* matches the table the reducer will find, not the one the caller is looking at.
|
||||
*/
|
||||
function sweepDeck(s: GameState, adjust: { exclude: CardId[]; include: CardId[] }): GameEvent | null {
|
||||
const collected = [
|
||||
// The Salvage Yard, less the trains whose slots are already filled — see above.
|
||||
...s.decks.salvageYard.filter((id) => !isSpentTimetabledTrain(s, id)),
|
||||
// Every Department in full: a discarded train was never played, so it is still runnable.
|
||||
...s.decks.departments.flat(),
|
||||
...s.decks.departments.flat().filter((id) => !adjust.exclude.includes(id)),
|
||||
...adjust.include,
|
||||
];
|
||||
if (collected.length === 0) return null;
|
||||
const rng = createRng(s.rngState);
|
||||
|
||||
+70
-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,19 @@ export type HouseRules = {
|
||||
startingHand: StartingHand;
|
||||
revenue: RevenueRules;
|
||||
extraStart: ExtraStartRule;
|
||||
/** Which Office every player opens on — see `StartingOffice`. */
|
||||
startingOffice: StartingOffice;
|
||||
/**
|
||||
* WHETHER THE SECOND SECTION CARD IS IN THE DECK (Q9, one copy).
|
||||
*
|
||||
* Not a table's choice — there is no dial for it — but a fact about how the game was DEALT, kept
|
||||
* here for the same reason `startingOffice` is: the deal has to be replayable. The card went into
|
||||
* the deck in v0.8.2, after that release's save check had been run, and a deck one card larger
|
||||
* shuffles into a different order from the same seed — so every save on the test server refused
|
||||
* at move 3 under 0.8.2 while its release notes said three would resume. `withSavedDeal` sets
|
||||
* this false for a save that predates the card; everything dealt since carries it as true.
|
||||
*/
|
||||
secondSectionCard: boolean;
|
||||
/**
|
||||
* §6.2 — MAY A TIMETABLED TRAIN BE THROWN AWAY? (Gitea#9, superseding Gitea#6.)
|
||||
*
|
||||
@@ -1244,12 +1257,23 @@ 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;
|
||||
secondSectionCard?: boolean;
|
||||
};
|
||||
|
||||
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
||||
@@ -1267,6 +1291,15 @@ 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',
|
||||
secondSectionCard: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1286,6 +1319,9 @@ 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, from a deck with no Second Section card.
|
||||
startingOffice: 'whistlePost',
|
||||
secondSectionCard: false,
|
||||
};
|
||||
|
||||
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||
@@ -1306,6 +1342,36 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
|
||||
},
|
||||
extraStart: given.extraStart ?? d.extraStart,
|
||||
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
|
||||
startingOffice: given.startingOffice ?? d.startingOffice,
|
||||
secondSectionCard: given.secondSectionCard ?? d.secondSectionCard,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* THE DEAL A SAVE THAT PREDATES THE 0.8.2 SETTINGS WAS DEALT UNDER.
|
||||
*
|
||||
* Two of the house rules change how a game is DEALT rather than how it plays, and getting either
|
||||
* wrong does not stop a replay part-way where it can be seen — it deals a different railroad from
|
||||
* intent one, silently. `startingOffice` is a different Office and a deck with four more cards in
|
||||
* it; `secondSectionCard` is a deck one card larger, which the same seed shuffles into a different
|
||||
* order. Every game saved before 2026-09-23 opened on a Whistle Post from a deck without the
|
||||
* Second Section card, and its `houseRules` cannot say so.
|
||||
*
|
||||
* So a saved config that names house rules but not `startingOffice` — the settings form has named
|
||||
* it on every config written since the setting existed — gets both of what it was played under.
|
||||
* A config with no house rules at all is a fresh game, not an old save, and is left alone; so is
|
||||
* one that names the opening, in either direction.
|
||||
*
|
||||
* APPLIED ON THE REPLAY PATHS ONLY (`configFor` in `web/game.ts`, `tryResumeSession` in
|
||||
* `server/session.ts`), never in `houseRules()` above: putting the legacy default in the resolver
|
||||
* made a fresh game deal the old railroad and read as "Custom" in the lobby.
|
||||
*/
|
||||
export function withSavedDeal<T extends { houseRules?: HouseRuleOverrides }>(config: T): T {
|
||||
const given = config.houseRules;
|
||||
if (!given || given.startingOffice !== undefined) return config;
|
||||
return {
|
||||
...config,
|
||||
houseRules: { ...given, startingOffice: 'whistlePost', secondSectionCard: given.secondSectionCard ?? false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1403,6 +1469,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 },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -126,6 +126,22 @@ 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;
|
||||
/** The train whose arrival freed the track — or null when a departure freed it (v0.8.3). */
|
||||
freedBy: number | null;
|
||||
}
|
||||
| { 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
|
||||
@@ -166,7 +182,7 @@ export type GameEvent =
|
||||
// -- freight agent
|
||||
| { type: 'stockToOutbound'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
|
||||
| { type: 'inboundCleared'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
|
||||
| { type: 'facilityUnjammed'; player: PlayerIndex; at: GridCoord; from: string; stock: RollingStock }
|
||||
| { type: 'facilityUnjammed'; player: PlayerIndex; at: GridCoord; from: string; index: number; stock: RollingStock }
|
||||
// -- trains
|
||||
/**
|
||||
* §7 — a Timetabled Train card played from hand is scheduled by a 1D12 roll. `rngState` carries
|
||||
@@ -185,7 +201,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. */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
|
||||
*/
|
||||
|
||||
import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts';
|
||||
import type { CarType, Direction, OfficeTier } from './content.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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'
|
||||
|
||||
+14
-6
@@ -12,7 +12,7 @@
|
||||
* If you find yourself writing a rule here, it belongs in apply.ts.
|
||||
*/
|
||||
|
||||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||||
import type { CarType } from './content.ts';
|
||||
import { enhancementRule, mainlineProfile } from './content.ts';
|
||||
import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
@@ -191,9 +191,6 @@ export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent
|
||||
return withRouteCache(s, () => switchCandidates(s, player).filter((i) => check(s, player, i) === null));
|
||||
}
|
||||
|
||||
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
return check(s, player, i) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate generation. Over-generates freely — `check` is the authority, so a candidate that
|
||||
@@ -313,12 +310,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 +379,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`
|
||||
|
||||
@@ -67,9 +67,3 @@ export function createRng(seed: number): Rng {
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore an RNG mid-stream, for rebuilding a game from a snapshot. */
|
||||
export function restoreRng(state: number): Rng {
|
||||
// mulberry32 advances from its state before drawing, and createRng seeds state directly,
|
||||
// so restoring is just seeding with the saved state.
|
||||
return createRng(state);
|
||||
}
|
||||
|
||||
+38
-7
@@ -19,15 +19,16 @@ import {
|
||||
OPENING_DEALS,
|
||||
MAINLINE_DECK,
|
||||
houseRules,
|
||||
mainlineProfile,
|
||||
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 +54,13 @@ 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',
|
||||
/** False only for a save that predates the card — see `HouseRules.secondSectionCard`. */
|
||||
secondSectionCard = true,
|
||||
): Card[] {
|
||||
/**
|
||||
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK REGARDLESS OF `pvpCardsAllowed`, for now.
|
||||
*
|
||||
@@ -81,7 +88,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 +106,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.
|
||||
*/
|
||||
if (secondSectionCard) 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 +175,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 +186,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: buildPassengerFacility('whistlePost'),
|
||||
facility: buildPassengerFacility(tier),
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
@@ -180,7 +208,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
|
||||
return {
|
||||
seat,
|
||||
tier: 'whistlePost',
|
||||
tier,
|
||||
grid,
|
||||
officeCoord,
|
||||
runningRow: row,
|
||||
@@ -285,6 +313,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 +323,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 +370,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, rules.secondSectionCard);
|
||||
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 };
|
||||
|
||||
/**
|
||||
|
||||
+133
-26
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
@@ -97,14 +97,57 @@ const MIME: Record<string, string> = {
|
||||
|
||||
const HEARTBEAT_MS = 20_000;
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
/**
|
||||
* THE LARGEST BODY ANY ROUTE HERE HAS A USE FOR, with room to spare (v0.8.4). The biggest thing a
|
||||
* client sends is a `GameConfig` on `/api/lobby/create` — a few hundred bytes. `readJson` used to
|
||||
* buffer whatever arrived, before any secret was checked, so anyone who could reach the port could
|
||||
* exhaust the process's memory with one POST. D14 expects this box to be reachable.
|
||||
*/
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
|
||||
/** A refusal with a status — thrown from anywhere in a handler and answered by the one catch. */
|
||||
class HttpError extends Error {
|
||||
readonly status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const declared = Number(req.headers['content-length'] ?? 0);
|
||||
if (declared > MAX_BODY_BYTES) throw new HttpError(413, 'body too large');
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += (chunk as Buffer).length;
|
||||
if (size > MAX_BODY_BYTES) throw new HttpError(413, 'body too large');
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
return text.trim() === '' ? {} : JSON.parse(text);
|
||||
if (text.trim() === '') return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
// A malformed body is the caller's mistake, answered as one — it used to surface as a 500.
|
||||
throw new HttpError(400, 'body is not JSON');
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new HttpError(400, 'expected a JSON object');
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
/**
|
||||
* NEVER A SECOND HEAD (v0.8.4). The SSE routes and `serveStatic` write their head and go on; a
|
||||
* throw after that reached the handler's catch, which called this, and `writeHead` on a response
|
||||
* whose head was sent throws `ERR_HTTP_HEADERS_SENT` — inside a `.catch`, with nothing above it,
|
||||
* so Node exited on the unhandled rejection. One bad stream write was a whole-server crash.
|
||||
*/
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) });
|
||||
res.end(text);
|
||||
@@ -149,7 +192,11 @@ async function serveStatic(
|
||||
'Content-Length': info.size,
|
||||
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
|
||||
});
|
||||
createReadStream(full).pipe(res);
|
||||
// A read that fails mid-stream (file replaced by a deploy, disk error) closes the response rather
|
||||
// than raising an error nothing listens for.
|
||||
createReadStream(full)
|
||||
.on('error', () => res.destroy())
|
||||
.pipe(res);
|
||||
} catch {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('not found');
|
||||
@@ -175,7 +222,8 @@ type LobbyPreview = {
|
||||
seated: { seat: number; who: string | null; bot: boolean }[];
|
||||
};
|
||||
|
||||
export function startServer(opts: ServerOptions): void {
|
||||
/** Returns the listening server — a test binds port 0 and reads the port back off it. */
|
||||
export function startServer(opts: ServerOptions): Server {
|
||||
const games = opts.initialGames;
|
||||
const lobbies = opts.initialLobbies;
|
||||
const sessions = opts.initialSessions;
|
||||
@@ -283,8 +331,29 @@ export function startServer(opts: ServerOptions): void {
|
||||
|
||||
async function persistSession(ps: PlayerSession): Promise<void> {
|
||||
sessions.set(ps.token, ps);
|
||||
const all = [...sessions.values()].filter((s) => s.gameId === ps.gameId);
|
||||
await writeSessions(opts.dataDir, ps.gameId, all);
|
||||
await persistSessionsOf(ps.gameId);
|
||||
}
|
||||
|
||||
/** Rewrites one game's `sessions.json` from what is in memory — after a token is revoked as much
|
||||
* as after one is issued, or a restart would hand the seat back to a browser that left it. */
|
||||
async function persistSessionsOf(gameId: string): Promise<void> {
|
||||
const all = [...sessions.values()].filter((s) => s.gameId === gameId);
|
||||
await writeSessions(opts.dataDir, gameId, all);
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE INTENT AT A TIME PER GAME — `protocol.md` §5's serialisation rule, which the intent handler
|
||||
* relied on Node's single thread to keep and did not (v0.8.4): the `await` on the disk write is
|
||||
* an interleaving point, so two moves arriving together could both apply in memory, race their
|
||||
* writes to one `.tmp`, and hand their broadcasts to the clients in the wrong order. Everything a
|
||||
* move does — apply, persist, answer, broadcast — now runs as one unit behind the move before it.
|
||||
*/
|
||||
const gameQueues = new Map<string, Promise<unknown>>();
|
||||
function inTurn<T>(gameId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = gameQueues.get(gameId) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
gameQueues.set(gameId, next.catch(() => undefined));
|
||||
return next;
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
@@ -571,6 +640,17 @@ export function startServer(opts: ServerOptions): void {
|
||||
sendJson(res, 403, { error: 'NOT_HOST' });
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* THE SEAT GOES, AND SO DOES THE TOKEN THAT HELD IT (v0.8.4).
|
||||
*
|
||||
* Leaving used to free the chair and keep the session: the token stayed in `sessions` and
|
||||
* on disk, and `joinLobby` hands a vacated chair to the next arrival. So a player who left
|
||||
* (or was removed) still held a token for seat N, and once somebody else sat in seat N and
|
||||
* the game began, `/api/stream` served that token the new occupant's hand and `/api/intent`
|
||||
* let it move for them — and its stream connection displaced theirs. Revoked here, before
|
||||
* the chair is offered to anyone.
|
||||
*/
|
||||
const occupant = seat === undefined ? ps : [...sessions.values()].find((s) => s.gameId === lobby.gameId && s.player === seat);
|
||||
const result = leaveLobby(lobby, ps.token, seat);
|
||||
if (result.empty) {
|
||||
// Nobody human is left to start it. Everything about this lobby goes, including the code,
|
||||
@@ -579,6 +659,7 @@ export function startServer(opts: ServerOptions): void {
|
||||
gameCodes.delete(lobby.gameCode);
|
||||
for (const [, watcher] of lobbyConnections.get(lobby.gameId) ?? []) watcher.end();
|
||||
lobbyConnections.delete(lobby.gameId);
|
||||
for (const [token, s] of [...sessions]) if (s.gameId === lobby.gameId) sessions.delete(token);
|
||||
await deleteLobby(opts.dataDir, lobby.gameId);
|
||||
// The row goes with the lobby rather than being marked: a game that never started is not a
|
||||
// game an administrator has any use for a record of.
|
||||
@@ -586,6 +667,12 @@ export function startServer(opts: ServerOptions): void {
|
||||
sendJson(res, 200, { ok: true, closed: true });
|
||||
return;
|
||||
}
|
||||
if (occupant && result.lobby !== lobby) {
|
||||
sessions.delete(occupant.token);
|
||||
lobbyConnections.get(lobby.gameId)?.get(occupant.token)?.end();
|
||||
lobbyConnections.get(lobby.gameId)?.delete(occupant.token);
|
||||
await persistSessionsOf(lobby.gameId);
|
||||
}
|
||||
await persistLobby(result.lobby);
|
||||
broadcastLobby(lobby.gameId);
|
||||
sendJson(res, 200, { ok: true });
|
||||
@@ -690,7 +777,9 @@ export function startServer(opts: ServerOptions): void {
|
||||
// `lobby`, because it may have changed (another join, another bot toggle) since connect.
|
||||
const current = lobbies.get(lobby.gameId);
|
||||
if (current && current.hostToken === token) {
|
||||
void persistLobby(reassignHost(current, token)).then(() => broadcastLobby(lobby.gameId));
|
||||
persistLobby(reassignHost(current, token))
|
||||
.then(() => broadcastLobby(lobby.gameId))
|
||||
.catch((err: unknown) => console.error(`reassigning the host of ${lobby.gameId} failed:`, err));
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -820,31 +909,49 @@ export function startServer(opts: ServerOptions): void {
|
||||
sendJson(res, 400, { error: 'expected { seq, intent }' });
|
||||
return;
|
||||
}
|
||||
const result = session.intent(ps.player, body.seq, body.intent);
|
||||
if (result.accepted) {
|
||||
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
|
||||
// this scale, not just "applied in memory" (§12 step 14).
|
||||
const dir = gameDir(opts.dataDir, ps.gameId);
|
||||
await writeGame(dir, session.exportSave(), opts.engineVersion);
|
||||
if (result.timing) await appendTiming(dir, result.timing);
|
||||
if (session.exportSave().status === 'finished') {
|
||||
// `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be
|
||||
// carried forward here rather than left blank — `gameCodes` is the only place still
|
||||
// holding it once a lobby's own record is gone.
|
||||
const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? '';
|
||||
await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' });
|
||||
const { seq, intent } = body;
|
||||
await inTurn(ps.gameId, async () => {
|
||||
const result = session.intent(ps.player, seq, intent);
|
||||
if (result.accepted) {
|
||||
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
|
||||
// this scale, not just "applied in memory" (§12 step 14).
|
||||
const dir = gameDir(opts.dataDir, ps.gameId);
|
||||
try {
|
||||
await writeGame(dir, session.exportSave(), opts.engineVersion);
|
||||
if (result.timing) await appendTiming(dir, result.timing);
|
||||
if (session.exportSave().status === 'finished') {
|
||||
// `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be
|
||||
// carried forward here rather than left blank — `gameCodes` is the only place still
|
||||
// holding it once a lobby's own record is gone.
|
||||
const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? '';
|
||||
await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' });
|
||||
}
|
||||
} catch (err) {
|
||||
// The move IS applied — every seat's game has moved on — so the table is told and the
|
||||
// disk's failure is the operator's to see in the log. Answering 500 here told the one
|
||||
// player who moved that their move was refused, while everyone else watched it happen.
|
||||
console.error(`persisting ${ps.gameId} after a move failed:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code });
|
||||
if (result.accepted) broadcastGame(ps.gameId, result.pushes);
|
||||
sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code });
|
||||
if (result.accepted) broadcastGame(ps.gameId, result.pushes);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
})().catch((err: unknown) => {
|
||||
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
||||
if (err instanceof HttpError) {
|
||||
sendJson(res, err.status, { error: err.message });
|
||||
return;
|
||||
}
|
||||
// Logged here, not echoed: an fs error's message carries the absolute path of the data
|
||||
// directory, which is the operator's to know and not the caller's.
|
||||
console.error(`${req.method ?? ''} ${req.url ?? ''} failed:`, err);
|
||||
sendJson(res, 500, { error: 'internal error' });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(opts.port, opts.bindAddress);
|
||||
return server;
|
||||
}
|
||||
|
||||
+15
-1
@@ -61,8 +61,22 @@ for (const entry of index) {
|
||||
}
|
||||
|
||||
const loaded = await loadGame(gameDir(dataDir, entry.gameId));
|
||||
if (!loaded.found && loaded.corrupt) {
|
||||
// One unreadable file is one game lost, not every game (v0.8.4) — see `loadGame`.
|
||||
console.error(`Skipping ${entry.gameId} (${entry.gameCode}): game.json is unreadable — ${loaded.corrupt}. The file is left untouched.`);
|
||||
continue;
|
||||
}
|
||||
if (loaded.found) {
|
||||
const resumed = tryResumeSession(loaded.saved);
|
||||
let resumed: ReturnType<typeof tryResumeSession>;
|
||||
try {
|
||||
resumed = tryResumeSession(loaded.saved);
|
||||
} catch (err) {
|
||||
// A save that parses but is not the shape the replay expects (no config, a history entry
|
||||
// that is not an intent) throws inside the engine rather than being refused. Same answer:
|
||||
// this game, not the server.
|
||||
console.error(`Skipping ${entry.gameId} (${entry.gameCode}): the save could not be replayed — ${err instanceof Error ? err.message : String(err)}. The file is left untouched.`);
|
||||
continue;
|
||||
}
|
||||
if (resumed.ok) {
|
||||
initialGames.set(entry.gameId, resumed.session);
|
||||
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
||||
|
||||
+2
-1
@@ -91,8 +91,9 @@ export function playerCountAllowed(mode: GameConfig['mode'], count: number): boo
|
||||
return mode === 'solitaire' ? count === 1 : count >= 2 && count <= 4;
|
||||
}
|
||||
|
||||
/** The creating player is the host and takes seat 0 (`lobby-and-sessions.md` §2). */
|
||||
/**
|
||||
* The creating player is the host and takes seat 0 (`lobby-and-sessions.md` §2).
|
||||
*
|
||||
* THE TABLE SIZE IS FIXED WHEN THE GAME IS CREATED, and `seats.length` is it.
|
||||
*
|
||||
* The host says how many are playing, so the seats array is built at full length with the host in
|
||||
|
||||
+69
-23
@@ -24,10 +24,33 @@ const INDEX_FILE = 'index.json';
|
||||
|
||||
type PersistedGame = SavedGame & { engineVersion: string };
|
||||
|
||||
/**
|
||||
* ONE WRITER AT A TIME PER FILE (v0.8.4).
|
||||
*
|
||||
* Every write here is read-modify-write or write-then-rename with an `await` in the middle, and
|
||||
* Node's single thread is no protection across an `await`: two requests for the same game could
|
||||
* both be inside `atomicWrite` at once. With ONE fixed `.tmp` name per path that tore the file —
|
||||
* measured at 200 rounds of two concurrent writes: every round lost one write to `rename` ENOENT,
|
||||
* and six left `game.json` as invalid JSON, which the boot then died on. So the temp name is unique
|
||||
* per write, and every write to a given path queues behind the one before it, which is also what
|
||||
* makes `upsertIndexEntry`'s "adds or updates exactly one row" claim true under two callers.
|
||||
*/
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
let writeSerial = 0;
|
||||
|
||||
function serial<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = queues.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
queues.set(key, next.catch(() => undefined));
|
||||
return next;
|
||||
}
|
||||
|
||||
async function atomicWrite(path: string, text: string): Promise<void> {
|
||||
const tmp = `${path}.tmp`;
|
||||
await writeFile(tmp, text);
|
||||
await rename(tmp, path);
|
||||
await serial(path, async () => {
|
||||
const tmp = `${path}.${process.pid}.${++writeSerial}.tmp`;
|
||||
await writeFile(tmp, text);
|
||||
await rename(tmp, path);
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeGame(dataDir: string, saved: SavedGame, engineVersion: string): Promise<void> {
|
||||
@@ -37,7 +60,8 @@ export async function writeGame(dataDir: string, saved: SavedGame, engineVersion
|
||||
}
|
||||
|
||||
export type LoadResult =
|
||||
| { found: false }
|
||||
/** `corrupt` names the parse error when the file is there and is not JSON — see `loadGame`. */
|
||||
| { found: false; corrupt?: string }
|
||||
/** The version that wrote the file, for diagnostics — it is no longer what decides. */
|
||||
| { found: true; saved: SavedGame; storedVersion: string };
|
||||
|
||||
@@ -61,7 +85,21 @@ export async function loadGame(dataDir: string): Promise<LoadResult> {
|
||||
} catch {
|
||||
return { found: false };
|
||||
}
|
||||
const payload = JSON.parse(text) as PersistedGame;
|
||||
/**
|
||||
* A FILE THAT IS NOT JSON IS REPORTED, NOT THROWN (v0.8.4). This parse was bare, and `index.ts`
|
||||
* awaited it at the top level — so one torn or half-edited `game.json` took the whole process
|
||||
* down before the port opened, and StartOS restarted it into the same file: every game on the
|
||||
* server unreachable because of one. The caller gets a reason to log and moves on.
|
||||
*/
|
||||
let payload: PersistedGame;
|
||||
try {
|
||||
payload = JSON.parse(text) as PersistedGame;
|
||||
} catch (err) {
|
||||
return { found: false, corrupt: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || !Array.isArray(payload.history)) {
|
||||
return { found: false, corrupt: 'not a saved game (no history array)' };
|
||||
}
|
||||
const { engineVersion, ...saved } = payload;
|
||||
return { found: true, saved, storedVersion: engineVersion };
|
||||
}
|
||||
@@ -71,14 +109,18 @@ export async function loadGame(dataDir: string): Promise<LoadResult> {
|
||||
export async function appendTiming(dataDir: string, timing: TurnTiming): Promise<void> {
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
const path = join(dataDir, TIMINGS_FILE);
|
||||
let existing: TurnTiming[];
|
||||
try {
|
||||
existing = JSON.parse(await readFile(path, 'utf8')) as TurnTiming[];
|
||||
} catch {
|
||||
existing = [];
|
||||
}
|
||||
existing.push(timing);
|
||||
await atomicWrite(path, JSON.stringify(existing, null, 1));
|
||||
// The read and the write are one unit under the file's queue, or two appends could each read the
|
||||
// same list and one of them would be lost.
|
||||
await serial(`rmw:${path}`, async () => {
|
||||
let existing: TurnTiming[];
|
||||
try {
|
||||
existing = JSON.parse(await readFile(path, 'utf8')) as TurnTiming[];
|
||||
} catch {
|
||||
existing = [];
|
||||
}
|
||||
existing.push(timing);
|
||||
await atomicWrite(path, JSON.stringify(existing, null, 1));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,11 +155,13 @@ async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise<v
|
||||
* silently drop every other game's row the moment two writes happened close together.
|
||||
*/
|
||||
export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise<void> {
|
||||
const entries = await readIndex(dataDir);
|
||||
const i = entries.findIndex((e) => e.gameId === entry.gameId);
|
||||
if (i >= 0) entries[i] = entry;
|
||||
else entries.push(entry);
|
||||
await writeIndex(dataDir, entries);
|
||||
await serial(`rmw:${join(dataDir, INDEX_FILE)}`, async () => {
|
||||
const entries = await readIndex(dataDir);
|
||||
const i = entries.findIndex((e) => e.gameId === entry.gameId);
|
||||
if (i >= 0) entries[i] = entry;
|
||||
else entries.push(entry);
|
||||
await writeIndex(dataDir, entries);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,11 +170,13 @@ export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry):
|
||||
* it on the next boot or leave `index.json` pointing at nothing.
|
||||
*/
|
||||
export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> {
|
||||
const entries = await readIndex(dataDir);
|
||||
await writeIndex(
|
||||
dataDir,
|
||||
entries.filter((e) => e.gameId !== gameId),
|
||||
);
|
||||
await serial(`rmw:${join(dataDir, INDEX_FILE)}`, async () => {
|
||||
const entries = await readIndex(dataDir);
|
||||
await writeIndex(
|
||||
dataDir,
|
||||
entries.filter((e) => e.gameId !== gameId),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */
|
||||
|
||||
+37
-7
@@ -18,6 +18,7 @@
|
||||
* checked, before `submit` is ever called — see `intent()` below.
|
||||
*/
|
||||
|
||||
import { withSavedDeal } from '../engine/content.ts';
|
||||
import { check } from '../engine/apply.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
@@ -71,6 +72,16 @@ export type Push = {
|
||||
scheduled?: number | null;
|
||||
announcement?: string | null;
|
||||
justDrawn?: string | null;
|
||||
/**
|
||||
* THE LAST INTENT `seq` THIS SEAT HAD ACCEPTED — on the connect push only (v0.8.4).
|
||||
*
|
||||
* The client numbers its intents from 1 per page load, and this host remembers the seat's last
|
||||
* accepted number for the life of the game and answers a repeat with "already applied" (§5). So
|
||||
* a seat that had made one move, reloaded, and clicked again sent `seq: 1` a second time: the
|
||||
* server said ok and did nothing, the page redrew nothing, and the click looked dead. Telling the
|
||||
* client where the count stands lets it continue from there instead of starting over.
|
||||
*/
|
||||
lastSeq?: number;
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
|
||||
*
|
||||
@@ -212,10 +223,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 {
|
||||
@@ -300,8 +326,7 @@ function buildSession(
|
||||
* `lobby-and-sessions.md` §5's turn clock exists to learn how long HUMANS take, and a bot decides
|
||||
* in zero wall-clock time by definition. Called once at construction (a resume could land exactly
|
||||
* on a bot's turn) and once after every accepted human intent.
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the bots' half of a unanimous vote.
|
||||
*
|
||||
* "Bots will not disagree with the human. Humans get to vote first. If all humans vote yes, then
|
||||
@@ -399,6 +424,7 @@ function buildSession(
|
||||
// connect IS the history, which is what lets the Frame stop carrying a second copy.
|
||||
sentLines.delete(seat);
|
||||
const push = pushFor(seat, null);
|
||||
push.lastSeq = lastSeq.get(seat) ?? 0;
|
||||
/**
|
||||
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
|
||||
* the shared delta chain has reached, so the next step merges onto it; before any step has
|
||||
@@ -511,7 +537,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 `withSavedDeal`.
|
||||
const { game, stopped } = fromMultiplayerSave(
|
||||
saved.seed, withSavedDeal(saved.config), saved.playerNames, saved.history,
|
||||
);
|
||||
if (stopped) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
+15
-9
@@ -113,14 +113,6 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
const CHIP_Y = RAIL_Y - 10;
|
||||
const BELOW_Y = RAIL_Y + 13;
|
||||
const GAP = 6;
|
||||
/**
|
||||
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
|
||||
* them without spilling onto its neighbours (docs/plans/switching-paths.md — "The Roster Pass").
|
||||
* Sized by CAPACITY, not by how many are occupied right now: a cell drawn for the trains it HAS
|
||||
* holds still as they come and go, where sizing by occupancy moved the East Division Point (and
|
||||
* everything past it) sideways every time an A/D track filled or cleared.
|
||||
*/
|
||||
const CHIP_W = 54;
|
||||
/**
|
||||
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
|
||||
*
|
||||
@@ -1259,7 +1251,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 +1472,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}
|
||||
|
||||
+1
-56
@@ -91,19 +91,6 @@ function because(reason: string, intent: Intent): Intent {
|
||||
return intent;
|
||||
}
|
||||
|
||||
/**
|
||||
* KNOBS FOR A/B MEASUREMENT, and nothing else.
|
||||
*
|
||||
* A heuristic change has to be measured against the bot it replaces, over the SAME deals — and
|
||||
* editing the bot between runs makes that impossible to do honestly, because the two sides of the
|
||||
* comparison never exist at once. Every flag here is off by default, so `makeDeveloperBot({})` is
|
||||
* byte-identical to the bot that came before this existed.
|
||||
*
|
||||
* TEMPORARY BY CONSTRUCTION. When a flag measures well it becomes the default and the flag is
|
||||
* deleted in the same commit; when it measures badly it is deleted with its finding recorded in the
|
||||
* changelog. What must not happen is a bot that accumulates switches nobody can account for — a
|
||||
* heuristic with no measurement attached is exactly what this machinery exists to prevent.
|
||||
*/
|
||||
/**
|
||||
* ABLATIONS, for re-measuring the heuristics that are now the bot's default play.
|
||||
*
|
||||
@@ -356,40 +343,13 @@ function committedTrains(s: GameState): number {
|
||||
*
|
||||
* §7 lets you play as many train cards as you draw, and a train that arrives with nowhere to stand
|
||||
* is an automatic collision (Gap 2d) — so the two rules together make a train card actively harmful
|
||||
* once the A/D tracks are spoken for. Off unless `trainCapSlack` is set.
|
||||
* once the A/D tracks are spoken for. `noTrainCap` switches the cap off for re-measurement.
|
||||
*/
|
||||
function trainWouldOverfillTheOffice(s: GameState, player: PlayerIndex, tweaks: BotTweaks): boolean {
|
||||
if (tweaks.noTrainCap) return false;
|
||||
return committedTrains(s) >= officeProfile(areaOf(s, player).tier).adTracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Would running here leave the engine buried among its own cars?
|
||||
*
|
||||
* Cars met on a FORWARD move couple onto the nose (§A.3), which pushes the engine back through its
|
||||
* own train — `carsCoupled` moves `engineAt` by the number taken. So the engine ends up buried
|
||||
* whenever it had cars behind it already and picks up more in front, and §8.2 then refuses to let
|
||||
* the train leave the Office. Only trains care: a local crew has nowhere it must depart from.
|
||||
*
|
||||
* Asked of the engine's own destination list, so the count is the count that will really couple.
|
||||
*/
|
||||
function wouldBuryTheEngine(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
move: Extract<Intent, { type: 'switch.move' }>,
|
||||
): boolean {
|
||||
const tray = s.trays.get(move.trayId);
|
||||
if (!tray || tray.trainNumber === null) return false;
|
||||
if (tray.position.at !== 'grid') return false;
|
||||
if (move.reverse) return false; // cars taken while backing up couple BEHIND the engine
|
||||
const len = tray.consist.length;
|
||||
if (len === 0 || tray.engineAt >= len) return false; // nothing behind the engine to bury it against
|
||||
|
||||
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, false).find(
|
||||
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
|
||||
);
|
||||
return (dest?.couples.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function chooseLocalOption(
|
||||
s: GameState,
|
||||
@@ -1427,8 +1387,6 @@ function followThrough(
|
||||
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot, tweaks),
|
||||
);
|
||||
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
|
||||
// both "qualify", and only one of them stops the collisions.
|
||||
// Best-ranked pile, not the first that qualifies. Measured as a near no-op — an Office card
|
||||
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
|
||||
// ranking function is for, and a coin flip on the card that decides whether the district
|
||||
@@ -1980,19 +1938,6 @@ function sidingsWorthCollecting(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cars standing on ordinary track that some facility would actually take. */
|
||||
function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; col: number }[] {
|
||||
const out: { row: number; col: number }[] = [];
|
||||
const tray = trayOf(s, player);
|
||||
if (!tray || tray.consist.length >= MAX_CONSIST) return out;
|
||||
for (const [key, card] of areaOf(s, player).grid) {
|
||||
if (card.facility || card.standing.length === 0) continue;
|
||||
if (!card.standing.some((c) => facilitiesWanting(s, player, c).length > 0)) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
out.push({ row: row!, col: col! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function trayOf(s: GameState, player: PlayerIndex) {
|
||||
for (const tray of s.trays.values()) {
|
||||
|
||||
+1
-1
@@ -251,7 +251,7 @@ if (isMain) {
|
||||
const tweaks = parseTweaks(args);
|
||||
|
||||
if (Object.keys(tweaks).length === 0) {
|
||||
console.error('nothing to compare — pass at least one tweak, e.g. trainCapSlack=1');
|
||||
console.error(`nothing to compare — pass at least one ablation, e.g. noValueLays=1 (one of: ${[...BOOLEAN_TWEAKS].join(', ')})`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(formatPaired(compare(tweaks, games, length)));
|
||||
|
||||
+43
-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,25 @@ 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 ${
|
||||
e.freedBy === null
|
||||
? 'a departure freed the A/D track it was waiting for'
|
||||
: `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 +727,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)}` };
|
||||
|
||||
|
||||
+3
-6
@@ -19,25 +19,22 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { advance } from '../engine/advance.ts';
|
||||
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
||||
import { applyIntent } from '../engine/apply.ts';
|
||||
import type { GameLength } from '../engine/content.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
MAINLINE_PROFILES,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
} from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||
import type { GameConfig } from '../engine/state.ts';
|
||||
import { actingPlayer } from '../engine/state.ts';
|
||||
import { reasonSentence } from '../web/panels.ts';
|
||||
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
import { cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
// The view-model lives in its own module so the browser build can import it without dragging in
|
||||
// this file's Node dependencies. Re-exported because tests and the web app import it from here.
|
||||
export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*
|
||||
* Run with:
|
||||
* node src/sim/save-replay.ts 400 --top 3
|
||||
* node src/sim/save-replay.ts 400 --top 3 trainCapSlack=0
|
||||
* node src/sim/save-replay.ts 400 --top 3 noValueLays=1
|
||||
*/
|
||||
|
||||
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
|
||||
+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. */
|
||||
|
||||
+31
-3
@@ -33,6 +33,7 @@ import {
|
||||
MANEUVER_CARDS,
|
||||
MODIFIER_PROFILES,
|
||||
REALIGNMENTS,
|
||||
SECOND_SECTION,
|
||||
OFFICE_ORDER,
|
||||
SPACE_USE_CARDS,
|
||||
STAGES_PER_SHIFT,
|
||||
@@ -888,14 +889,14 @@ export function describeDecision(
|
||||
groups.set(o.type, list);
|
||||
}
|
||||
const rejected = [...groups.entries()]
|
||||
.map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) }))
|
||||
.map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, list) }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length };
|
||||
}
|
||||
|
||||
/** A short, concrete example of what a group of rejected options would have done. */
|
||||
function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
||||
function sampleDetail(s: GameState, list: Intent[]): string {
|
||||
// Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe
|
||||
// identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the
|
||||
// actual range of choices — the opposite of what this panel is for.
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+99
-25
@@ -37,23 +37,24 @@ import { collectStep, newCollector } from '../sim/display-step.ts';
|
||||
import type { DisplayCollector } from '../sim/display-step.ts';
|
||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||
// which would pull node:fs into a browser bundle.
|
||||
import { cardDescription, cardName, currentActorOfState, describeIntent, geometryLabel, simpleCardName, snapshot, trainName, variantLabel } from '../sim/view.ts';
|
||||
import { cardDescription, cardName, currentActorOfState, describeIntent, simpleCardName, snapshot, trainName, variantLabel } from '../sim/view.ts';
|
||||
import {
|
||||
DEFAULT_DAYS,
|
||||
DEFAULT_HOUSE_RULES,
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
LEGACY_HOUSE_RULES,
|
||||
withSavedDeal,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
industryProfile,
|
||||
mainlineProfile,
|
||||
trainProfile,
|
||||
} from '../engine/content.ts';
|
||||
import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
|
||||
import type { CarType, Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
||||
import { acceptsCar, areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
|
||||
/**
|
||||
@@ -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;
|
||||
}
|
||||
@@ -978,16 +1007,31 @@ function consistNeeds(game: Game, trayId: string): string | null {
|
||||
const have = (k: 'coach' | 'caboose' | 'freight'): number =>
|
||||
tray.consist.filter((c) => cat(c.type) === k).length;
|
||||
|
||||
/**
|
||||
* ASKED OF `acceptsCar`, NOT RECOUNTED (v0.8.4). Counting by category alone promised cars the
|
||||
* engine would then refuse: with the caboose already coupled, nothing may go on behind it
|
||||
* (§A.3), so "Still needs 2 boxcar" was printed while no yard chip lit and the only offer was
|
||||
* to send the train out as it stands. One sample car per category settles it the same way
|
||||
* `advance.ts`'s make-up report does.
|
||||
*/
|
||||
const sample: Record<'coach' | 'caboose' | 'freight', CarType> = {
|
||||
coach: 'coach',
|
||||
caboose: 'caboose',
|
||||
freight: p.consist.freightTypes?.[0] ?? 'boxcar',
|
||||
};
|
||||
const parts: string[] = [];
|
||||
const freight = p.consist.freight - have('freight');
|
||||
const coach = p.consist.coach - have('coach');
|
||||
const caboose = p.consist.caboose - have('caboose');
|
||||
if (freight > 0) {
|
||||
parts.push(`${freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
||||
}
|
||||
if (coach > 0) parts.push(`${coach} coach${coach > 1 ? 'es' : ''}`);
|
||||
if (caboose > 0) parts.push(`${caboose} caboose`);
|
||||
return parts.length > 0 ? parts.join(' + ') : null;
|
||||
let blocked = false;
|
||||
const want = (k: 'coach' | 'caboose' | 'freight', label: (n: number) => string): void => {
|
||||
const n = (k === 'coach' ? p.consist.coach : k === 'caboose' ? p.consist.caboose : p.consist.freight) - have(k);
|
||||
if (n <= 0) return;
|
||||
if (acceptsCar(tray, sample[k])) parts.push(label(n));
|
||||
else blocked = true;
|
||||
};
|
||||
want('freight', (n) => `${n} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
||||
want('coach', (n) => `${n} coach${n > 1 ? 'es' : ''}`);
|
||||
want('caboose', (n) => `${n} caboose`);
|
||||
if (parts.length > 0) return parts.join(' + ');
|
||||
return blocked ? 'nothing more — the caboose is on, and nothing couples behind it' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1011,7 +1055,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 +1206,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 +1453,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 +1470,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 +1500,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 +1539,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 withSavedDeal({ ...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>
|
||||
|
||||
|
||||
+2
-2
@@ -421,7 +421,7 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
|
||||
|
||||
// -- seating --------------------------------------------------------------------------------
|
||||
|
||||
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
|
||||
function renderSeating(lobby: Lobby, token: string): void {
|
||||
$('lb-gamecode').textContent = lobby.gameCode;
|
||||
const isHost = lobby.hostToken === token;
|
||||
|
||||
@@ -566,7 +566,7 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
|
||||
handlers.onReady({ token, gameId, seat: push.you, gameCode: push.lobby.gameCode });
|
||||
return;
|
||||
}
|
||||
renderSeating(push.lobby, push.you, token);
|
||||
renderSeating(push.lobby, token);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+15
-8
@@ -149,8 +149,8 @@ function saveSettings(patch: Partial<Settings>): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* The game, behind the Session boundary — `LocalSession` (solitaire, `?seat=` absent from the URL)
|
||||
* or `RemoteSession` (`?seat=` present, Phase 2). Typed as the common `Session` surface; every
|
||||
* The game, behind the Session boundary — `LocalSession` (solitaire) or `RemoteSession` (a seat
|
||||
* this browser holds in `localStorage`, or a claim link; `?seat=` is no longer a route). Typed as the common `Session` surface; every
|
||||
* LocalSession-only touch (undo, local saves, dealing a new game) goes through `isLocal` below rather
|
||||
* than assuming, since `session` may now be either.
|
||||
*/
|
||||
@@ -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>
|
||||
|
||||
+51
-12
@@ -6,8 +6,8 @@
|
||||
*
|
||||
* - `LocalSession` runs the engine in this browser. Solitaire, exactly as it has always worked,
|
||||
* with no server involved at any point.
|
||||
* - `RemoteSession` (not built yet — see `docs/architecture/multiplayer.md` Phase 2) will hold no
|
||||
* authoritative state at all. It cannot: it has neither the deck order nor the other players'
|
||||
* - `RemoteSession` (`docs/architecture/multiplayer.md` Phase 2, built) holds no authoritative
|
||||
* state at all. It cannot: it has neither the deck order nor the other players'
|
||||
* hands, and if it did the game would be cheatable.
|
||||
*
|
||||
* So this interface is deliberately the SMALLER of the two — everything a remote client could
|
||||
@@ -282,6 +282,8 @@ type Push = {
|
||||
scheduled?: number | null;
|
||||
announcement?: string | null;
|
||||
justDrawn?: string | null;
|
||||
/** Where the server's count of this seat's accepted intents stands — connect push only (v0.8.4). */
|
||||
lastSeq?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -322,7 +324,19 @@ export function createRemoteSession(
|
||||
let scheduled: number | null = null;
|
||||
let announcement: string | null = null;
|
||||
let justDrawnCard: string | null = null;
|
||||
/**
|
||||
* NUMBERED FROM WHERE THE SERVER SAYS, NOT FROM 1 (v0.8.4).
|
||||
*
|
||||
* This started at 1 on every page load, and the server remembers a seat's last accepted number
|
||||
* for the life of the game and answers a repeat with "already applied" (`protocol.md` §5). So a
|
||||
* seat that had made one move, reloaded, and clicked again sent `seq: 1` twice: the server said
|
||||
* ok, did nothing, pushed nothing, and the click looked dead. The connect push now carries the
|
||||
* server's count and this continues from it — never backwards, in case a submit is in flight
|
||||
* across a reconnect.
|
||||
*/
|
||||
let nextSeq = 1;
|
||||
/** One submit in flight at a time — see `submit`. */
|
||||
let inFlight = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
@@ -355,7 +369,15 @@ export function createRemoteSession(
|
||||
});
|
||||
};
|
||||
source.onmessage = (ev: MessageEvent<string>) => {
|
||||
const push = JSON.parse(ev.data) as Push;
|
||||
let push: Push;
|
||||
try {
|
||||
push = JSON.parse(ev.data) as Push;
|
||||
} catch {
|
||||
// A push that is not JSON is dropped rather than thrown out of an event handler nothing
|
||||
// catches; the next push carries a full delta chain from what this seat was last sent.
|
||||
return;
|
||||
}
|
||||
if (push.lastSeq !== undefined) nextSeq = Math.max(nextSeq, push.lastSeq + 1);
|
||||
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
|
||||
// seat's turn — only a push that actually came from the game (always carries a real `frame`,
|
||||
// per `session.ts`'s `Push`) updates the board or the menu.
|
||||
@@ -402,16 +424,33 @@ export function createRemoteSession(
|
||||
actor: () => need().actor,
|
||||
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
||||
async submit(intent: Intent): Promise<boolean> {
|
||||
/**
|
||||
* ONE AT A TIME (v0.8.4). The page redraws the SAME menu the instant a submit is sent — the
|
||||
* new one only arrives with the push — so a second click before the round trip posted a
|
||||
* second, fresh `seq` for the same option, and the server, which de-duplicates on `seq`
|
||||
* alone, applied it again when it was still legal: two cards drawn, two Moves spent, two cars
|
||||
* coupled. A click that lands while one is in flight is dropped; the push is milliseconds away.
|
||||
*/
|
||||
if (inFlight) return false;
|
||||
inFlight = true;
|
||||
const seq = nextSeq++;
|
||||
const res = await fetch(`/api/intent?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seq, intent }),
|
||||
});
|
||||
const result = (await res.json()) as { ok: boolean; code?: string };
|
||||
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
|
||||
// not from this response — this only reports whether the rules accepted it.
|
||||
return result.ok;
|
||||
try {
|
||||
const res = await fetch(`/api/intent?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seq, intent }),
|
||||
});
|
||||
const result = (await res.json()) as { ok?: boolean; code?: string };
|
||||
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
|
||||
// not from this response — this only reports whether the rules accepted it.
|
||||
return result.ok === true;
|
||||
} catch {
|
||||
// A network failure or a non-JSON answer used to reject out of a `void`ed promise — an
|
||||
// unhandled rejection and nothing on screen. False is honest: the move was not confirmed.
|
||||
return false;
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
},
|
||||
subscribe(fn: () => void) {
|
||||
listeners.add(fn);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -242,6 +242,7 @@ export function createStepQueue(
|
||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||
showing: () => last,
|
||||
lit: () => litPiles,
|
||||
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
|
||||
/**
|
||||
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||
*
|
||||
@@ -253,7 +254,6 @@ export function createStepQueue(
|
||||
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
|
||||
* "there is more to come, or what is up has not had its moment yet".
|
||||
*/
|
||||
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
|
||||
flashing: () => flashedCards,
|
||||
busy: () => pending.length > 0 || dueAt !== null,
|
||||
};
|
||||
|
||||
+75
-1
@@ -12,7 +12,6 @@ import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.t
|
||||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot } from '../src/sim/bot.ts';
|
||||
import type { CrewTray, DivisionNode, GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { coordKey, railFacingOf } from '../src/engine/state.ts';
|
||||
|
||||
@@ -23,6 +22,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,
|
||||
@@ -1700,3 +1703,74 @@ describe('a train sorted to the nose is judged by §8.2, not by where the engine
|
||||
assert.match(why, /caboose must be at the rear/, `the hold did not say why: ${why}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the collision floor at the end of a Day (v0.8.3)', () => {
|
||||
it('fires on a breach reached in Stage 12, before the Day rolls over', () => {
|
||||
/**
|
||||
* `shiftChange` reset `collisionsToday` at the Day rollover and only THEN asked whether the Day
|
||||
* had breached the limit — so a third wreck in Stage 12 was read as zero, and the one Stage of
|
||||
* the Day in which the floor could not fire was the last one. The tests above all set `stage`
|
||||
* to 1, which never crosses the rollover.
|
||||
*/
|
||||
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 2 });
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished', 'a breach in the last Stage of the Day went unpunished');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Expedite fault is charged once per Mainline Phase (v0.8.3)', () => {
|
||||
it('does not charge again when the phase resumes after a clearance ruling', () => {
|
||||
/**
|
||||
* The Q3 fault loop ran unguarded at the top of `mainlinePhase`, and the phase is re-entered
|
||||
* from the top after every clearance, Yard Office or Red Flag question — so an Expedited train
|
||||
* left on a siding was fined once per QUESTION rather than once per Phase.
|
||||
*/
|
||||
const s = game(7, { days: 5 });
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
const area = areaOf(s, 0);
|
||||
// The Expedited train, parked off the station: one fault is due.
|
||||
s.trays.set('expedited', {
|
||||
id: 'expedited', trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: { row: area.officeCoord.row, col: area.officeCoord.col + 1 } }, movesUsed: 0,
|
||||
});
|
||||
// A departing train that will put a clearance question to the Superintendent mid-phase.
|
||||
s.trays.set('leaving', {
|
||||
id: 'leaving', trainNumber: 12, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push('leaving');
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[ahead];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e', position: { at: 'mainline', index: ahead }, movesUsed: 0,
|
||||
});
|
||||
if (node?.kind === 'mainline') node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
const before = s.players[0]!.revenue;
|
||||
const first = advance(s);
|
||||
assert.ok(first.needsInput, 'no clearance question was put, so the phase never resumed');
|
||||
assert.equal(first.events.filter((e) => e.type === 'expediteFault').length, 1);
|
||||
|
||||
// Hold the departing train, so the only Revenue that can move is the fault's.
|
||||
const ruling = legalActions(s, s.clock.superintendent).find((i) => i.type === 'mainline.clearance' && !i.allow);
|
||||
assert.ok(ruling, 'no clearance ruling on offer');
|
||||
assert.ok(applyIntent(s, s.clock.superintendent, ruling).ok);
|
||||
const resumed = advance(s);
|
||||
assert.ok(!resumed.events.some((e) => e.type === 'expediteFault'), 'the fault was charged a second time');
|
||||
assert.equal(s.players[0]!.revenue, before - EXPEDITE_FAULT_PENALTY, 'more than one fault was charged');
|
||||
});
|
||||
});
|
||||
|
||||
+196
-73
@@ -14,7 +14,7 @@ import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { carsOn, coordKey, railFacingOf, spaceOn, turnOf } from '../src/engine/state.ts';
|
||||
import { cardDescription, snapshot } from '../src/sim/view.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
@@ -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(
|
||||
@@ -1238,7 +1243,6 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
|
||||
// REPORTED from playtesting: two Ice Houses in one district. Industries have been barred from
|
||||
// doubling up since Q4, but a Modifier is a different card kind and had no such check at all.
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
const copies = [...s.cards.entries()]
|
||||
@@ -1247,6 +1251,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 +1276,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 +1412,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 +1435,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 +1594,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 +1610,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 +1642,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 => ({
|
||||
@@ -2465,3 +2479,112 @@ describe('backing up over a cut to something beyond it takes both (v0.4.9d repor
|
||||
empty(s, at(0, 1), at(0, 0));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a Department draw that empties the Home Office deck (v0.8.3)', () => {
|
||||
/**
|
||||
* THE DRAWN CARD WAS DUPLICATED AND THE REFILL CARD DESTROYED.
|
||||
*
|
||||
* `draw.fromDepartment` queued `departmentRefilled` and then asked `reshuffleIfDepleted` to sweep
|
||||
* the Departments — from the state BEFORE either event had been reduced. So the sweep collected
|
||||
* the card being drawn (still on its pile) and missed the refill card (still on the deck), and
|
||||
* the reducers then dealt the drawn card into the new deck while the refill card, moved onto a
|
||||
* pile the reshuffle immediately wiped, left the game. The Home Office path was covered by the
|
||||
* tests above; this path was not.
|
||||
*/
|
||||
it('neither duplicates the drawn card nor loses the refill card', () => {
|
||||
const s = game();
|
||||
const all = [...s.decks.homeOffice];
|
||||
s.decks.salvageYard = all.slice(0, 40);
|
||||
s.decks.homeOffice = all.slice(40, 41); // exactly one card left: the refill card
|
||||
const refill = s.decks.homeOffice[0]!;
|
||||
const drawn = s.decks.departments[0]![s.decks.departments[0]!.length - 1]!;
|
||||
s.decks.departments[0] = [drawn]; // a single card, so taking it empties the pile
|
||||
const everywhere = (): string[] => [
|
||||
...s.decks.homeOffice,
|
||||
...s.decks.departments.flat(),
|
||||
...s.decks.salvageYard,
|
||||
...[...s.decks.hands.values()].flat(),
|
||||
];
|
||||
const before = everywhere().length;
|
||||
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'the deck ran out and was not reshuffled');
|
||||
|
||||
const after = everywhere();
|
||||
assert.equal(after.length, before, 'the reshuffle created or destroyed cards');
|
||||
assert.equal(new Set(after).size, after.length, 'a card ended up in two places');
|
||||
assert.ok(s.decks.hands.get(0)!.includes(drawn), 'the drawn card is not in hand');
|
||||
assert.equal(after.filter((id) => id === drawn).length, 1, 'the drawn card was dealt back into the deck too');
|
||||
assert.equal(after.filter((id) => id === refill).length, 1, 'the refill card left the game');
|
||||
assert.ok(s.decks.departments.every((p) => p.length === 1), 'the Departments were not re-dealt one deep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unjamming the box the player named (v0.8.3)', () => {
|
||||
/**
|
||||
* `facilityUnjammed` cleared the FIRST load on MEN | AT | WORK, whatever index the intent named,
|
||||
* because the event never carried the index — the same shape as the "westmost car" fault
|
||||
* `unloadBegan` once had. With one load on the track it could not be seen.
|
||||
*/
|
||||
it('clears the named MEN | AT | WORK load, not the first one', () => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.set('-1,0', {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, modifiers: [], enhancements: [],
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: true },
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 },
|
||||
// An inbound tank load on MEN, a stranded outbound hopper on WORK.
|
||||
menAtWork: [{ type: 'tank', dir: 'in' }, null, { type: 'hopper', dir: 'out' }],
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'freightAgent';
|
||||
const yardBefore = s.yards.classificationYard.length;
|
||||
|
||||
const r = applyIntent(s, 0, { type: 'freightAgent.unjam', at: { row: -1, col: 0 }, from: 'menAtWork', index: 2 });
|
||||
assert.ok(r.ok, 'the jam could not be cleared');
|
||||
const f = area.grid.get('-1,0')!.facility as { menAtWork: ({ type: string } | null)[] };
|
||||
assert.ok(f.menAtWork[0], 'the tank load on MEN was cleared instead of the hopper on WORK');
|
||||
assert.equal(f.menAtWork[2], null, 'the hopper on WORK is still there');
|
||||
const returned = s.yards.classificationYard[yardBefore];
|
||||
assert.equal(returned?.type, 'hopper', `a ${returned?.type} went to the Classification Yard, not the hopper`);
|
||||
});
|
||||
|
||||
it('clears the named car in a green or red box, not the first of its type', () => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.set('-1,0', {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, modifiers: [], enhancements: [],
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [{ type: 'hopper', loaded: true, origin: 1 }, { type: 'hopper', loaded: true, origin: 2 }],
|
||||
inboundBox: [], capacity: { outbound: 2, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'freightAgent';
|
||||
|
||||
const r = applyIntent(s, 0, { type: 'freightAgent.unjam', at: { row: -1, col: 0 }, from: 'outbound', index: 1 });
|
||||
assert.ok(r.ok);
|
||||
const f = area.grid.get('-1,0')!.facility!;
|
||||
assert.deepEqual(f.outboundBox.map((c) => c.origin), [1], 'the wrong car left the box');
|
||||
});
|
||||
});
|
||||
|
||||
+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'];
|
||||
@@ -855,3 +941,60 @@ describe('defensive enhancements', () => {
|
||||
assert.equal(hasDistrictEnhancement(areaOf(s, 0), 'waterColumn'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a train held at the Limits is released when a track frees (v0.8.3)', () => {
|
||||
it('takes a free A/D track at the end of the Mainline Phase without waiting for another arrival', () => {
|
||||
/**
|
||||
* The only release was inside `arriveAtOffice` for a DIFFERENT train — so an Office that
|
||||
* emptied by departures alone kept the held train at its Limits for the rest of the game,
|
||||
* invisible: no transit, not in `adOccupancy`, not counted by the clearance check.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
const card = straight();
|
||||
card.enhancements.push('interlocking');
|
||||
addCard(s, at(0, 2), card);
|
||||
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 = []; // the Office cleared, and nothing is arriving
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(area.adOccupancy.includes('waiting'), 'the held train is still at the Limits with the Office empty');
|
||||
assert.deepEqual(area.heldAtLimits, []);
|
||||
assert.deepEqual(s.trays.get('waiting')!.position, { at: 'grid', seat: 0, coord: area.officeCoord });
|
||||
const released = r.events.find((e) => e.type === 'trainReleasedFromLimits');
|
||||
assert.ok(released, 'the release is silent');
|
||||
const line = narrate(released as never, { playerName: () => 'A' });
|
||||
assert.match(line.text, /RELEASED from the Limits/, line.text);
|
||||
});
|
||||
|
||||
it('still waits while the Office is full', () => {
|
||||
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 = ['blocker'];
|
||||
s.trays.set('blocker', {
|
||||
id: 'blocker', trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
} as never);
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
s.clock.phase = 'mainline';
|
||||
advance(s);
|
||||
assert.deepEqual(area.heldAtLimits, ['waiting']);
|
||||
assert.ok(!area.adOccupancy.includes('waiting'));
|
||||
});
|
||||
});
|
||||
|
||||
+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,
|
||||
@@ -227,7 +231,7 @@ describe('the game conserves Rolling Stock', () => {
|
||||
|
||||
for (let t = 0; t < 50_000; t++) {
|
||||
const before = census(s);
|
||||
const pumped = pump(s);
|
||||
pump(s);
|
||||
/**
|
||||
* A COLLISION DESTROYS NO CAR, and this used to assume it destroyed all of them.
|
||||
*
|
||||
|
||||
@@ -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,128 @@
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
});
|
||||
|
||||
it('nests a more-indented bullet as a list inside its item (v0.8.4)', () => {
|
||||
/**
|
||||
* The code said "nesting is rendered by recursion" and appended the nested bullet to the parent
|
||||
* as text, so the published home-deck page read "…knows. - ABS Signals is the exception…" with
|
||||
* a literal dash mid-sentence.
|
||||
*/
|
||||
const out = html(['- parent', ' continues here.', ' - child one', ' wraps too', ' - child two', '- second'].join('\n'));
|
||||
assert.equal(
|
||||
out.trim(),
|
||||
'<ul><li>parent continues here.<ul><li>child one wraps too</li><li>child two</li></ul></li><li>second</li></ul>',
|
||||
);
|
||||
assert.doesNotMatch(html(readFileSync(join(import.meta.dirname, '..', 'docs', 'home-deck.md'), 'utf8')), /\. - <strong>/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,12 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaAtSeat, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaAtSeat, areaOf, check, occupancyFor } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY, STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
|
||||
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions, turnOf } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { divisionSvg } from '../src/sim/board-svg.ts';
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -975,3 +979,74 @@ describe('Employee Rotation (Appendix B)', () => {
|
||||
for (const name of ['Alice', 'Bob', 'Carol']) assert.match(line, new RegExp(name));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.8.3 — audit findings (2026-09-29): a player's switching stays in their own district
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("switching is confined to the actor's own district (v0.8.3)", () => {
|
||||
/**
|
||||
* `check` resolved the tray with no seat test at all: the legal-move GENERATOR filtered trays by
|
||||
* seat, `check` did not, and the server validates with `check` alone. Every district opens on the
|
||||
* same coordinates, so a destination legal for a tray of your own at (0,0) was "legal" for a
|
||||
* rival's tray at THEIR (0,0) — and `trayMoved` then charged the Moves to the rival.
|
||||
*/
|
||||
const placeOwn = (s: GameState, owner: PlayerIndex, id: string): void => {
|
||||
const area = areaOf(s, owner);
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', position: { at: 'grid', seat: seatOf(s, owner), coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
};
|
||||
const switching = (s: GameState, player: PlayerIndex): void => {
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = player;
|
||||
applyIntent(s, player, { type: 'localOps.choose', option: 'switch' });
|
||||
assert.equal(turnOf(s, player).option, 'switch', 'could not choose Switch');
|
||||
};
|
||||
|
||||
it("refuses a switch.move on a rival's tray that would have been legal on your own", () => {
|
||||
const s = game(2);
|
||||
// Find a move the actor could make with a tray of THEIR OWN standing at the Office.
|
||||
placeOwn(s, 0, 'mine');
|
||||
switching(s, 0);
|
||||
const own = legalActions(s, 0).find((i) => i.type === 'switch.move' && i.trayId === 'mine');
|
||||
assert.ok(own && own.type === 'switch.move', 'no switching move to test with');
|
||||
s.trays.delete('mine');
|
||||
// Now the same move named against seat 1's tray, standing at seat 1's Office.
|
||||
placeOwn(s, 1, 'theirs');
|
||||
const movesBefore = turnOf(s, 0).movesRemaining;
|
||||
const theirMovesBefore = turnOf(s, 1).movesRemaining;
|
||||
const code = check(s, 0, { ...own, trayId: 'theirs' });
|
||||
assert.equal(code, 'NO_SUCH_TRAY', `a rival's tray was accepted (${code ?? 'null'})`);
|
||||
assert.ok(!applyIntent(s, 0, { ...own, trayId: 'theirs' }).ok, 'the move was applied');
|
||||
assert.equal(turnOf(s, 0).movesRemaining, movesBefore);
|
||||
assert.equal(turnOf(s, 1).movesRemaining, theirMovesBefore, "the rival's Moves were charged");
|
||||
assert.deepEqual(s.trays.get('theirs')!.position, { at: 'grid', seat: seatOf(s, 1), coord: areaOf(s, 1).officeCoord });
|
||||
});
|
||||
|
||||
it("refuses dropCars and sortConsist on a rival's tray too", () => {
|
||||
const s = game(2);
|
||||
placeOwn(s, 0, 'mine'); // Switch is only on offer with a tray of your own to switch
|
||||
placeOwn(s, 1, 'theirs');
|
||||
s.trays.get('theirs')!.consist.push({ type: 'boxcar', loaded: false });
|
||||
switching(s, 0);
|
||||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 'theirs', count: 1 }), 'NO_SUCH_TRAY');
|
||||
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 'theirs', order: [0] }), 'NO_SUCH_TRAY');
|
||||
});
|
||||
|
||||
it("does not see a rival's crew as standing in your district", () => {
|
||||
/**
|
||||
* `occupancyFor().trayAt` matched on coordinates alone, so a crew at seat 0's (0,2) blocked
|
||||
* seat 1's (0,2) as "another train standing here". Invisible in solitaire.
|
||||
*/
|
||||
const s = game(2);
|
||||
const spot = { row: areaOf(s, 0).officeCoord.row, col: areaOf(s, 0).officeCoord.col + 2 };
|
||||
s.trays.set('crew0', {
|
||||
id: 'crew0', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', position: { at: 'grid', seat: seatOf(s, 0), coord: spot }, movesUsed: 0,
|
||||
});
|
||||
assert.equal(occupancyFor(s, 0, 'other').trayAt(spot), 'crew0', 'the owner cannot see their own crew');
|
||||
assert.equal(occupancyFor(s, 1, 'other').trayAt(spot), null, "a rival's crew is standing in the wrong district");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The browser's half of the multiplayer transport, driven with a fake `EventSource` and `fetch`.
|
||||
* `createRemoteSession` is pure otherwise — no DOM — so it runs here as it does in the page.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createRemoteSession } from '../src/web/session.ts';
|
||||
|
||||
type Fake = { onmessage: ((ev: { data: string }) => void) | null; emit(data: unknown): void; close(): void };
|
||||
|
||||
function fakeTransport(): { source: () => Fake; bodies: () => { seq: number }[]; fail: (on: boolean) => void } {
|
||||
let last: Fake | null = null;
|
||||
const bodies: { seq: number }[] = [];
|
||||
let failing = false;
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
g['EventSource'] = class {
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
constructor() {
|
||||
last = this;
|
||||
}
|
||||
emit(data: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
close(): void {}
|
||||
};
|
||||
g['fetch'] = async (_url: string, init?: { body?: string }) => {
|
||||
if (init?.body) bodies.push(JSON.parse(init.body) as { seq: number });
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
if (failing) throw new Error('network down');
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true }) };
|
||||
};
|
||||
return { source: () => last!, bodies: () => bodies, fail: (on) => (failing = on) };
|
||||
}
|
||||
|
||||
const connectPush = (lastSeq: number): unknown => ({ frame: null, menu: null, lines: [], lastSeq });
|
||||
|
||||
describe('the remote session (v0.8.4)', () => {
|
||||
it('continues the intent count from where the server says, not from 1', async () => {
|
||||
const t = fakeTransport();
|
||||
const s = createRemoteSession('tok', 0);
|
||||
t.source().emit(connectPush(7));
|
||||
await s.submit({ type: 'draw.end' });
|
||||
assert.deepEqual(t.bodies().map((b) => b.seq), [8], 'the first intent after a reload re-used a number the server had already applied');
|
||||
// A later reconnect never moves the count backwards.
|
||||
t.source().emit(connectPush(3));
|
||||
await s.submit({ type: 'draw.end' });
|
||||
assert.deepEqual(t.bodies().map((b) => b.seq), [8, 9]);
|
||||
});
|
||||
|
||||
it('drops a second submit while the first is still in flight', async () => {
|
||||
const t = fakeTransport();
|
||||
const s = createRemoteSession('tok', 0);
|
||||
t.source().emit(connectPush(0));
|
||||
const [a, b] = await Promise.all([s.submit({ type: 'draw.end' }), s.submit({ type: 'draw.end' })]);
|
||||
assert.equal(a, true);
|
||||
assert.equal(b, false, 'a double-click posted twice');
|
||||
assert.equal(t.bodies().length, 1, 'two intents went over the wire for one click');
|
||||
// And the next one, after the round trip, goes through as normal.
|
||||
assert.equal(await s.submit({ type: 'draw.end' }), true);
|
||||
assert.equal(t.bodies().length, 2);
|
||||
});
|
||||
|
||||
it('answers false, not an unhandled rejection, when the network fails', async () => {
|
||||
const t = fakeTransport();
|
||||
const s = createRemoteSession('tok', 0);
|
||||
t.source().emit(connectPush(0));
|
||||
t.fail(true);
|
||||
assert.equal(await s.submit({ type: 'draw.end' }), false);
|
||||
t.fail(false);
|
||||
assert.equal(await s.submit({ type: 'draw.end' }), true, 'the session did not recover after a failed submit');
|
||||
});
|
||||
|
||||
it('drops a push that is not JSON instead of throwing out of the handler', () => {
|
||||
const t = fakeTransport();
|
||||
createRemoteSession('tok', 0);
|
||||
assert.doesNotThrow(() => t.source().onmessage?.({ data: '{not json' }));
|
||||
});
|
||||
});
|
||||
+22
-9
@@ -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,8 +66,9 @@ 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: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', index: 0, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
|
||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false }, trainNumber: 10, isExtra: false },
|
||||
{ type: 'carPassed', player: 0, trayId: 't0', trainNumber: 10, isExtra: false },
|
||||
@@ -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 },
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The HTTP layer, driven end to end over a real socket. `startServer` binds port 0 on a temp data
|
||||
* directory; nothing here reads the built site, so `distDir` is a directory with nothing in it.
|
||||
*
|
||||
* Added in v0.8.4, when four faults in `http.ts` turned out to be uncovered because no test had ever
|
||||
* stood the server up: a leaver's token surviving the leave, an unbounded body, a torn save under
|
||||
* concurrent moves, and a crash on an error after the SSE head was sent.
|
||||
*/
|
||||
|
||||
import { describe, it, after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import type { GameConfig } from '../../src/engine/state.ts';
|
||||
import { startServer } from '../../src/server/http.ts';
|
||||
|
||||
const SECRET = 'test-secret';
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
|
||||
let server: Server;
|
||||
let base = '';
|
||||
let dataDir = '';
|
||||
|
||||
before(async () => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'station-master-http-'));
|
||||
server = startServer({
|
||||
port: 0,
|
||||
bindAddress: '127.0.0.1',
|
||||
joinSecret: SECRET,
|
||||
distDir: dataDir,
|
||||
dataDir,
|
||||
engineVersion: 'test',
|
||||
initialGames: new Map(),
|
||||
initialLobbies: new Map(),
|
||||
initialSessions: new Map(),
|
||||
});
|
||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('no port');
|
||||
base = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
// A write queued behind the last move may still be landing; retry rather than race it.
|
||||
await rm(dataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
||||
});
|
||||
|
||||
const post = async (path: string, body: unknown): Promise<{ status: number; json: Record<string, unknown> }> => {
|
||||
const res = await fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return { status: res.status, json: (await res.json()) as Record<string, unknown> };
|
||||
};
|
||||
const get = async (path: string): Promise<number> => (await fetch(base + path)).status;
|
||||
|
||||
/** The first SSE message on a stream, then the stream is dropped. */
|
||||
async function firstPush(path: string): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(base + path);
|
||||
assert.equal(res.status, 200, `${path} answered ${res.status}`);
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) throw new Error('stream ended before a push');
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const m = /data: (.*)\n\n/.exec(buffer);
|
||||
if (m) {
|
||||
await reader.cancel();
|
||||
return JSON.parse(m[1]!) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Seat = { token: string; player: number; gameId: string; gameCode: string };
|
||||
|
||||
async function table(): Promise<{ host: Seat; guest: Seat }> {
|
||||
const created = await post('/api/lobby/create', { secret: SECRET, config, displayName: 'Host', players: 2 });
|
||||
assert.equal(created.status, 200, JSON.stringify(created.json));
|
||||
const host = created.json as unknown as Seat;
|
||||
const joined = await post('/api/lobby/join', { secret: SECRET, gameCode: host.gameCode, displayName: 'Guest' });
|
||||
assert.equal(joined.status, 200, JSON.stringify(joined.json));
|
||||
return { host, guest: joined.json as unknown as Seat };
|
||||
}
|
||||
|
||||
describe('the HTTP layer (v0.8.4)', () => {
|
||||
it('revokes the token of a player who leaves, so it cannot play the seat the next arrival takes', async () => {
|
||||
const { host, guest } = await table();
|
||||
const left = await post('/api/lobby/leave', { token: guest.token });
|
||||
assert.equal(left.status, 200);
|
||||
// The leaver's token is dead at once — for the lobby and for the game that follows.
|
||||
assert.equal(await get(`/api/lobby/stream?token=${guest.token}`), 404, 'a leaver can still watch the lobby');
|
||||
const again = await post('/api/lobby/join', { secret: SECRET, gameCode: host.gameCode, displayName: 'Newcomer' });
|
||||
assert.equal(again.status, 200);
|
||||
assert.equal(again.json['player'], guest.player, 'the vacated chair was not the one re-offered');
|
||||
const started = await post('/api/lobby/start', { token: host.token });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.json));
|
||||
assert.equal(await get(`/api/session?token=${guest.token}`), 404, 'the leaver still holds a seat in the running game');
|
||||
assert.equal(await get(`/api/stream?token=${guest.token}`), 404, "the leaver can read the newcomer's stream");
|
||||
const move = await post(`/api/intent?token=${guest.token}`, { seq: 1, intent: { type: 'localOps.choose', option: 'draw' } });
|
||||
assert.equal(move.status, 404, 'the leaver can move for the newcomer');
|
||||
// And the newcomer's own token works.
|
||||
assert.equal(await get(`/api/session?token=${again.json['token'] as string}`), 200);
|
||||
// On disk too, so a restart does not hand the seat back.
|
||||
const onDisk = JSON.parse(await readFile(join(dataDir, 'games', host.gameId, 'sessions.json'), 'utf8')) as { token: string }[];
|
||||
assert.ok(!onDisk.some((s) => s.token === guest.token), 'the revoked token is still in sessions.json');
|
||||
});
|
||||
|
||||
it('lets the host remove a player, revoking that token the same way', async () => {
|
||||
const { host, guest } = await table();
|
||||
const removed = await post('/api/lobby/leave', { token: host.token, seat: guest.player });
|
||||
assert.equal(removed.status, 200, JSON.stringify(removed.json));
|
||||
assert.equal(await get(`/api/lobby/stream?token=${guest.token}`), 404);
|
||||
assert.equal(await get(`/api/lobby/stream?token=${host.token}`), 200, 'the host lost their own seat');
|
||||
});
|
||||
|
||||
it('refuses an oversized body before reading it, and a malformed one with 400', async () => {
|
||||
const big = await fetch(base + '/api/claim', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'x'.repeat(200_000) }),
|
||||
});
|
||||
assert.equal(big.status, 413);
|
||||
const bad = await fetch(base + '/api/lobby/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{not json' });
|
||||
assert.equal(bad.status, 400);
|
||||
const notObject = await fetch(base + '/api/lobby/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'null' });
|
||||
assert.equal(notObject.status, 400);
|
||||
});
|
||||
|
||||
it('tells a connecting seat where its intent count stands', async () => {
|
||||
const { host, guest } = await table();
|
||||
assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200);
|
||||
const hostPush = await firstPush(`/api/stream?token=${host.token}`);
|
||||
const actor = hostPush['menu'] !== null ? host : guest;
|
||||
assert.equal(hostPush['lastSeq'], 0);
|
||||
const move = await post(`/api/intent?token=${actor.token}`, { seq: 1, intent: { type: 'localOps.choose', option: 'draw' } });
|
||||
assert.deepEqual(move.json, { ok: true });
|
||||
const reconnect = await firstPush(`/api/stream?token=${actor.token}`);
|
||||
assert.equal(reconnect['lastSeq'], 1, 'the reconnect push does not carry the count');
|
||||
});
|
||||
|
||||
it('applies a burst of concurrent moves one at a time and leaves the save readable', async () => {
|
||||
const { host, guest } = await table();
|
||||
assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200);
|
||||
const hostPush = await firstPush(`/api/stream?token=${host.token}`);
|
||||
const actor = hostPush['menu'] !== null ? host : guest;
|
||||
// Three moves that are legal only in this order, fired together.
|
||||
const intents = [
|
||||
{ type: 'localOps.choose', option: 'draw' },
|
||||
{ type: 'draw.fromHomeOffice' },
|
||||
{ type: 'draw.end' },
|
||||
];
|
||||
const results = await Promise.all(intents.map((intent, i) => post(`/api/intent?token=${actor.token}`, { seq: i + 1, intent })));
|
||||
assert.ok(results.every((r) => r.status === 200), 'a concurrent move was answered with an error');
|
||||
const save = JSON.parse(await readFile(join(dataDir, 'games', host.gameId, 'game.json'), 'utf8')) as { history: unknown[] };
|
||||
assert.ok(save.history.length >= 1, 'no move reached the save');
|
||||
assert.equal(save.history.length, results.filter((r) => r.json['ok'] === true).length, 'the save and the answers disagree');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -238,7 +242,7 @@ describe('playerCountAllowed', () => {
|
||||
describe('game codes', () => {
|
||||
it('skips codes the caller marks taken', () => {
|
||||
let calls = 0;
|
||||
const code = freshGameCode((c) => {
|
||||
const code = freshGameCode(() => {
|
||||
calls++;
|
||||
return calls < 3; // taken twice, free on the third
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ import { join } from 'node:path';
|
||||
|
||||
import type { GameConfig } from '../../src/engine/state.ts';
|
||||
import type { SavedGame } from '../../src/server/session.ts';
|
||||
import { appendTiming, loadGame, writeGame } from '../../src/server/persistence.ts';
|
||||
import { appendTiming, loadGame, readIndex, upsertIndexEntry, writeGame } from '../../src/server/persistence.ts';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
@@ -15,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,
|
||||
@@ -97,4 +102,57 @@ describe('game persistence (Phase 3)', () => {
|
||||
const timings = JSON.parse(text) as unknown[];
|
||||
assert.equal(timings.length, 2);
|
||||
}));
|
||||
|
||||
// -- v0.8.4: concurrent writers ---------------------------------------------------------------
|
||||
|
||||
it('two hundred concurrent writes to one save leave it valid and never throw (v0.8.4)', () =>
|
||||
withTempDir(async (dir) => {
|
||||
/**
|
||||
* One fixed `.tmp` per path, and no queue: measured at 200 rounds of two concurrent writes,
|
||||
* every round lost one to `rename` ENOENT and six left the file as invalid JSON. Boot then
|
||||
* died on it. Unique temp names and a per-path queue are the fix; this is the measurement.
|
||||
*/
|
||||
const writes: Promise<void>[] = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const grown: SavedGame = { ...saved, history: Array.from({ length: i + 1 }, () => ({ type: 'draw.end' })) };
|
||||
writes.push(writeGame(dir, grown, '1.2.3'));
|
||||
}
|
||||
await Promise.all(writes);
|
||||
const result = await loadGame(dir);
|
||||
assert.equal(result.found, true, 'the save is unreadable after concurrent writes');
|
||||
if (result.found) assert.equal(result.saved.history.length, 200, 'the last write did not win');
|
||||
await assert.rejects(() => readFile(join(dir, 'game.json.tmp')));
|
||||
}));
|
||||
|
||||
it('two concurrent index upserts both land (v0.8.4)', () =>
|
||||
withTempDir(async (dir) => {
|
||||
await Promise.all([
|
||||
upsertIndexEntry(dir, { gameId: 'a', gameCode: 'AAA-1', status: 'active' }),
|
||||
upsertIndexEntry(dir, { gameId: 'b', gameCode: 'BBB-2', status: 'lobby' }),
|
||||
]);
|
||||
const rows = await readIndex(dir);
|
||||
assert.deepEqual(rows.map((r) => r.gameId).sort(), ['a', 'b'], 'a concurrent upsert lost a row');
|
||||
}));
|
||||
|
||||
it('two concurrent timing appends both land (v0.8.4)', () =>
|
||||
withTempDir(async (dir) => {
|
||||
await Promise.all([
|
||||
appendTiming(dir, { player: 0, phase: 'localOps', day: 1, stage: 1, startedAt: 1, endedAt: 2 }),
|
||||
appendTiming(dir, { player: 1, phase: 'localOps', day: 1, stage: 1, startedAt: 2, endedAt: 3 }),
|
||||
]);
|
||||
const timings = JSON.parse(await readFile(join(dir, 'turn-timings.json'), 'utf8')) as unknown[];
|
||||
assert.equal(timings.length, 2);
|
||||
}));
|
||||
|
||||
it('reports a save that is not JSON instead of throwing (v0.8.4)', () =>
|
||||
withTempDir(async (dir) => {
|
||||
await writeFile(join(dir, 'game.json'), '{"seed": 42, "hist');
|
||||
const result = await loadGame(dir);
|
||||
assert.equal(result.found, false);
|
||||
if (!result.found) assert.ok(result.corrupt, 'a torn file was reported as merely missing');
|
||||
await writeFile(join(dir, 'game.json'), '{"seed": 42}');
|
||||
const shape = await loadGame(dir);
|
||||
assert.equal(shape.found, false);
|
||||
if (!shape.found) assert.match(shape.corrupt ?? '', /history/);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -607,3 +611,24 @@ describe('narration reaches a seat exactly once, by one path (#97)', () => {
|
||||
assert.deepEqual(third.lines, opening.lines, 'a reconnect is the full log, every time');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the intent sequence across a reconnect (v0.8.4)', () => {
|
||||
it('tells a connecting seat the last seq it had accepted, so a reloaded page continues the count', () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob']);
|
||||
const first = session.connect(0 as PlayerIndex);
|
||||
assert.equal(first.lastSeq, 0, 'a seat that has moved nothing should be told 0');
|
||||
const actor = (first.menu !== null ? 0 : 1) as PlayerIndex;
|
||||
const r = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.ok(r.accepted);
|
||||
// A reload: the client starts its own count from 1 again unless told otherwise.
|
||||
const again = session.connect(actor);
|
||||
assert.equal(again.lastSeq, 1, 'the reconnect push does not say where the count stands');
|
||||
// The repeat the old client would have sent — silently swallowed as a resend.
|
||||
const repeat = session.intent(actor, 1, { type: 'draw.fromHomeOffice' });
|
||||
assert.ok(repeat.accepted && repeat.pushes.size === 0, 'seq 1 should still read as an idempotent resend');
|
||||
// Continuing from lastSeq + 1 is a real move.
|
||||
const next = session.intent(actor, 2, { type: 'draw.fromHomeOffice' });
|
||||
assert.ok(next.accepted && next.pushes.size > 0, 'seq 2 was not applied');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+105
-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, withSavedDeal } 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,92 @@ 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. `withSavedDeal`
|
||||
* fills it for a save that names other rules and cannot name this one.
|
||||
*/
|
||||
const saved: { houseRules: { startingHand: 'sixRandom'; startingOffice?: 'depot' | 'whistlePost'; secondSectionCard?: boolean } } =
|
||||
{ houseRules: { startingHand: 'sixRandom' } };
|
||||
assert.equal(withSavedDeal(saved).houseRules.startingOffice, 'whistlePost');
|
||||
// ...and from a deck without the Second Section card, which went in at the same time (v0.8.3).
|
||||
assert.equal(withSavedDeal(saved).houseRules.secondSectionCard, false);
|
||||
|
||||
// A config that names it is left exactly as it is, in both directions.
|
||||
assert.equal(withSavedDeal({ houseRules: { startingOffice: 'depot' as const } }).houseRules.startingOffice, 'depot');
|
||||
assert.ok(!('secondSectionCard' in withSavedDeal({ houseRules: { startingOffice: 'depot' as const } }).houseRules));
|
||||
// And a config with no house rules at all is a fresh game, not an old save.
|
||||
assert.deepEqual(withSavedDeal({}), {});
|
||||
});
|
||||
|
||||
it('deals the same deck a pre-0.8.2 save was dealt from — no Second Section card (v0.8.3)', () => {
|
||||
/**
|
||||
* The card went into the deck in v0.8.2 after that release's save check had been run. A deck one
|
||||
* card larger shuffles into a different order from the same seed, so every save on the test
|
||||
* server refused at move 3 while the release notes said three would resume. Pinned here: the
|
||||
* legacy deal has no such card and the fresh deal has exactly one.
|
||||
*/
|
||||
const count = (g: ReturnType<typeof createGame>): number =>
|
||||
[...g.cards.values()].filter((c) => c.kind.kind === 'secondSection').length;
|
||||
const fresh = createGame({ id: 'f', seed: 7, config: { ...solitaireConfig, houseRules: {} } as never, playerNames: ['A'] });
|
||||
assert.equal(count(fresh), 1, 'a fresh deal should carry one Second Section card');
|
||||
const legacy = createGame({
|
||||
id: 'l', seed: 7, config: withSavedDeal({ ...solitaireConfig, houseRules: { startingHand: 'sixRandom' } }) as never, playerNames: ['A'],
|
||||
});
|
||||
assert.equal(count(legacy), 0, 'a pre-0.8.2 save was dealt from a deck with no Second Section card');
|
||||
});
|
||||
});
|
||||
|
||||
+11
-2
@@ -9,7 +9,6 @@ import assert from 'node:assert/strict';
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { applyIntent, check, refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
|
||||
import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
|
||||
import type { Intent } from '../src/engine/intents.ts';
|
||||
import { connectionsFor, exitsFrom, hasPort, joins, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
|
||||
@@ -27,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,
|
||||
@@ -1024,7 +1027,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,10 +24,13 @@ 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'] });
|
||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
|
||||
const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
+155
-26
@@ -8,7 +8,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { turnOf } from '../src/engine/state.ts';
|
||||
import { areaOf, check } from '../src/engine/apply.ts';
|
||||
import { acceptsCar as acceptsCarOf, areaOf, check } from '../src/engine/apply.ts';
|
||||
import type { Game } from '../src/web/game.ts';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
@@ -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 { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile, trainProfile } 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
|
||||
@@ -1785,17 +1794,6 @@ describe('the static build', () => {
|
||||
};
|
||||
|
||||
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
|
||||
// Specifically the PLAY verb: a hand usually holds cards that can only be discarded, and
|
||||
// discarding highlights the Department piles rather than the board.
|
||||
const clickVerb = (el: Record<string, unknown>, verb: string): boolean => {
|
||||
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
|
||||
const target = fn
|
||||
.call(el, 'button.cardact')
|
||||
.find((n) => (n['dataset'] as Record<string, string>)['verb'] === verb);
|
||||
if (!target) return false;
|
||||
(target['onclick'] as (() => void) | null)?.();
|
||||
return true;
|
||||
};
|
||||
/**
|
||||
* TRY EVERY PLAY BUTTON, NOT JUST THE FIRST — a card offering "play" does not necessarily play
|
||||
* ONTO THE BOARD.
|
||||
@@ -2450,6 +2448,43 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('never lists a car in "still needs" that the engine would refuse (v0.8.4)', () => {
|
||||
/**
|
||||
* `consistNeeds` counted by category on its own and could promise cars `acceptsCar` refuses —
|
||||
* nothing couples behind a caboose (§A.3), and a card narrows which freight it takes. It asks
|
||||
* `acceptsCar` per category now, so this holds every line of the panel to the engine's answer,
|
||||
* and the reverse: a category the engine still takes is never left off.
|
||||
*/
|
||||
let panels = 0;
|
||||
for (const seed of [430, 99, 270861860]) {
|
||||
const game = newGame(seed);
|
||||
for (let i = 0; i < 600 && currentActor(game) !== null; i++) {
|
||||
const menu = actionMenu(game);
|
||||
if (menu.makeUp) {
|
||||
const tray = game.state.trays.get(menu.makeUp.trayId)!;
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)!;
|
||||
const needs = menu.makeUp.needs ?? '';
|
||||
const categories = [
|
||||
{ name: 'freight', sample: profile.consist.freightTypes?.[0] ?? 'boxcar', re: /boxcar|hopper|reefer|tank|freight/ },
|
||||
{ name: 'coach', sample: 'coach', re: /coach/ },
|
||||
{ name: 'caboose', sample: 'caboose', re: /\d caboose/ },
|
||||
] as const;
|
||||
for (const c of categories) {
|
||||
const listed = c.re.test(needs);
|
||||
const takes = acceptsCarOf(tray, c.sample);
|
||||
if (listed) assert.ok(takes, `seed ${seed}: the panel lists ${c.name} the engine refuses: "${needs}"`);
|
||||
const wanted = (c.name === 'coach' ? profile.consist.coach : c.name === 'caboose' ? profile.consist.caboose : profile.consist.freight) > tray.consist.filter((x) => (x.type === 'coach' ? 'coach' : x.type === 'caboose' ? 'caboose' : 'freight') === c.name).length;
|
||||
if (takes && wanted) assert.ok(listed, `seed ${seed}: the engine still takes a ${c.name} the panel does not list: "${needs}"`);
|
||||
}
|
||||
panels++;
|
||||
}
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||
}
|
||||
}
|
||||
assert.ok(panels > 0, 'no seed reached a train being made up');
|
||||
});
|
||||
|
||||
it('keys each make-up car to the yard chip that shows it', () => {
|
||||
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
|
||||
// exactly those cars by type and load state. The yard is the surface.
|
||||
@@ -2570,7 +2605,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 +3151,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 +3162,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 +3241,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 +3282,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 +3791,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 +4059,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 +5835,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 +5893,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 +5929,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`);
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
|
||||
Reference in New Issue
Block a user