Compare commits

...
24 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Fable 5.1 def48201e4 v0.8.6 — a Competitive seat gets its save when the game is over
Jesse's ruling on TODO #117: accept the leak in Co-op; otherwise, save only at the end of
the game. `/api/save` answers 403 SAVE_AFTER_FINISH to a Competitive seat while the game
runs, and serves a Co-op or one-seat game at any time. The Save replay button says why and
stays disabled until the end. Pinned in the HTTP suite; documented in rules.md §6.4.

Also corrects the 0.8.3 changelog entry: 0.8.2's notes did name the Second Section card
going into the deck; what was missing was the save check after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:31:23 -04:00
Jesse.MarkowitzandClaude Fable 5.1 04ca74c365 v0.8.5 — housekeeping from the audit, and the playtest line retired
The third release from the audit; nothing a player sees changes. CHANGELOG has the detail.

The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that
existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every
line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused
declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were
dead bot functions from rejected candidates the round said it had deleted. The documents no
longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2),
model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted);
the README's account of bot flags now matches the bot's. Five playtest saves committed in
`docs/` against the repository's own rule are in the ignored `playtests/`.

What the audit found and did not fix is written down as TODO #112-#117, each with its reason.
#112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 —
`/api/save` hands a seat the seed mid-game — waits on a conversation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:02:33 -04:00
Jesse.MarkowitzandClaude Fable 5.1 e47cd3d400 v0.8.4 — the multiplayer transport: server and browser
The second release from the audit. 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 on a real port. CHANGELOG has the reasoning.

SERVER. Leaving a lobby freed the chair and kept the token, so a leaver could stream and
move for whoever took the seat next — revoked now, in memory and on disk. The browser
numbered intents from 1 per page load while the server remembered the seat's last number,
so the first move after a reload was swallowed as a resend — the connect push carries the
count and the client continues from it. Nothing serialised moves within a game and every
write shared one `.tmp` name, so two moves at once tore `game.json` (measured: 6 of 200),
and the boot's bare `JSON.parse` then took every game down — per-path write queues, a
per-game move queue, and a boot that skips one bad file. An error after the SSE head was
sent crashed the process. Bodies were unbounded before any secret check.

BROWSER. A double-click did the thing twice: one submit in flight at a time. A failed
submit is `false`, not an unhandled rejection. The documentation renderer flattened nested
bullets into a literal "- " mid-sentence on the published home-deck page. The make-up panel
promised cars the engine refuses; it asks `acceptsCar` now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:02:32 -04:00
Jesse.MarkowitzandClaude Fable 5.1 4d222a7eba v0.8.3 — the engine half of the audit, and the deal 0.8.2 silently changed
Seven rules faults and one dealing fault, from a four-way code audit (engine, server,
client, tests) read against the code before anything was acted on. Each is pinned by a
test that failed first. CHANGELOG has the reasoning; this is the list.

THE DEAL. 0.8.2 put the Second Section card into the deck after its save check had run
and without a line in its notes. A deck one card larger shuffles differently from the same
seed, so every save on the test server refused at move 3 — the boot log shows thirteen of
thirteen — while the release notes said three would resume. `withSavedDeal` (was
`withSavedOpening`) now sets `secondSectionCard: false` for a config that predates the
setting, and the thirteen replay exactly as 0.8.2 described: three resume, ten refuse, the
same ten at the same moves.

THE RULES. `check` never tested that a switching tray was in the actor's own district, so
a rival's train could be shunted and the rival charged the Moves. Occupancy matched on
coordinates alone, so a rival's crew blocked your track. A Department draw that emptied the
deck duplicated the drawn card and destroyed the refill card. The unjam cleared the first
load rather than the one named. The collision floor could not fire in Stage 12. The
Expedite fault was charged once per clearance question rather than once per phase. A train
held at the Limits was only ever released by another arrival, never by a departure.

Docs: rules.md describes each as built (and no longer says an Expedited train departs at
Shift Change — that was v0.4.8's reading, corrected in v0.4.9's code and never in the
document); game-state.md's collision-floor note now matches the code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:02:31 -04:00
Jesse.MarkowitzandClaude Opus 5 6f2a8dff09 The guide still has the workshop showing — TODO #111
Jesse's read of the rendered documentation after 0.8.2: dramatically better, and still
carrying too much of what was said about the game rather than what the game is. Four
things to fix, recorded with the measurements so the pass can start without re-surveying.

SECTION NUMBERS, 25 of them — rules 11, home-deck 5, mainline-deck 5, components 4,
quickstart 0. Two problems in the same notation. Sections 8.1, 8.2, 8.3, 10, 2.2 and the
7 cited by the deck documents resolve to nothing published: they are the prototype
rulebook's numbering, and rules.md:456 is a section whose own heading is named after it.
Sections 3.5 and 4.2 through 4.6 do resolve, because rules.md numbers its headings, but a
reader has to go counting. The renderer settles the fix: slug() in scripts/markdown.ts
strips a leading number, so a numbered reference has no anchor to point at and a
cross-reference has to become a named link. Do that first and the rest is mechanical.

REPOSITORY PATHS — four documents cite src/engine/content.ts to vouch that a table is
generated. The guarantee is worth keeping; the path is not.

CARD COUNTS, which reverses a standing ruling. #15a's "deliberately NOT including card
counts per category" was Jesse's call of 2026-08-22 and this is the same person reversing
it, so that entry is marked reversed here rather than left to read as current. The two
places the ruling is written into the documents are named. Generation answers the
staleness the ruling guarded against: every catalogue row already carries copiesInDeck or
copies, so the generator gains a column and the existing test keeps it honest. The one
hard part is recorded too — since 0.8.2 the office cards dealt depend on the starting
Office, so a printed count has to say it describes the default Depot opening.

BUILD-STATUS COMMENTARY, with the line that must not be crossed. "It is not implemented,
and never has been" and the live/dormantSolo/unbuilt column are an engineering status
printed for players, and they go. The facts underneath them do not: a player needs to know
the opponent-directed cards are not dealt and that the Interchange does not sort cars. A
pass that deletes the sentence and the fact together makes the documents wrong instead of
clean.

No code, no documents and no version touched — this is the worklist only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-23 07:43:24 -04:00
Jesse.MarkowitzandClaude Opus 5 3befc420da v0.8.2 — every district opens on a Depot, and the docs are pages now
A second-digit bump for a playtest read back against the save file. Nine questions
were asked of one three-Day game; three were bugs, three were the rules working
and undocumented, 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 rather than discovered after.

EVERY DISTRICT OPENS ON A DEPOT. 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. Two A/D tracks and passengers
from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder
game, set when the game is created. 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 as a test failure rather than an argument: the cue-coverage
pool needed widening from 24 seeded games to 60 before it held one 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, with no
error. `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.

THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE.

An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing
standing, the train the Interlocking had been holding at the Limits was 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 priority; the newcomer now takes the consequence it would have
met had the held train arrived first.

THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each
seat's "what have I sent you" bookmark was an INDEX into an array the game trims,
so once a seat's bookmark reached the limit the slice returned nothing for the
rest of the game — at a different moment per seat, because each holds its own.
That game's log ended at exactly the cap. Lines carry a sequence number now, which
survives trimming; proven by pushing twice the cap through a simulated seat.

§8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the
Subdivision was looked at, so a train entering a Double Track was released however
busy the rest of it was — that, not anything about Control Points, is what let
Train 8 out with no ruling. And a train standing at an Office was invisible to the
scan, so one about to re-enter the very Subdivision being entered counted for
nothing. Capacity is the test, not presence: a Depot with a track free is not in
the way; a Whistle Post with its one track taken is.

THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a
train released from the Limits (a side effect of somebody else's arrival, so it
simply appeared at the Office), and the train an Interlocking is holding, whose
explanatory tooltip has existed since #99 with NO renderer ever reading the flag.

WHERE A MOVE IS REFUSED, AND WHY. `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 nothing said. Those squares are blocked with the rule that blocks them
now, and the reasons are got by ASKING `check` rather than re-deriving: a second
implementation of the rules is exactly the failure the block list exists to avoid.
A train may also always recover its own caboose — X13 prints "may drop but not
pick up anything", and a train needs its caboose to be made up, so one that parted
with it could never legally leave again.

RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its
host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both
were built, measured, held back for a fortnight so a playtest could finish, and
applied now. A Second Section costs its card: `SECOND_SECTION` was declared in
content.ts and never dealt, so the action was free and the bot ordered 26
accidental ones in a measured round. The card is dealt and spent — gating on a card
the deck never holds would have deleted the mechanic rather than fixed it.

THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a
card reference is mostly tables, and as plain text a table is rows of pipes.
Markdown is still the one copy; the build renders it, and publishes the .md beside
each page. No Markdown library: this project has no runtime dependencies and one
would be a poor first. The pages add what Markdown cannot carry without drifting —
a nav across the set, a contents list built from the headings actually rendered,
an anchor on every heading, a 70-character measure, and tables that are tables.
They print as ink on paper.

The references caught up with the rules, checked rather than assumed: two
statements had gone from stale to misleading (the Quickstart told a new player to
"get a Depot down as soon as one appears"), and four rules nobody could look up
are written down — the Office tier table, §8.1 in practice, what the Circus Train
pays for, and that a Realignment can be a card with no legal target.

Adding one card to the deck reshuffles every seeded deal, which broke five
fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so
seeds moved and pools widened rather than assertions weakening, and the clearance
fixture pins its terrain the way `enhancements.test.ts` already does. The three
published replays were re-recorded.

Closes TODO #40, #42a, #108, #109 and #110.

1046 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-23 07:07:21 -04:00
Jesse.MarkowitzandClaude Opus 5 517238a727 v0.8.1.0 — the car comes back empty, and the lobby lets you leave
A second-digit bump, deliberately. 0.8.1 had been reserved for the seatless
display table; that work is getting more thought, and this table pass over
v0.8.0.17 earned the number on its own. Six reports: one was a rules question,
one a wording complaint with a real bug underneath, four straightforward.

A CAR CLEARED FROM A RED INBOUND BOX CAME BACK STILL LOADED. Reported as
wording — "technically accurate but doesn't make any sense" — and the wording
was the visible half. `inboundCleared` pushed `pooled(e.stock)` under a comment
reading "a car back in a yard is back in the common supply, carrying nothing",
and `pooled` does not do that: it strips the load's origin stamp and keeps
`loaded` ON PURPOSE, because a train can retire at a Division Point with freight
aboard. So the comment described an intention the call never carried out, and
every car the Freight Agent cleared reached the Classification Yard carrying a
load already delivered and already paid for.

It bites hardest on coaches: `passengersDetrained` takes `coach && !loaded` out
of the Division Yard and §2.2 refills that yard from Classification, so a
cleared coach came back as stock that could never unload another passenger.
Measured over five three-Day solitaire games: 18 loaded coaches in
Classification against 6 empty. The red box is where a journey ENDS; clearing it
sends the passengers out of the station, or the delivered load into the
industry, and returns the CAR, empty. The option says that now instead of
describing the counter that moves.

SCOPED TO THE RED BOX ON PURPOSE. `retireTrain` also returns loaded cars and is
left alone: that is what `pooled`'s own documentation describes, and a loaded
car in a yard is pre-loaded cargo rather than dead stock — it can be made up and
delivered, and a loaded coach can still detrain. Only the red box's contents had
already finished their journey.

GAMES IN PROGRESS DO RESUME, MEASURED RATHER THAN ARGUED. Yard contents change,
so the worry was real. All twelve saves on the test server were pulled and
replayed through `tryResumeSession` — the server's own boot check — against this
build. Six resume, six refuse, and the six refusals are the SAME six, at the
same moves, with the same codes, that 0.8.0.17 already logged. Nothing new was
stranded. That pre-install replay is a better check than reading the next boot
log, because it answers before the install rather than after.

THE HISTORY SAID "Mainline card 7" and left the reader to remember what card 7
was — with two Plains dealt, which is why the slot is kept beside the name
rather than replaced by it. A second fault sat one word to its left and nobody
reported it: the name was built as `e.key.replace(/([A-Z])/g, ' $1')`, so
`absSignals` printed as "abs Signals" while the action list directly above said
"ABS Signals". `narrate` takes `mainlineAt` and `enhancementName` beside the
`facilityAt` it already had, and `simpleCardName` is exported so the log reads
the same table the buttons do. `mainlineModified` had both faults and is fixed
with it.

LEAVING A RUNNING GAME WAS A DEAD END. `enterSeating` hides the choice section
and only the lobby's own two leave paths put it back; leaving a running game is
a third route, so the lobby came back holding nothing but "Games you are in"
with both doors on the page at display:none and no control able to reveal them.
Reset in `runLobby`, which is the one function every route onto that screen goes
through — which is exactly why the two paths that did it themselves missed a
third.

THE LOBBY'S ACTION BUTTONS CARRY THE BOARD'S AMBER. A list of the actions rather
than `#lobby button`: the settings form under Create is a field of inputs, and
amber on all of it would say everything is a move and so say nothing. A disabled
Start game drops back to chrome.

THE DEPARTMENT REFILL IS A RULE AND IS NOW WRITTEN DOWN. §6.2 — "if any of the
Department decks is empty, draw a Home Office card and place it in the empty
spot" — firing only when the draw actually empties the pile. Kept as implemented
(Jesse's ruling) and stated in rules.md and home-deck.md, neither of which had
ever mentioned it. A rule implemented from the prototype and never written down
is a rule that surprises the table.

1016 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-22 21:20:52 -04:00
Jesse.MarkowitzandClaude Opus 5 b90c0413d2 v0.8.0.17 — four things the game knew and the screen did not say
All four reported from a table on Day 1 of v0.8.0.16, and all the same shape.

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

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

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

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

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

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

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

1010 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-21 05:53:52 -04:00
Jesse.MarkowitzandClaude Opus 5 dc31555625 v0.8.0.16 — the guide a tester can reach, and a flag that named the card instead of the gate
v0.8.0.15 wrote a Quickstart for a tester who has never played and then left it
in docs/, where a tester does not look — reachable only by somebody who already
has the repository. Nobody handed the box had it. build-web.ts now copies it to
dist/quickstart.md and the splash page offers it under the three doors, as a
line rather than a fourth door: reading the guide is not a way to play, and
giving it equal weight in that grid would say it is.

COPIED, NEVER RE-WRITTEN. The Markdown document stays the one copy. A
hand-written HTML twin drifts from it on the first edit, which is the failure
#15a was raised about and precisely what the v0.8.0.15 pass spent itself
undoing. It is served as PLAIN TEXT, which is honest rather than good — tables
render as pipes and the links do not click. Rendering it into a styled page
wants a small Markdown converter and is filed as TODO #109; build-web.ts's
comment names that number rather than gesturing at "the next step", so the file
and the worklist cannot drift the way the references just did.

Two things had to be true and tsc checks neither, so both are tests. The href on
the splash page and the filename the build writes are two strings with nothing
connecting them: rename the document and the build quietly publishes nothing
while the page keeps offering a link that 404s. And a .md file must not arrive
as a download — the server's MIME fallback is application/octet-stream, which a
browser saves instead of displaying, so the link would have handed a tester a
file to save rather than a page to read. '.md' is in http.ts's table now, and
the test reads that table out of the source rather than asserting on a copy of
it, which would pass while the real one was wrong.

VERIFIED AGAINST A RUNNING SERVER, not only compiled: 200,
text/plain; charset=utf-8, the guide's own first lines, and the splash link
resolving.

THE sortsCars COMMENT. Asked after v0.8.0.15 whether everything now agreed, and
the audit turned up one place that did not — the field's own doc comment named
the card's printed text as though it were the flag's meaning. Nothing reads it
to permit a sort; its two readers, resolveExtraStart in apply.ts and the
enumeration in legal.ts, both ask whether this is the one Mainline card with a
Yard Limit and therefore the one an Extra may be made up and started on. Comment
only, and worth the bump because of where it is: it is what a developer reads
before using the flag, and it is the likeliest source of the sentence v0.8.0.15
had to correct off the board. The name is kept for its link to the card face and
the comment now says outright that the name is not the meaning.

The five references and docs/design.md read v0.8.0.16. They describe this build
because the audit re-checked them against it, not because the number was swept
forward — a stamp bumped without a reading is worth less than none.

1001 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-21 01:28:51 -04:00
Jesse.MarkowitzandClaude Opus 5 f308a2d94d v0.8.0.15 — the reference documents, and a card that advertised what it cannot do
The four hand-written references brought up to the game as it actually runs,
ahead of the next testing round, plus a Quickstart to hand a tester who has never
played. They had not been touched since v0.6.2 — a month and two minor versions —
and each now says at the top which build it describes.

ONE LIVE BUG CAME OUT OF THE PASS. mainlineDescription told players "Cars may be
sorted into any new order here" on the Interchange. It is the printed capability
and has never been implemented: nothing reads sortsCars to permit a sort, and its
one live use is marking the card an Extra may be made up on, because it is the
Mainline card with a yard. That sentence is not only documentation — view.ts
renders it as a Mainline card's `what`, so it is what a player reads on the
board, and the generated reference printed a "Sorts cars: yes" column beside it.
A card advertising a button that does not exist sends a player hunting for it and
then concluding the game is broken.

What the documents had wrong, all of it verified against the code rather than
read for tone: the victory model in the Rules book (firstToTarget /
highestAfterDays and the target-bearing length presets stopped existing in
2026-08 — it is a free days count and a combined floor of 3 x players x days);
"there is no lobby, no server, no multiplayer"; crossing time in mph rather than
regions; Extras launched automatically eastbound; the Uncontrolled Siding listed
as a passing card; industry track length taken from the box count; and a
"Sister Trains" optional rule that never existed. The Home deck's counts table
came out under TODO #15a — Jesse's own ruling that counts move with balance —
and it had been wrong for a month, which is the argument made twice.

The Home and Mainline deck references are restructured to explain how a deck is
USED and to defer every per-card table to rules/as-built.md. Duplicating it by
hand is precisely the drift #15a was raised about: as-built needed no correction
beyond the Interchange, because build:cards regenerates it and a test fails when
the checked-in file disagrees. Everything hand-maintained around it had drifted;
it had not.

docs/design.md, the index everything starts from, said v0.4.3, "what is not: the
server", and 493 tests. It now also lists the player-facing references, which it
never has, so the Quickstart is findable at all.

999 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
2026-09-20 12:13:18 -04:00
Jesse.MarkowitzandClaude Opus 5 a6657241de v0.8.0.14 — the coaches that never come back, and a district that ends at its own sign
Six reports from the Day 2-3 playtest of v0.8.0.13.

GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the
Limits, which makes a once-legal move illegal, so a save holding one is refused
at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call,
knowing it strands the game on the box. The file is untouched and v0.8.0.13
still finishes it.

The Sparrow running empty and Tom unable to unload his passengers are the same
shortage from opposite ends, and both are the rules working as printed. §9.2
boarding discards the emptied coach into the CLASSIFICATION yard, detraining
draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back
only when the Division Yard runs bare — so coaches move one way. Measured over
the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the
end, fifteen piled in Classification, the Division Yard steady at 46-47 freight
cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage
stays and the game says so. A train made up short now reports what its card
wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null
for "done" and for "cannot be done" alike, so the phase moved on in silence); the
yard panel warns while the condition lasts; the Depot's blocked panel was right
all along.

The modifier outside the Limits was working as designed and the design was
Jesse's own call, now reversed. What decided it is what the board shows — a card
beyond your own sign, in territory §8.1 and §10 reason about. The case that
motivated the exemption was checked on the reported move rather than argued away:
the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside
were free, legal and adjacent.

Switching filled the history with coordinates — a line per move, plus one per
mandatory coupling. It is still LOGGED in full; what the panel draws is the line
saying somebody switched, the first move, work at an INDUSTRY (named, not a
coordinate), the Small Yard sort, and a closing summary. The suppressed lines are
still WRITTEN, marked `trace`: dropping them outright was the first attempt and
the step-queue suite caught it, because dwellForStep pays nothing for a step that
said nothing, so the board stopped replaying switching at all. The last move
rides in the closing line rather than being kept in place — nothing knows a move
was the last until the turn is over, by which time the line has been streamed to
every client and cannot be revised. Two things fell out of reading those lines:
every move ended with a tutorial sentence the opener already gives, and the move
count said "of 6" with the six hardcoded, which is wrong on a night Stage.

Make-up lines name their train — they all read "the train being made up", so
looking back for train 10 found nothing under that name — and "a empty tank" is
now "an empty tank". The Small Yard's options read as the train they would build
instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable.
Two of those five were junk: bringing the last car to the end is the identity and
would have spent a Move, and a two-car reversal duplicated its only real option.
Both are filtered by the resulting order, not by the case that made them.

A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open
question. Two sources disagreed and the design notes won: the v0.4.5 card text
says the sort puts the engine at the nose, implications.md says "any order,
including cars ahead of the engine". `engineAt` is optional on the intent, so
older saves replay to the same train. The menu did not multiply — the engine is a
separate short list against the consist as it stands, eight options for a
four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is
deliberately direction-free, so a PUSHING train is fit to run and only a
broken-backed one is held. The button warns by asking that predicate rather than
copying it, and immediately earned itself — every one of train 10's eight options
is refused, the one asked for at the table included, because that train carries a
caboose and each sort moves it off the rear. That is the right answer rather than
a gap: the train is already made up, so every offer would break it, and the labels
say which is which. A made-up order is always on the menu for a train that needs
one, because "bring car k to the tail" is enumerated for every car and the caboose
is one of them.

Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow.
"Front to back" is not a direction a table can read — which end is the front
depends on which way the train points — and board-svg has reversed east-facing
consists since v0.8.0, so the button now describes the same train as the picture.

The Freight Agent, Porter and Laborer groups now say what the role is for, where
the role is chosen. Tom reached for the Freight Agent to detrain passengers,
which is a Porter's action in the Cargo phase; both halves were working and
neither was visible.

TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the
measurement, to revisit on a second game's data).

999 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
2026-09-17 20:49:31 -04:00
Jesse.MarkowitzandClaude Opus 5 ad277fb994 v0.8.0.13 — the board on screen is the board you can act on
Nine reports from the Day 1-2 playtest of v0.8.0.12. One moved a car, one was a
rule working correctly with nothing on screen to say so, and the rest are things
the table could not see.

The real bug: the Division Yard chips stayed lit and clickable while the board
was catching up. `renderActions` puts the action list away while the queue is
behind — a move offered against a position that has already moved on is a move
made blind — but the make-up wiring sat outside that guard. A chip was clicked
during a bot's make-up, a coach left the yard, and the train ended up with three
cars: a real intent submitted against a board several moves stale. The chips now
follow the queue like every other control, and the yard COUNTS are drawn from the
shown board rather than the live game — they were the one panel still reporting a
future the player had not been shown.

The Office Area picker had a button per opponent and none for yourself, so the
one player who could not reach their own district was the player waiting on
everybody else. Your own seat is in the row now, and the row is ordered by SEAT,
west to east as the Division map draws it, rather than by join order — sorted
from the Frame's own `seat` on every render, so it rotates with Employee Rotation
instead of having to be told.

§5's handover of the Fedora rode on `actorChanged`, which is turn bookkeeping and
which `record()` drops as noise, so the one moment it carried that a player needed
went past in silence. It is its own event now, narrated and announced. The phase
keeps its name: the Supervisor Shift refreshes every Laborer and Porter EVERY
Stage and the Fedora moves only every third.

A collision now names whose Office it was and who paid the 5 Revenue, which rode
in a separate `revenueChanged`; a Mainline collision is phrased differently
because §10 makes it the Superintendent's.

Passengers, reported as a bug and ruled not one after replaying the save: the
Depot's capacity and modifiers were fine, and §6.3 stocking wants a LOADED coach
out of the Division Yard, which held none while six sat in Classification. The
shortage stays — running out is part of the game, the same ruling Gitea#2 got —
but the blocked panel says so now instead of the action being silently absent.

Smaller: "working left" is "working eastward" in the make-up panel and the New
Train tip, because the map runs west to east and the table does not; the history
panel keeps 90 lines instead of 60 in the same 230px box.

`git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is NOT empty this time:
`events.ts` declares `superintendentChanged` and `advance.ts` emits it. Both are
additive — `check()`, `legal.ts` and every predicate are untouched, and events are
derived by replaying a save rather than stored — so no once-legal move became
illegal and games in progress resume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
2026-09-17 04:43:59 -04:00
Jesse.MarkowitzandClaude Opus 5 7c9ef8797d v0.8.0.12 — put a player back in their seat after losing their browser storage
Gitea#33. A session token is the only identity the game has, and it lives in
exactly one place the player controls: their browser's localStorage, scoped to
the origin they joined at. Lose it — a cleared profile, a private window, a
different browser — and the seat is unreachable while the game runs on and the
session sits intact on disk. Reported from the table: of two humans in one game
the host reloaded straight back in, the joiner met an empty lobby.

Diagnosed before it was fixed, and two server-side theories of mine were
retracted on the evidence: no storage key changed in 0.8.0.11, nothing in the
app deletes the secret or name, create and join both call persistSession, that
game's sessions.json held both seats, and it resumed with 80 intents replayed.
Both players used the same URL, so it was not a second origin either.

The fix is a recovery link. An administrator mints a code for a named seat
(admin-gated: deciding somebody lost a seat is a judgement no route can make);
the player opens the link and the page trades the code for the token over a
POST, then strips it from the address bar. The link never carries the token —
lobby-and-sessions.md §1 says keep it out of URLs, and a recovery link is
exactly what gets pasted into a chat. Single use, 30-minute expiry, held in
memory because a restart dropping them is the right failure.

server/claims.ts is a pure store, so single use, lazy expiry and one identical
answer for unknown/spent/expired codes are tested rather than asserted. The
admin game listing gained seatedPlayers — the seats a human holds a token for,
read from the session map rather than guessed from player names — so the
StartOS action can offer real players instead of bot chairs.

No rule changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so
games in progress resume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-16 20:16:24 -04:00
Jesse.MarkowitzandClaude Opus 5 9a9e50b3c6 v0.8.0.11 — sixteen fixes from the second multiplayer playtest
Arrivals name whose Office they reached, and no longer tell every seat they can
work the train. The turn chart follows the animation queue, so being five behind
looks five behind across the whole screen rather than half of it. Pause sits
beside Skip and preserves the dwell a held step still owed. A one-render look at
another player's Office Area. The district summary counts the board being shown.
LIMITS is printed beneath its card instead of through its border. The Mainline
region divider is visible. Only Hilly mentions FAST/SLOW, because it is the only
card that reads it. A passenger Modifier on a Whistle Post reports itself dormant
rather than claiming the facility "only receives". An automatic phase says what
the Division is doing instead of answering by negation. The version appears once
in the header rather than twice on every .s9pk. Save files carry the join code,
the Stage and the date.

The New Train phase, reviewed before being changed: the make-up panel now says
what the train STILL needs rather than only what its card calls for, explains
that a player adds one car before the round passes on, marks the train being
loaded on the Division map, and gives an addable car in the yard the same amber
every other clickable thing on the page wears.

Reasoning, measurements and the reports behind each are in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-16 16:06:01 -04:00
Jesse.MarkowitzandClaude Opus 5 4adf149ba5 v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 22:44:20 -04:00
Jesse.MarkowitzandClaude Opus 5 76c6e103b3 v0.8.0.9 — the bot plans its switching turn, stops wasting its draws, and the engine walks each route once
The developer bot, re-measured decision by decision against the bot before it,
goes from about -0.3 revenue a game to about 4.8:

- plans the whole switching turn before its first Move (sim/switch-planner.ts),
  +2.89 over 1600 paired seeds; closes TODO #53
- takes a face-up card only if it could play it, +1.52 over 1600 seeds
- stops running Second Sections by accident in the New Train phase, +0.32
- lays track by what the district can do afterwards, +0.12 over 6400 seeds,
  run-arounds in 22 of 60 districts against 9

The engine is 2.8x faster with play proven identical: a route cache scoped to
one unchanged position, applyIntent split into prepareIntent + commitEvents,
and less allocation in exploreMoves. npm test now leaves out the bot
simulations, which run as npm run test:sim.

No rule changed; games in progress resume. Rejected candidates and the
Second Section card question are in CHANGELOG.md and TODO.md (#104-#106).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 15:30:42 -04:00
Jesse.MarkowitzandClaude Opus 5 072029b1f7 v0.8.0.8 — a played train does not come back; a discarded one does
Jesse's ruling on the question v0.8.0.7 filed: once a regularly scheduled train has
been played its number is on the timetable, so putting it back into a reshuffled
deck to be played again makes no sense. The same card sitting in a discard pile was
never played and its slot is still open, so it should come back. An Extra is a
single run rather than a standing slot, so a played one is free to run again.

The test is therefore WHERE the card is, not only what it is — which is worth
writing down, because it is exactly the rule a later tidy-up would simplify into
filtering by kind everywhere.

And the duplicate that started it: trainScheduled was pushing a synthetic
train-<number> into the Salvage Yard beside the real card cardPlayed had already
put there — four scheduled trains left eight entries in a pile holding four cards.
Nothing read it, it inflated the depth, it displayed as "a card", and it would have
been swept into the draw deck to be drawn as an id with nothing behind it. Removed,
which retires the phantom-id class rather than papering over it, so v0.8.0.7's
cardName resolver for it goes too.

Games in progress resume: no predicate changed its answer, and a draw is a draw
whatever is on top. What differs is the Yard's depth, which was double-counting,
and what a reshuffle recovers — and reshuffles are effectively unreachable, with
zero seen across eight games driven to 4000 moves.

Closes #23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 07:19:47 -04:00
Jesse.MarkowitzandClaude Opus 5 d0e5091824 v0.8.0.7 — the Salvage Yard had nothing to say, and phases too little time to read
The Salvage Yard was face up all along; its tile just read "a card". apply.ts
pushes a synthetic train-<n> id on trainScheduled, nothing in s.cards matches it,
and cardName fell through to its default — and since a train is scheduled several
times a Day that id is on top most of the time. Measured before touching anything:
the tile read "a card" from the opening frame through 60 pushes while its depth
climbed from 2 to 8. cardName resolves it now, in sim/view.ts, because this is a
name.

The engine half is filed as Gitea#23 rather than fixed here. reshuffleIfDepleted
sweeps the Salvage Yard back into the draw deck, so that synthetic id can be
shuffled in and drawn into a hand as an id with no card behind it. Eight games
driven to 4000 moves across eight seeds produced zero reshuffles, so it is latent;
there are two defensible fixes and the choice turns on what the synthetic id is
for, which is not a call to make while fixing a label.

And phases scale with the speed control again, damped to a third of the rate. They
were pinned in v0.8.0.3 because scaling them walled off a player's own turn; pinned
turns out to be too short to read at 10x. Damped satisfies both: 1x unchanged, 10x
lands exactly on the four-times guess. Bounded because phase beats cluster rather
than accumulate — 1.0 per push on average, 4 at worst, so the wait after a move is
~2.4s typical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 06:58:09 -04:00
Jesse.MarkowitzandClaude Opus 5 64e8ce584f v0.8.0.6 — your move waits its turn, the lit pile keeps asking to be looked at
Your actions are put away while the board is catching up. The board on screen is
behind the game, so a move offered there is a move against a position that has
already moved on — and the screen had grown to four things competing at once: the
district, the history, the catching-up row, and a lit pile. Skip is one click away,
so the wait stays voluntary.

That could have locked a player out of their own game. Hiding actions behind busy()
makes that flag the thing standing between a player and their turn, and without
requestAnimationFrame nothing ever advances the queue — so busy() would never
clear. Caught by the DOM-stub test that has been proving this page still starts
since long before any of this existed. No rAF now means draw everything at once,
which is what pace 0 does deliberately, and a queue that throws empties itself
rather than stranding anyone.

The lit pile was never brief: measured, it stays lit for 6997ms at 10x. It was a
single flash over a dark fill, easy to miss while watching the district — a state
that settles stops asking to be looked at. It pulses now for as long as the move is
up.

And the pace ceiling was not theoretical. 10x was the top of the ladder and was
reported still a bit fast; it runs to 20 now. A control whose limit is reached in
ordinary use has the wrong limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 05:49:01 -04:00
Jesse.MarkowitzandClaude Opus 5 3fca325699 v0.8.0.5 — the Home Office deck, and lighting the pile a move touched
"Many operations still occurred too fast for me to see", at 10x — where an action
already holds the screen for seven seconds. So it was never duration: a bot drawing
a card changes one number in a panel nobody is watching, and the board sits
unchanged. Raising the dwell was the wrong lever and it had been pulled three
times.

f.deck has carried the face-down count since the Frame existed and nothing drew it
— the display gap test/display-gaps.test.ts sweeps for, surviving in the one panel
that draws every other pile. It is a tile now, first in the row, face down, because
that is the order a card travels and not knowing what is on top is the point.

And the piles a move touched are lit for as long as that move is on screen. Derived
from the frames either side of a step rather than sent, so nothing joins the
protocol and the 0.8.1 board gets it free. What lights follows what is public, and
was measured across four seeds rather than reasoned about: a Home Office draw
lights the deck and never names the card; a Department draw lights that pile, and
the deck too when it refills; a discard lights the Department it lands on; a played
card lights the Salvage Yard. Switching and new trains light nothing here — they
move the board, which the district panel already follows.

A state rather than a flash: the timetable's fixed 1.5s animation would be over
long before a seven-second pause. Not for your own moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 04:35:59 -04:00
Jesse.MarkowitzandClaude Opus 5 fc40fc39ed v0.8.0.4 — take the test server's name back out of the tracked files
Both repositories allow anonymous clone — checked rather than assumed: info/refs
for git-upload-pack answers 200 for each, git-receive-pack answers 401. So
everything committed here is public, and tracked files are supposed to carry
placeholders rather than real hosts.

Ten mentions added while building v0.8.0 are now "the test server" or "the target
hardware", across CHANGELOG.md, the common-board plan's three deferral banners,
sim/pacing.ts, and two test files. Prose and comments only, no behaviour; the
quotes are untouched, because what was said about bot pacing is the part worth
keeping.

Left alone deliberately: nineteen older mentions in entries about v0.7.5, v0.7.6
and v0.7.8 and in TODO.md, since rewriting a changelog after the fact makes the
record less true; and scripts/deploy-web.ts, where the host is the functional
default for FB_URL rather than prose — turning that into a required variable
changes how deploying works and wants deciding on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 21:43:06 -04:00
Jesse.MarkowitzandClaude Opus 5 ff629c0708 v0.8.0.3 — Skip on the left, a caption that says who, and a clock that stops
stretching

Three things from playing v0.8.0.2, all about the row rather than the mechanism.

Skip was on the far right and a player's eye is on the countdown. Moved to the
left, in front of the count.

The caption said what but never who. Measured over 40 turns of a real 3-seat game,
half the waiting is automatic phases — 21.0s of phases against 21.7s of other
players — and a phase narrates as "Mainline", which is accurate and no answer at
all to "who am I waiting on". A phase introduces itself now: "The Division:
Mainline phase". A player's move already carries its name from record(), so it is
left alone. The row was also hiding one step early, because it showed only while
behind > 0 — which goes false exactly when the last step of a burst goes up, so the
step most likely to be read lost its caption.

And the speed control was stretching the clock along with the players. It was not
his own move being replayed — own moves have cost nothing since v0.8.0.1 — it was
the phases behind it, which put 105 seconds of clock-ticking into a 5x game. pace
now scales a player's move and leaves a phase at its tabled beat, which is what the
control has always claimed to do. Off still means off for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 21:06:22 -04:00
Jesse.MarkowitzandClaude Opus 5 c10f52791e v0.8.0.2 — the speed control that was only ever a URL parameter, and a Day-end
contradiction

Two things found by playing v0.8.0.1, neither in the mechanism itself.

?pace= never worked. index.html's doors are play.html?lobby and
play.html?solitaire, so arriving through the splash replaces the query string and
the play page only ever saw ?lobby — a whole game was played at 1x while believing
it was at 7x. v0.8.0 shipped that parameter as the only way to change speed and the
game's own front door destroyed it. There is a control on the play screen now,
beside zoom, persisted per viewer; the doors carry pace through as well, so the URL
lever is honest for handing two playtesters different speeds. PACE_LEVELS moved to
sim/pacing.ts with DWELL and MAX_PACE — the tuning surface in one file, and
testable. The committed default is unchanged: what it should be is a question for a
game played at a speed that took effect.

And the Day-end dialog said "0 today, 2 in all". advance.ts increments the Day and
then zeroes collisionsToday, and noteDayEnd() fires when the Day goes up — so the
dialog reporting the Day that just finished was drawn from the very frame in which
that Day's count was reset. Reproduced on four of five seeds before changing
anything. The count is captured at the rollover now; it is not derivable on the
client, because in multiplayer the push announcing the new Day is the same push
that carries the reset. And "today" was the wrong word regardless: it names the Day
instead — "Collisions: 2 on Day 1, 2 in all".

Unrelated to v0.8.0 — that one has been wrong since the dialog was built for
Gitea#10, and needed somebody to play a Day with a collision in it and then read
the summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 20:08:39 -04:00
Jesse.MarkowitzandClaude Opus 5 0cfeb4c496 v0.8.0.1 — bot play was way too fast, and the last step never got its moment
Two things from the first real play on phoenix.local. One bug: busy() was
pending.length > 0, so the final step of a burst reported the queue idle the
instant it was shown — the district panel snapped back to the viewer's own board
and the countdown row vanished before either could be read.

And calibration. "Start at 1s and tune down" was applied to switching, while a
250ms action tier was invented beside it — fine for a switching burst, wrong for
the common case, since switching is not legal until there is track down. A real
early-game bot turn measured 750ms end to end. Actions are 700ms now, and
localOps.choose moved out of bookkeeping: it is the line announcing what a bot is
about to do, and at zero dwell nobody ever saw it.

The viewer's own moves now cost nothing — their board comes from their own Frame,
so holding their click only delayed the thing they wanted to watch. And pace
supports 2 and 3 as asked, bounded by MAX_PACE so a typo cannot look like a
frozen board; every tier scales together, so the weighting survives any speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 17:20:18 -04:00
108 changed files with 17020 additions and 8803 deletions
+2029 -3
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -46,6 +46,15 @@ deliberately no longer names one: it went stale for six releases.
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
where its automatic phases finally get a visible beat.
**The caption and the history panel are not the same list**, since 2026-09-17. Switching is logged
in full; a move from the middle of a turn writes its line as tone `trace`, so the step still
carries it — the board captions the move and earns its dwell, and `dwellForStep` pays nothing for a
step that said nothing — while the history panel filters the tone out. What the panel draws is the
line saying somebody switched, the FIRST move, work at an **industry**, the Small Yard sort, and a
closing summary. The last move rides in that summary rather than being kept in place: nothing knows
a move was the last until the turn is over, by which time the line has been written and streamed to
every client, so it cannot be revised.
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
the page is proven not to throw, but nobody has watched a bot switch on screen.
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
@@ -56,7 +65,8 @@ deliberately no longer names one: it went stale for six releases.
Balance is *not* where it should be, and this file no longer quotes a figure for it. It used to say
"the developer bot averages 7.0 Revenue against a target of 20", which stopped being true the moment
the transit rule it names was defaulted to off — that rule was worth ~5.4 of the 7.0, for traffic
nobody had to work. Measured at the current defaults the bot means about **zero**.
nobody had to work. Measured at the current defaults the bot meant about **zero** until it began
planning its switching turns (2026-09-14), which put it near **2.8**.
The three rates — passenger per coach, freight per load, train per transit — are **settings fixed when
the game is dealt**, along with the opening hand and where an Extra may start, so the economy can be
@@ -74,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
@@ -102,7 +112,8 @@ separate thing: it assembles the static SITE into `dist/`.)
```sh
npm install
npm test # node --test
npm test # node --test, everything except the bot simulations — run after every change
npm run test:sim # test/sim.test.ts, the bot simulations (~7 min) — run after a bot or balance change
npm run typecheck # tsc --noEmit
```
@@ -113,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
@@ -123,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
+551 -35
View File
@@ -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,16 +102,17 @@ 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
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
6. **Rules** — #12 #80 #82 #83 #85
6. **Rules** — #12 #80 #82 #83 #85 #108
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
8. **The bot** — #41 #57 #59 #53 #54 #58 #55 #56 #60
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
9. **Code health and housekeeping** — #46 #45 #84 #87
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.
@@ -100,29 +133,127 @@ Everything else in this file waits behind a release; this waits behind an aftern
**Test runs WERE made across 0.7.4 through 0.7.9** (Jesse, 2026-09-07) and produced no change
requests — the two bugs that did come out of them are Gitea#21 and #22, fixed in v0.7.9.1. So this
section is not "nobody has touched it since 0.7.4"; it is the narrower and still-true claim that the
specific paths below have not been exercised at a table. **More testing is planned at the end of the
0.7.9 series, before 0.8.0 starts** — that is the moment to close these, not a separate errand.
specific paths below have not been exercised at a table.
- [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
**The gate moved.** It was "before 0.8.0 starts"; 0.8.0 shipped anyway, through v0.8.0.8, so the
session now runs against that build and covers what it added as well. See **Preparing the session**
below — written 2026-09-10 because the measurement it rests on is the whole point: **three of the
four things this section is named for do not happen by themselves.**
### Preparing the session
**MEASURED, 2026-09-10, across ten full competitive games driven to completion.** What a table will
meet without trying, and what it will not:
| interruption | fires in | so |
| --- | --- | --- |
| Superintendent clearance (§8.1) | **9/10 games** | you will meet it; just play |
| a train held at the Limits | 7/10 | ditto |
| Extras started and queued | 10/10 | ditto |
| collisions | 7/10 | ditto |
| Red Flags set / spent | 7/10, 6/10 | ditto |
| **the Yard Office offer** | **0/10** | must be set up |
| **the Red Flag hold and its prompt** | **0/10** | must be set up |
| **extended play (`dayExtended`)** | **0/10** | must be set up |
Those last three are exactly what #39 and #35 are NAMED for. They are not broken — they are
conditional, and the conditions are these, read out of `advance.ts` rather than guessed:
- **Yard Office** (`advance.ts` ~1290) needs the destination district to contain a card carrying the
`yardOffice` **enhancement**, AND an arriving train with **no coach** in its consist, AND a usable
route. The bot never builds one, so **somebody has to build a Yard Office and then let a freight
train arrive.**
- **Red Flag hold** (`advance.ts` ~1232) needs the destination player to be **holding the Red Flags
maneuver card**, AND an arrival that would genuinely collide — §8.3's own two ways: no free A/D
track, or cars fouling the Running Track. So: **hold that card and let your A/D tracks fill.**
- **Extended play** needs the timetable to RUN OUT, which a five-Day game does not do. Deal it with
**`days: 1`** — that is exactly what the 2026-08-29 API verification did, and why it got there.
**What the session needs**
- **Two people, two browsers, two devices.** #35's remaining gap is specifically what a SECOND
player sees while waiting on a first, and whether "waiting on Carol" still reads once Carol has
closed her laptop. That cannot be tested alone, and it is the half that has never been done.
- **Two games, not one.** A short `days: 1` game to reach the extension vote, and an ordinary game
for everything else — with somebody deliberately building a Yard Office and holding Red Flags.
- **#42a is separate and takes five minutes**, solitaire, one person: click every field on the setup
screen and confirm the dealt game matches what was chosen.
**The caution this section exists because of.** #35's own Reference entry records that the
2026-08-29 verification passed over the HTTP API — **which renders no dialog** — and that is exactly
why the v0.7.9 bug survived: the vote sat underneath a modal results dialog whose only control was
Close. What was proven was that the SERVER supports extended play, not that a player can reach it.
Read that into every "verified on `phoenix.local`" line in this file, and into everything v0.8.0
added, all of which is verified by test and simulation and none of it by eye.
**What v0.8.0 added to this list**, none of it played by a person for a whole game and none with a
second human: the watchable board and its ordered steps, the speed control, the pile highlighting and
the Home Office deck tile, "Your Move" being put away while catching up, the Day-end collision line,
and the Salvage Yard naming its top card.
### The checklist
Grouped by what has to be set up, with the item each observation closes. Nothing here needs a
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.
- [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).
- [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.)
- [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).
- [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.
- [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)
- [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).
- [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)
- [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.**
- [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.
- [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
mid-thought**, which is exactly the kind of thing only play reveals. See **Reference · #39**.
mid-thought**, which is exactly the kind of thing only play reveals. **Neither of those two
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. See **Reference · #35**.
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`.
---
@@ -305,8 +436,47 @@ 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
a train there reorders "and puts the engine at the nose", `implications.md` says "any order,
INCLUDING cars ahead of the engine". The design notes won.
`switch.sortConsist` gained an optional `engineAt` (absent = the nose, so older saves replay
unchanged). The menu did NOT multiply: the engine is a separate short list offered against the
consist as it stands, so a four-car train has eight options rather than twenty, and a player
wanting both a re-order and an engine move spends two Moves. §8.2 needed no new code —
`badlyMadeUp` already holds a broken-backed train, and is deliberately direction-free, so a
PUSHING train (whole consist ahead of the engine) is fit to run. The button warns first, by
asking that predicate rather than copying it.
Labels read WEST TO EAST with the engine drawn as the board's own ◀ / ▶ arrow, because "front
to back" depends on which way the train points and the board has reversed east-facing consists
since v0.8.0. Each says `MADE UP, ready to leave` or `HELD at the Office: <why>`.
- [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.
§9.2 boarding discards the emptied coach into **Classification**; detraining draws a fresh
empty **out of the Division Yard**; §2.2 returns Classification only when the Division Yard runs
bare. Coaches therefore move one way only. **Measured over `whistle-6945` (3 Days, 539
intents):** 16 coaches in the Division Yard at setup, **0 from Day 2 Stage 8 to the end**, 15
in Classification — while the Division Yard held steady at 46-47 freight cars, so the refill
could not fire. From that point no passenger can board or detrain anywhere on the board, and
four of the twelve timetabled trains (1/2 Crack Limited, 5/6 Sparrow) carry nothing but
coaches.
The two changes that would break the ratchet were put up and declined for now: sending the
emptied coach back to the **Division** Yard instead of Classification (a one-line change to the
boarding reducer), or amending §2.2 to refill when the Division Yard holds no car of a NEEDED
type rather than only when bare. **Revisit with a second game's data** — one game cannot tell a
rule from a seed.
---
@@ -368,21 +538,37 @@ v0.7.9's collision-floor change (#61).
The developer bot exists to measure the game, not to be a good opponent — so a bot weakness matters
when it stops a measurement being trustworthy. **Read #57 before tuning any weights.**
**Since 2026-09-14 the bot plans its whole switching turn** (`sim/switch-planner.ts`, +2.89 revenue a
game), **takes a face-up card only if it could play it** (+1.52), and **since 2026-09-15 lays track by
what the district can do afterwards** (`bestValuedLay`, +0.12 over 6400 seeds, run-arounds 9/60 → 22/60). Jesse's goal for it is better decisions in simulated runs AND at a real table, with no
non-player advantage — it reads the board, never the deck.
- [ ] **#104** — Weigh a switching turn against drawing and the Freight Agent. Letting the planned gain
gate switching on its own measured nothing (0.1, 0.25) or worse (0.5): `usefulSwitching` already
says yes exactly when a plan gains. What would matter is a VALUE for the other two options to
compare against, which the bot does not have. See **Reference · #104**.
- [ ] **#106** — The Extra trap: a full hand of Extras the A/D cap is holding back cannot be discarded,
so the next draw forces one into a full Office. All 23 train plays past the cap in 40 games were
this. Avoiding the draw measured nothing (−0.03) because it stalled development. See
**Reference · #106**.
- [ ] **#105** — Plan across more than one turn. Jesse is in favour, one turn first to see the impact —
which is now measured. Deferred for a conversation, not declined. See **Reference · #105**.
- [ ] **#41** — The bot never plays Red Flags — zero in 200 games since Gitea#19, and that is deck
luck rather than unwillingness. It takes the danger prompt unconditionally; what it never does
is plant a flag ON PURPOSE to buy a Stage for switching, which needs it to know it wants time.
See **Reference · #41**.
- [ ] **#57** — The bot's priorities are not the problem — measured across ten heuristic variations.
**Read this before tuning weights**; it is the argument that the ceiling is elsewhere. See
**Reference · #57**.
**Read this before tuning weights**; it is the argument that the ceiling is elsewhere. **Part of
"elsewhere" was choosing one Move at a time**: planning the whole switching turn was worth
+2.89 (t = 15.8) in the 2026-09-14 bot-tuning round. See **Reference · #57**.
- [ ] **#59** — The run-around is out of reach of any bot, and the deck is why — measured five ways.
See **Reference · #59**.
- [ ] **#53** — The bot does not know to bring an expedited train back to the station. See **Reference
· #53**.
- [ ] **#54** — The bot cannot spot a car at a stub industry, and the cut-ordering rules made that
visible. See **Reference · #54**.
@@ -405,9 +591,11 @@ when it stops a measurement being trustworthy. **Read #57 before tuning any weig
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**.
@@ -431,6 +619,93 @@ 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**.
- [ ] **#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**.
---
## The 2026-09-29 audit — what it found and did not fix
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`.
- [x] **#117** — **RULED 2026-09-29 (Jesse): "accept the leak in coop. otherwise save only at end of
game."** Built the same day: `/api/save` answers `403 SAVE_AFTER_FINISH` to a Competitive seat
while the game runs and serves a Co-op or one-seat game at any time; the page's Save replay
button says so and stays disabled until the end. Originally: **`/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**.
---
## Reference — measurements, rulings and rejected approaches
@@ -1424,6 +1699,12 @@ measuring deck luck rather than reachability, and its comment now says so.
#### #57 — THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.
**2026-09-14 — confirmed, and one ceiling found.** Reordering priorities still moves nothing; what
moved the bot was SEARCH. `sim/switch-planner.ts` plans the whole switching turn against a score of
where it ends, and measured +2.89 ± 0.18 (t = 15.79) over 1600 paired seeds — freight loads and
unloads 0.22 → 1.53, Cargo phases with a car spotted 5% → 18%. The "8% of Cargo phases" below was a
fact about how the bot switched, not only about the deck. See `CHANGELOG.md`, 0.8.0.9.
**THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.** Ten heuristic variations, each paired
over 400+ seeds. Every reordering of what the bot prefers came out inside the noise; the only
@@ -1449,6 +1730,21 @@ prioritised better. What is left is the economy itself, which is a deck question
#### #59 — THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measu…
**2026-09-14 — part of "the deck is why" was the bot's own draw.** It was taking ~32 face-up trains and
industries a game it could not play and discarding them again, so the hand rarely held track. Taking
only playable cards (now the default) grew districts 14 → 24 cards and run-arounds 3/60 → 9/60. And
~13 of the ~16 track pieces a game were being laid by the draw turn's "play what is in hand" fallback
at the first legal square, not by `bestTrackLay`; holding them (−0.83) and placing them by
`bestTrackLay`'s score (+0.07, noise) both failed, so the next limit is that SCORING — it cannot tell a
piece that opens an industry site or advances a run-around from one that fills a square. The deck
measurements below were taken before any of this and should be re-read with it in mind.
**2026-09-15 — the scoring, fixed.** `bestValuedLay` scores the layout a lay leaves (reachable industry
sites, a closed run-around, ways off the main) instead of the piece: closed run-arounds in 22 of 60
districts against 9, +0.118 ± 0.029 revenue (t = 4.14, 6400 seeds). The run-around is now reachable
without changing the deck; what is left is a bot that can USE one, which needs more than one turn of
planning (#105).
**THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measured, five ways.**
"Teach the bot to plan across turns" was tried properly and does not work. Every attempt is
@@ -1490,6 +1786,8 @@ the end of the game, drawing the fault **26 times**. Not an engine bug — the m
exactly as designed — but a clear next bot heuristic: prefer ending a switching turn with any
expedited crew back on the Office square, at least once it has finished the work it went out for.
**CLOSED 2026-09-14** — see **Done · 53**.
#### #54 — THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rul…
**THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rules made that visible.**
@@ -1511,6 +1809,12 @@ pass should not read the drop as a deck problem.
#### #58 — The bot cannot get a crew next to an industry, so Flying Switch never…
**2026-09-14 — the premise is now a deck fact.** Flying Switch is dealt **0 copies** (not in sheet 5;
Jesse, 2026-08-26), so no bot can fire it: across 30 standard games none was ever drawn. The switching
planner searches the card by default, so it will be used the day it is dealt again. The reachability
sweep's exemption in `sim.test.ts` stays until then.
**The bot cannot get a crew next to an industry, so Flying Switch never fires.** Industries are
now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows
@@ -1558,6 +1862,114 @@ of zero" over 400 games — but at 400 games the standard error is ±0.33, so a
have looked like nothing. They are nearly free to re-run now and at least one may have been
discarded wrongly.
#### #104 — WEIGH SWITCHING AGAINST THE OTHER TWO OPTIONS, not against a threshold.
Measured 2026-09-14, paired over 400 seeds against the planner: switching only when the planned gain
clears a threshold scored −0.33 at 0.5 (t = −5.06), −0.01 at 0.25, +0.06 at 0.1 (t = 1.79). At 0.5 it
refuses turns that only collect cars, which feed later deliveries; below that it agrees with
`usefulSwitching`. The choice that is still made by a fixed ladder is WHICH of §6's three options a
Stage goes to, and the planner can now put a number on one of them. The other two need numbers of
their own — what a draw is worth given the hand and the Departments, what stocking a box is worth given
the cars spotted — before the three can be compared. Also: planning at every Local Operations decision
costs a full search each time, so any version of this has to stay cheap.
**2026-09-15 — measured the ceiling first: there is almost none.** At 907 real Local Operations choices
(75 standard games, seeds outside the usual measurement range), every legal option was tried and the rest
of the game played out by today's bot, 4 times each with the HIDDEN parts reshuffled — the Home Office
deck order and future rolls — and the same reshuffles for every option, so the comparison is paired.
Grouped by the rule that made the choice, the value of each alternative against it:
| the ladder chose | times | switch instead | draw instead | Freight Agent instead |
| --- | --- | --- | --- | --- |
| draw — nothing urgent, develop | 494 | +0.08 ± 0.12 | — | +0.02 ± 0.04 |
| Freight Agent — feed the pipeline | 147 | +0.07 ± 0.06 | −0.01 ± 0.04 | — |
| switch — a train with work at the Office | 108 | — | −0.17 ± 0.10 | −0.25 ± 0.08 |
| draw — an Office upgrade in hand | 100 | +0.21 ± 0.16 (6) | — | −0.08 ± 0.06 |
| switch — walk the crew home | 47 | — | +0.22 ± 0.14 | −0.15 ± 0.08 |
| draw — a train card in hand | 11 | — | — | −0.45 ± 0.24 |
No rule has an alternative that is significantly better; where the table leans, the ladder is usually
the one that is right. So given how the bot plays each option once chosen, the choice itself is close to
optimal, and value functions for draw and Freight Agent have little to find. The one lean worth a look if
this is reopened is walking a stranded crew home (+0.22, t ≈ 1.6). The rollout tool is analysis only —
the bot never sees a rollout.
#### #106 — THE EXTRA TRAP — why the cap on committed trains still lets an Office overfill.
**2026-09-15 — re-measured under today's defaults, and most of it is not the Extra trap.** 20 "no free
A/D track" collisions in 60 standard games, −1.67 revenue a game. No train was held (§8.2 or clearance) in
the Stage before any of them, and only 8 of the 20 trains destroyed were Extras. **Six destroyed a train
of the same number as a Second Section run within the previous two Stages — and all 26 Second Sections
the bot ran in those games were an accident:** the New Train phase's "no car on offer" fallback takes
`options[0]`, and `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, so whenever an
Extra was waiting to start the bot doubled the train due out instead. The Office never had an A/D track
to spare for one.
**A RULES QUESTION FOR JESSE, found on the way — not a bot matter.** Q9 (`implications.md`) defines the
Second Section as a CARD "played on a train that is due out", and `content.ts` defines `SECOND_SECTION`
with 1 copy — but `buildDeck` never deals it, and `check`'s `newTrain.secondSection` asks for no card in
hand. So any player may run a Second Section for free on every train due out. Either the card should be
dealt and required, or the free action is the intended rule and Q9's wording is stale.
Also measured and removed: starting an over-cap Extra where its run never reaches the Office (+0.10,
t = 1.82) — such a start was on offer at 1 of 25 over-cap starts.
**The accident is fixed** (2026-09-15, default): the New Train fallback takes a car, a pass or the
Extra's start, never `options[0]` — +0.32 ± 0.09 (t = 3.64), collisions 0.24 → 0.19. **Still open under
this item:** the forced Extra itself (a full, undiscardable hand of Extras), and the Second Section card
question above.
`choose` removes train-card plays from the options when `trainWouldOverfillTheOffice`, but yields if
that would leave nothing legal. It does leave nothing legal in one ordinary position: the hand is over
the limit (§6.2 requires reducing it) and every card in it is an Extra, which `keepReason` forbids
discarding. Measured 2026-09-14 after the face-up take rule: 23 of 196 train plays in 40 games were past
the cap, every one "play what is in hand" with four Extras held, and the worst seeds each lost 3-4
collisions to it. Declining the draw option in that position measured −0.03 ± 0.11 — collisions fell
0.26 → 0.20 but cards played fell 29.0 → 25.6. Better answers to try: play the Extra at the least
dangerous moment rather than the first, count WHEN each committed train is due at the Office instead of
how many there are, or keep the hand from filling with Extras in the first place.
#### #105 — PLAN ACROSS TURNS — the evidence so far, for the conversation.
For: one-turn planning already reaches most switching work (Cargo phases with a car spotted 5% → 18%),
and what it cannot do is exactly what spans a Stage — leave a car on a spur for the next crew, or start
a run-around and finish it later. The planner already scores staged wanted cars (+0.2), which is a
first, crude step in that direction.
Against, for now: everything between two switching turns is not the player's — a Mainline Phase, trains
arriving, a Load/Unload phase — so a second turn cannot be searched the way the first is without either
simulating those phases (arrivals are on the public timetable, but cars on arriving trains are not
known) or scoring the position between turns more cleverly. And search cost is already what sets the
test suite's running time. Cheapest next step if taken up: a better score for "what the next turn can
still reach", not a deeper search.
**2026-09-15 — the run-around measurement that bears on this.** A candidate that lays track by what the
district can do afterwards (`valueLays`) more than doubled closed run-arounds, 9/60 → 22/60, yet moved
revenue only +0.14 ± 0.06 (t = 2.58, 1600 seeds). Traced over 40 games: the one-turn planner DOES use
the loop — 63 of 131 plans in a district with one end a move on it, 35 run it both ways — but its planned
gain per turn is the same with a run-around as without (0.28 against 0.27). A run-around is for putting a
train's cars in a different order, which pays off in the turns after; a planner that looks one turn
ahead has no way to value it.
**2026-09-15 — two-turn planning, built and measured: it does not pay.** `planTwoTurns` kept the six best
ends of a switching turn, removed the trains that would highball in the Mainline Phase in between (on the
Office square and made up — their cars leave with them), reset the Moves, planned the next turn from each,
and chose by the position after departures plus 0.8 of what the next turn adds. Nothing hidden is read.
| version | revenue, 400 paired seeds | what went wrong |
| --- | --- | --- |
| first | −0.28 ± 0.11 (t = −2.58) | an expedited train left away drew 31 faults in one game: the fault is charged in the gap, which the discounted next turn "recovered"; trains left away 5.3 a game against 3.1 |
| with the gap fault charged in full and 0.5 a Stage per train left away | −0.14 ± 0.06 (t = −2.36) | trains still left away 4.8 a game; the "crew must get back to the Office" choice 4.2 a game against 2.7 |
Why a second turn has so little to find, measured over 30 standard games:
- only **49%** of switching turns have the same train in the district at the next Local Operations choice;
- **6.1 wanted cars a game** do leave aboard departing trains — but a further switching turn from those
exact positions could have spotted only **0.7** of them: most were never deliverable;
- the one-turn planner already gains no more with a run-around than without (0.28 against 0.27).
And what a second turn COSTS is a Stage: trains left away have to be walked home, and those choices come
out of drawing and the Freight Agent (cards played 28.8 → 28.3). A multi-turn bot would have to weigh
switching against the other two options — which is #104, not a deeper search.
### Code health and housekeeping
#### #46 — tsc --noUnusedLocals finds 29 unused declarations across 14 files, and…
@@ -1704,7 +2116,7 @@ source Start-position art for the ten card types before it can begin.
2026-08-22, immediately after Gitea#7 changed the coach counts on four train cards and the
answer to "where do we keep track of that?" turned out to be **five places of three different
vintages**: `src/engine/content.ts` (the truth), `docs/StationMaster-Home-Deck-v0.4.5.md` (a
vintages**: `src/engine/content.ts` (the truth), `docs/home-deck.md` (a
readable per-card table, a version-stamped snapshot), `docs/rules/implications.md` §5 (the
transcription of `Trains3.pdf`, deliberately frozen at what the design SAYS),
`docs/Trains3.pdf` (the artwork), and `docs/rules/card-reference.md` (an invented placeholder
@@ -1729,13 +2141,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.
@@ -1789,18 +2204,118 @@ 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
each group.
### Closed in the 2026-09-14 bot-tuning round (unreleased)
53. ~~**The bot did not know to bring an expedited train back to the station.**~~ — done 2026-09-14,
not by a heuristic of its own but as a consequence of planning the switching turn: the planner's
score charges an expedited train left away from the Office a full Revenue point, which is what Q3
charges. Over 400 paired seeds, 19 games drew expedite faults under the rule ladder and **none**
under the planner, worth +1.07 a game (t = 3.83) — the two worst cases had drawn 45 and 42 faults
in a single game. See `CHANGELOG.md`, 0.8.0.9.
### Shipped through v0.7.9.8, from the queue
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
@@ -2097,7 +2612,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
-58
View File
@@ -1,58 +0,0 @@
# Station Master — Components and Markers
**Rules implementation reference: v0.4.5**
**Scope:** non-card physical components and supplies modeled by the v0.4.5 game. Card-created facilities, workers, deck piles, hand state, timetable state, and other markers are documented with their cards or in the rules book.
## Rolling stock
Rolling stock has a type and a load state. In the interface, coloured cars are loaded and white cars are empty. The Division Yard starts with the following supply.
| Type | Loaded | Empty | Total | Use |
| --- | ---: | ---: | ---: | --- |
| Coach | 8 | 8 | 16 | Passenger work. |
| Boxcar | 10 | 10 | 20 | Freight House outbound/inbound; Grocer’s Warehouse inbound. |
| Hopper | 8 | 8 | 16 | Mine Tipple outbound; Power Plant inbound. |
| Reefer | 5 | 5 | 10 | Packing Sheds outbound; Grocer’s Warehouse inbound. |
| Tank car | 6 | 6 | 12 | Refinery outbound; Power Plant inbound. |
| Caboose | 6 | 0 | 6 | Required or permitted at the rear of the applicable train cards. |
| **Total** | **43** | **35** | **78** | |
The engine is not rolling stock and does not count against the four-car Crew Tray limit.
## The two yards
### Division Yard
The Division Yard is the active supply. It provides:
- loaded cars to a Freight Agent stocking a green outbound box;
- empty cars for building up trains;
- an empty coach when passengers detrain; and
- an empty matching freight car when a loaded freight car begins unloading.
When a train completes a run or is destroyed, its caboose returns to the Division Yard. When a car is taken from the Division Yard and that yard has become entirely empty, every car from the Classification Yard moves back into the Division Yard immediately.
### Classification Yard
The Classification Yard collects used rolling stock: cars displaced by boarding passengers, cars cleared from inbound red boxes, cars removed by unjamming a facility, ordinary cars from completed or destroyed trains, and empty cars replaced by a completed outbound freight load. It is not a player-selectable source. It returns to service only when the Division Yard is empty.
## Crew Trays and trains
Crew Trays represent an engine and its coupled consist. The game creates **players + 3** free trays at setup. A tray is scarce: a due train without a free tray is held for that Stage, and Extras wait until one becomes free.
Within a tray, the engine can pull, push, or be between cars while switching. To depart an Office for the Mainline, it must be made up with the engine at an outer end; if it has a caboose, the caboose must be at the opposite outer end. A train may have at most four cars. A scheduled train can run under-strength but never with more than its card allows.
## Fedora
The **Fedora** is the physical marker for the Superintendent. The player with it resolves following-train clearance decisions and takes the 5-Revenue penalty for a Mainline collision caused by an unsafe clearance. The initial holder is the first player tied for the highest Superintendent setup D12 roll. The Fedora passes to the next seat to the left at each third-stage shift change.
## D12 and seeded randomness
The D12 is used by the engine for:
- the initial seating roll in a multi-player engine game;
- the initial Superintendent roll;
- a timetabled train’s timetable slot; and
- 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.
-162
View File
@@ -1,162 +0,0 @@
# Station Master — Home Deck
**Rules implementation reference: v0.4.5**
**Scope:** every card associated with the Home Office deck, including cards catalogued in the source but deliberately excluded from the dealt deck.
## Dealt card catalogue
The live Home Office deck contains **213 cards** in every currently supported mode. The rules for its setup, drawing, discarding, reshuffling, and hand limit are in the rules book, section 4.2.
| Active category | Cards |
| --- | ---: |
| Track | 96 |
| Office upgrades | 14 |
| Freight facilities | 27 |
| Facility modifiers | 23 |
| Train cards | 22 |
| Enhancements | 18 |
| Mainline modifiers | 7 |
| Maneuvers | 6 |
| **Total dealt** | **213** |
The catalogue also contains 12 space-use cards and 10 action cards. All 22 are excluded from every v0.4.5 dealt deck because their opponent-directed play rules are not implemented. They are listed at the end of this document for completeness.
## Track cards — 96
Track cards are ordinary Home Office cards, not a separate personal supply. A placed card must connect to existing rail. The Running Track is the horizontal row from Limit to Limit; cards there must carry an east–west through route. Placing track on a Limit extends the Running Track and moves that Limit outward.
| Card | Copies | Implemented placement facts |
| --- | ---: | --- |
| Straight track | 32 | East–west Operational Rail. |
| Curved track, right | 16 | A 45° curve; can rotate 180°, but cannot flip. Right-hand geometry is fixed to the `ne_sw` diagonal. |
| Curved track, left | 16 | A 45° curve; can rotate 180°, but cannot flip. Left-hand geometry is fixed to the `nw_se` diagonal. |
| Turnout, right | 16 | East–west through route plus one 45° branch. It may be passed through but is not Operational Rail, so a train cannot end a Move on it. |
| Turnout, left | 16 | Same operational rules; opposite fixed diagonal. |
| Sharp curved track, right | 0 | Catalogued but not dealt. |
| Sharp curved track, left | 0 | Catalogued but not dealt. |
A turnout may upgrade an existing straight, or a curve whose arc is exactly the turnout’s diverging arc. An upgrade is forbidden if the existing card holds standing cars or an enhancement. All other occupied squares are unavailable.
## Offices — 14
Every player begins at a Whistle Post, which is not drawn from the deck. Office cards are upgrades and must be played in sequence; they upgrade the existing Office rather than replacing its card or attached track.
| Card | Copies | A/D tracks | Porters | Passenger outbound/inbound slots | Other effect |
| --- | ---: | ---: | ---: | ---: | --- |
| Depot | 8 | 2 | 1 | 1 / 1 | Becomes a Control Point and Passenger Facility. |
| Station | 4 | 3 | 2 | 2 / 2 | Upgrade Depot only; Control Point and Passenger Facility. |
| Terminal | 2 | 4 | 3 | 3 / 3 | Upgrade Station only; Control Point and Passenger Facility. |
The Whistle Post has one A/D track, no porters, and no passenger slots. An Office upgrade preserves modifiers already applied to it.
## Freight facilities — 27
An industry may be placed only on a connected straight stub off the Running Track. Each facility begins with one Laborer and a three-box MEN | AT | WORK freight pipeline. Its industry track has capacity equal to its base outbound plus inbound capacity, with a minimum of one car.
No Office Area may contain a duplicate industry, or both ends of a listed lockout pair.
| Card | Copies | Cars handled | Flow | Base boxes | Lockout in same Office Area |
| --- | ---: | --- | --- | --- | --- |
| Freight House | 6 | Boxcar | Outbound and inbound | 1 out / 1 in | Freight House; Grocer’s Warehouse |
| Mine Tipple | 6 | Hopper | Outbound | 1 out / 0 in | Power Plant |
| Refinery | 3 | Tank car | Outbound | 1 out / 0 in | Power Plant |
| Power Plant | 6 | Hopper or tank car | Inbound | 0 out / 1 in | Mine Tipple; Refinery |
| Packing Sheds | 3 | Reefer | Outbound | 1 out / 0 in | Grocer’s Warehouse |
| Grocer’s Warehouse | 3 | Boxcar or reefer | Inbound | 0 out / 1 in | Packing Sheds; Freight House |
## Facility modifiers — 23
A modifier occupies an empty square adjacent to an eligible facility. It is unique by modifier type within an Office Area. The implementation attaches it permanently to the first eligible adjacent facility found; it does not implement a per-Stage choice when one modifier touches more than one possible facility.
`+ out` adds green outbound capacity only where the host can load; `+ in` adds red inbound capacity only where the host can unload. A capacity increase at a freight facility also lengthens its industry track by the usable number of added slots. Worker increases always apply.
| Modifier | Copies | Eligible host | Actual grant |
| --- | ---: | --- | --- |
| Waiting Area | 3 | Any Office | +1 outbound passenger slot; +1 Porter |
| Restaurant | 2 | Any Office | +1 outbound passenger slot; +1 Porter |
| Hotel | 1 | Any Office | +1 outbound passenger slot; +1 Porter |
| Truck Dock | 2 | Freight House, Packing Sheds, Grocer’s Warehouse | +1 **inbound** slot; no Laborer |
| Railroad Express Agency | 1 | Freight House | +1 outbound slot; +1 Laborer |
| Forklifts | 2 | Freight House, Packing Sheds | +1 outbound slot; +1 Laborer |
| Prep Plant | 1 | Mine Tipple | +1 outbound slot; +1 Laborer |
| Coal Piles | 1 | Mine Tipple | +1 outbound slot; +1 Laborer |
| Conveyor Belts | 1 | Mine Tipple | +1 outbound slot; +1 Laborer |
| Pipelines | 1 | Refinery | +1 outbound slot; +1 Laborer |
| Oil Depot | 1 | Refinery | +1 outbound slot; +1 Laborer |
| Viscosity Breakers | 1 | Refinery | +1 outbound slot; +1 Laborer |
| Transmission Lines | 1 | Power Plant | +1 Laborer |
| Rotary Dumps | 1 | Power Plant | +1 Laborer |
| Steam Turbines | 1 | Power Plant | +1 Laborer |
| Ice House | 2 | Packing Sheds or Grocer’s Warehouse | +1 outbound slot; +1 Laborer |
| Local Small Groceries | 1 | Grocer’s Warehouse | +1 Laborer |
A bonus beside a facility that cannot use its direction is not usable and does not create a slot or lengthen the track — an outbound bonus beside an inbound-only facility, or the Truck Dock's inbound bonus beside the outbound-only Packing Sheds, which leaves that card with no effect at all. Likewise, passenger modifiers beside a Whistle Post add Porters but do not create an outbound slot until the Office becomes a Passenger Facility.
## Train cards — 22
Playing a timetabled train card rolls the seeded D12 and places its number in the first open timetable slot at or after the result, wrapping around the 12-slot chart. It then runs at that Stage every Day. An Extra is queued and made up when a Crew Tray becomes available; v0.4.5 automatically launches Extras eastbound from the Western Division Point.
The listed consist is a maximum, not a minimum: a train may depart with fewer cars, but must not exceed the listed categories, put a car behind a caboose, or leave with the engine buried among cars. A Crew Tray holds no more than four rolling-stock cars.
> **Changed 2026-08-22 (Gitea#7), Jesse's call:** the coach counts on **1/2 Crack Limited** and
> **5/6 The Sparrow** were swapped — the Limited drops from three coaches to two, the Sparrow rises
> from two to three. This is a change to the CARDS, not a correction to this table: `Trains3.pdf` and
> the transcription in [`rules/implications.md`](rules/implications.md) §5 still show the original
> numbers, and are right about what the printed cards said. `src/engine/content.ts` and this table
> carry what the game plays.
| Train | Speed | Direction | Listed maximum consist | Implemented special rule |
| --- | --- | --- | --- | --- |
| 1/2 Crack Limited | Fast | 1 west / 2 east | **2 coaches** | No switching; passenger work only at Terminals; expedited. |
| 3/4 Express | Fast | 3 west / 4 east | 2 freight | May exchange at most one freight car at each grid location during its switching turn; expedited. |
| 5/6 The Sparrow | Fast | 5 west / 6 east | **3 coaches** | No switching; expedited. |
| 7/8 Local | Slow | 7 west / 8 east | 1 freight, 1 coach | Its coach may not be set out during switching. |
| 9/10 Heavy Freight | Slow | 9 west / 10 east | 3 freight, 1 caboose | — |
| 11/12 Drag Freight | Slow | 11 west / 12 east | 2 freight, 1 caboose | — |
| X13 Appleseed Extra | Slow | Player choice printed; v0.4.5 launches east | 3 empty freight, 1 caboose | May drop cars but cannot pick up. The engine enforces no pickup, but does not enforce the printed empties-only consist restriction at make-up. |
| X14 Fruit Growers Express | Fast | Player choice printed; v0.4.5 launches east | 2 reefers, 1 caboose | Expedited. The code enforces reefers-only; the printed extra loaded-reefer pickup is not a separate rule. |
| X15 Yard Xfer | Slow | Player choice printed; v0.4.5 launches east | 2 freight, 1 caboose | — |
| X16 Light Engine Move | Fast | Player choice printed; v0.4.5 launches east | No cars | No switching. |
| X17 Campaign Train | Fast | Player choice printed; v0.4.5 launches east | 1 coach | No switching. First Office arrival lays over for speeches; later arrivals are expedited. |
| X18 Circus Train | Slow | Player choice printed; v0.4.5 launches east | 2 freight, 1 coach, 1 caboose | No switching. The first Mainline Phase in which it remains stopped awards its current Office’s player 1 Revenue. |
| X19 Military Train | Slow | Player choice printed; v0.4.5 launches east | 1 freight, 2 coaches | No switching; no passenger work; expedited. |
| X20 Director’s Private Car | Slow | Player choice printed; v0.4.5 launches east | 2 freight, 1 coach | No passenger work. |
| X21 Freight Extra | Slow | Player choice printed; v0.4.5 launches east | 3 freight, 1 caboose | — |
| X22 Pee-Dee | Slow | Player choice printed; v0.4.5 launches east | 1 caboose | May pick up empty cars only. |
## Enhancements — 18
| Card | Copies | Placement | v0.4.5 behavior |
| --- | ---: | --- | --- |
| Interlocking | 2 | Bare Running Track straight | When the Office is full, an inbound train is held at the Limits instead of colliding. |
| Facing Point Locks | 2 | Any card; requires an Interlocking somewhere in the district | Blocks Derail. Derail is unavailable in v0.4.5, so this remains dormant. |
| Yard Office | 1 | Bare Secondary Track straight | A coachless inbound train is diverted to this track instead of occupying an A/D track. |
| Small Yard | 1 | Bare Secondary Track straight | A train may spend one switching Move here to reorder its entire consist and put the engine at the nose. |
| Water Column | 2 | Bare Running Track straight | Removes a Watertower. Watertower cards are unavailable, so this remains dormant. |
| Overpass | 1 | Any card | No implemented effect. |
| Telegraph | 3 | Bare Running Track straight | Once per Day, the Superintendent may add 4 to an opposing train’s number when that makes a facing clearance safe. |
| Telephone | 2 | On a Telegraph | Same dispatch mechanism, +8 once per Day. |
| Radio | 2 | On a Telephone | Same dispatch mechanism, +12 once per Day. |
| ABS Signals | 2 | Any Mainline card | Prevents rear-end collisions and holds a following train short. |
An enhancement requiring a bare straight cannot share that straight with another such enhancement. Telephone and Radio are the explicit stackable chain.
## Maneuvers — 6
| Card | Copies | Behavior |
| --- | ---: | --- |
| Red Flags | 5 | May be played at any time on a train stopped on a Mainline card. An approaching following train is held instead of moving into it. |
| Flying Switch | 1 | During the owner’s switching option, spend one Move to roll a tail-end cut into a track-connected freight facility without moving the engine there. |
| Poling | 0 | Catalogued but not dealt; no rule is implemented. |
## Catalogued but not dealt — 22 opponent-directed cards
### Space-use cards — 12
Bean House (1), Flop House (1), Watertower (1), Hobo Jungle (1), Section House (1), City Blocks (4), Engine Shops (1), Tenderloin District (1), and Engineer Cemetery (1) are excluded. Their source descriptions say they consume table space; Hobo Jungle additionally describes vandalism looting a passing boxcar. No placement or effect is available in v0.4.5.
### Action cards — 10
Derail (2), Broken Coupler (1), Railroad Crossing (1), Per Diem Inventory (1), Demurrage Charge (1), Customer Complaints (1), Vandalism (1), Hotbox (1), and Outlawed (1) are excluded. Their printed target/effect text is catalogued in the code, but `card.play` rejects the categories as not implemented. Consequently, no card can currently be played on another player.
There are also two **Facing Point Locks** listed among Mainline modifiers. They are treated as the same grid enhancement as Facing Point Locks above, require an Interlocking, and are included in the active 213-card total.
@@ -1,85 +0,0 @@
# Station Master — Mainline Deck
**Rules implementation reference: v0.4.5**
**Scope:** the tarot-sized Mainline cards placed between Offices. This is an implementation reference, not a transcription of earlier prototype rules.
## How Mainline cards work
At setup the game places one randomly selected Mainline card between each neighbouring pair of Offices and one beyond each end Office, between it and a Division Point. Thus, a game with *N* players has *N + 1* Mainline cards. The implementation selects types with replacement; it does not deal them from a shuffled finite deck.
A train crossing a Mainline card spends Stages, rather than moving through its printed cells. A 60 mph card costs one Stage and a 30 mph card costs two. A Slow train adds one Stage to every crossing. On Hilly terrain, any train carrying at least one coach uses the passenger rate; a train carrying no coach uses the freight rate. No crossing can take less than one Stage.
The card does not itself determine which player owns the adjacent Office. It is part of the shared Division.
## Physical card inventory in `Mainline Cards.pdf`
The supplied PDF has **ten** tarot-sized terrain cards. Plains appears twice; the other terrain types appear once each. It also includes two Division Point cards.
| Physical card | Copies in the PDF |
| --- | ---: |
| Plains | 2 |
| Curves | 1 |
| Hilly | 1 |
| Heavy Grade | 1 |
| Double Track | 1 |
| Uncontrolled Siding | 1 |
| Tunnel | 1 |
| Trestle | 1 |
| Interchange | 1 |
| East Division Point | 1 |
| West Division Point | 1 |
The PDF art labels this card “Yard”; this reference uses the implementation’s correct name, **Interchange**, to distinguish it from the Division Yard, Classification Yard, Yard Office, and Small Yard.
## Implemented Mainline card reference
| Card | Implemented crossing time and feature |
| --- | --- |
| Plains | 60 mph; one Stage for Fast, two for Slow. The implementation has one Plains *type* rather than the PDF’s two physical copies. |
| Curves | 30 mph; two Stages for Fast, three for Slow. |
| Hilly | Passenger train: 60 mph. Freight-only train: 30 mph. Add one Stage if Slow. |
| Heavy Grade | Starts at 30 mph; two Stages for Fast, three for Slow. The card prints “Player sets orientation”; **the game deliberately overrides that and rolls the uphill direction from the seed** — settled in v0.5.0 and re-confirmed 2026-08-23, see the note below. Grade modifiers can reduce the time, to a minimum of one Stage. |
| Double Track | 60 mph. Printed capability: trains may pass. The traffic-resolution rule is in Rules §4.5. |
| Uncontrolled Siding | 60 mph. Printed capability: trains may pass. The traffic-resolution rule is in Rules §4.5. |
| Tunnel | 30 mph. |
| Trestle | 60 mph. |
| Interchange | 60 mph. A train may be reordered there only through the card’s printed “sort cars” concept; the current engine does **not** provide a Mainline sorting action for it. |
### PDF/code mismatch — CORRECTED
**Was:** `src/engine/content.ts` defines nine `MAINLINE_PROFILES` types: one Plains entry plus the eight other terrain types above. `setup.ts` selected uniformly from that nine-type list, **with replacement**. The second Plains card shown in `Mainline Cards.pdf` was therefore not represented as a duplicate card or as extra Plains weight in setup — and, worse than a weighting error, a Division could be dealt two Interchanges, two Tunnels or two Trestles, none of which the deck contains.
**Now:** `MAINLINE_DECK` in `content.ts` is the inventory table above — ten drawable cards, Plains twice and the other eight once each — and `buildDivision` deals from it without replacement. The two Division Point cards are not in that deck: they are the fixed ends of the Division, laid by `buildDivision` itself rather than drawn.
The Interchange is what forced the correction. §7 lets an Extra be started at the Interchange "if one is on the board" (see `docs/rules/implications.md`, §7), which only reads as a rule if the board can hold at most one.
The executable state represents East and West Division Points as fixed end nodes, not as card records. They are functionally present at the ends of the Division, but are not represented as the two PDF cards in the deck/state model.
## Heavy Grade modifiers
These cards come from the Home Office deck and are played onto a Mainline card during a player’s Draw option.
| Card | Copies | Placement and actual effect |
| --- | ---: | --- |
| Brakeman | 1 | Heavy Grade only. Reduces a downhill crossing by one Stage. |
| Airbrakes | 1 | Heavy Grade only, and Brakeman must already be on that card. Reduces a downhill crossing by one additional Stage. |
| Helpers | 1 | Heavy Grade only. Reduces an uphill crossing by one Stage. |
| Realignment | 2 | May be played only onto an unoccupied Mainline card. Changes Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, or Trestle → Uncontrolled Siding. It cannot be played on any other card type. |
For the grade cards, “uphill” should be the direction selected by the player when the card is placed. In v0.4.5 it is the seeded `gradeUp` direction because setup has no player-choice step.
## What is not implemented
- There is no player choice or physical placement interaction for Mainline cards; setup deals them automatically from the seeded random stream. There **is** a finite draw pile as of v0.6.2 — the deck above, dealt without replacement, so no Division can hold two of a card printed once.
- Interchange is catalogued as a “sort cars” card, but v0.4.5 has no operation that reorders a train on the Interchange. The Small Yard in an Office Area is the implemented sorting mechanism.
- Interchange now has one player-facing use: an Extra Train may be **started** there, made up in its yard and highballing onto the Mainline when the Subdivision is clear (§7, v0.6.2). Car sorting remains unimplemented.
## Heavy Grade orientation is settled, not missing
Heavy Grade orientation is rolled from the seed rather than chosen by a player. **This is a decision, not a gap, and it is not awaiting a player-selection step.**
The card prints “(Up)” and “Player sets orientation”, which assumes the card has an owner. This one does not: `buildDivision` lays the Division as `DP · Mainline · Office · Mainline · … · DP`, so a Heavy Grade always sits **between two districts**, or beyond an end Division Point next to one — never inside a single player’s own district.
Orientation is not cosmetic: Brakeman and Airbrakes each take a Stage off a train running **downhill**, Helpers takes one off a train running **uphill**, and odd-numbered trains run west while even run east. Turning the card around therefore decides which of those modifier cards are worth anything and which direction of traffic is favoured — permanently, for the whole game. Handing that to one of the two neighbours advantages them over the other, and no player has a fair claim to it.
**Re-opened and closed again on 2026-08-23**, when the option of giving the choice to the Superintendent was considered and rejected. Jesse’s call: v0.5.0’s ruling stands. Rolling from the seed is deterministic, roughly even (51/49 east/west over 400 games), identical for solitaire and multiplayer, and keeps setup non-interactive — the game has no setup phase, so the question would have to interrupt play before the first Local Operations, in the minority of games that deal the card at all (20% at one player, rising to 50% at four).
-290
View File
@@ -1,290 +0,0 @@
# Station Master — Rules
**First-draft rules reference for v0.4.5**
**Authority:** observed v0.4.5 code paths and tests. Where a card face, prototype document, and executable behavior differ, this document reports executable behavior and marks unimplemented material.
## 1. Overview and background
Station Master is a railroad-operations game set in the timetable-and-train-order era. Each player operates an Office Area on a shared east–west Division. The local work is building rail, switching cars, moving passengers and freight through facilities, and keeping the Office clear. The shared work is dispatching scheduled trains across the Division. The player holding the Superintendent’s Fedora decides whether to clear a following train into an occupied Subdivision; an unsafe decision can cause a collision.
Revenue is the score. Passenger boarding and detraining, freight loading and unloading, completed runs, collisions, and victory conditions are all resolved by the rules configured when the game is dealt.
This book is divided as follows:
1. overview and scope;
2. definitions;
3. creating, saving, replaying, and configuring games;
4. the shared basic game mechanics;
5. the implemented solitaire game;
6. the implemented multiplayer/engine status; and
7. FAQs and implementation limits.
The companion references are [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md), [Home deck](StationMaster-Home-Deck-v0.4.5.md), and [components](StationMaster-Components-v0.4.5.md).
## 2. Definitions
| Term | Meaning in v0.4.5 |
| --- | --- |
| A/D track | An Office arrival/departure capacity. A Whistle Post has 1; Depot, Station, and Terminal have 2, 3, and 4. |
| Card location | A square in an Office Area grid. A train may end a switching Move only on Operational Rail. |
| Classification Yard | The holding yard for used rolling stock. It refills the Division Yard only when that yard is completely empty. |
| Control Point | A Depot, Station, or Terminal. It breaks the shared Mainline into Subdivisions. A Whistle Post is not one. |
| Crew Tray | An engine plus up to four coupled rolling-stock cars. The engine itself does not count as a car. |
| Department pile | One of three shared face-up card stacks. Draw and discard affect only the top card. |
| Division Point | The western or eastern end of the shared Division, where trains enter and leave it. |
| Division Yard | The active source of rolling stock. |
| Expedited train | A train with the expedite rule. It performs Load/Unload at an Office after arriving, then attempts to depart at the end of that same Stage. |
| Facility | A Passenger Facility at an upgraded Office or a Freight Facility at an industry. |
| Freight Agent | The Local Operations option that stocks one outbound box, clears one inbound box, unjams one load, or ends without acting. |
| Limits | The two signs defining an Office Area’s Running Track. They move outward when that track is extended. |
| Local Operations | The first player-driven phase of a Stage. A player chooses one: switch, draw, or Freight Agent. |
| Mainline | The shared cards between Offices and Division Points. |
| MEN \| AT \| WORK | A three-box freight pipeline. Any load on it locks its industry track. |
| Operational Rail | A card on which a train may finish a Move. Turnouts are pass-through only; a locked industry is not usable at all. |
| Running Track | The east–west track between an Office Area’s Limits, including its Office. |
| Secondary Track | All local rail inside the Limits that is not Running Track. |
| Stage | One of twelve turns in a Day. Its phases are Local Operations, New Train, Mainline, Load/Unload, and Shift Change. |
| Subdivision | Mainline between Division Points or Control Points. Clearance checks look through the whole next Subdivision. |
| Superintendent | The player with the Fedora. The role decides same-direction clearances and rotates every three Stages. |
| Timetabled train | A numbered train card scheduled to one of the 12 Stage slots, then due at that slot each Day. Odd numbers go west; even numbers go east. |
## 3. Create, save, replay, and game configuration
### 3.1 Starting a new game in the shipped client
The v0.4.5 browser page creates a **one-player solitaire Standard game**. Select **New game**, then choose:
1. a numeric seed, or leave it blank for a fresh browser-generated seed;
2. a starting hand;
3. passenger, freight, and train-transit Revenue rates.
The browser writes those choices into the URL. A particular game is defined by the **seed plus these house rules**, not the seed alone.
The three starting-hand choices are:
| Choice | Setup result |
| --- | --- |
| Three random cards | Default. Deal three cards from one shuffled deck. |
| Six random cards | Deal six cards from one shuffled deck. The player begins over the normal hand limit and must play/discard down before ending the first Draw turn. |
| Three random track and three random non-track cards | Shuffle track and non-track piles separately for the opening deal; then recombine and shuffle their remainders for all later draws. |
The three Revenue values are independently rounded and clamped to 0–5:
- passenger Revenue per coach, paid on boarding and again on detraining;
- freight Revenue per load, paid on completion of an outbound load and again on completion of an inbound unload; and
- train Revenue per transit, paid to every player when a train leaves the Division.
Defaults are 1 / 1 / 0. Zero turns that revenue source off; it does not prevent the underlying operation.
### 3.2 Deterministic setup and the seed
The engine uses one seeded random stream for shuffles, Mainline selection, opening D12 rolls, timetable rolls, and automatic Heavy Grade orientation. Given the same seed, configuration, player names/order, and accepted action history, it reconstructs the same state.
At setup it creates each Office Area as `Limit — Whistle Post — Limit`, makes the Division Yard, creates players + 3 Crew Trays, turns up three Department cards, and constructs the shared Division.
In a multi-player engine game, every player receives two seeded D12 rolls:
- The **division roll** orders seats from low west to high east; equal results put the lower player index farther east.
- The **Superintendent roll** gives the initial Fedora to the first player tied for highest.
The opening deal starts at the Superintendent’s seat and proceeds left (eastward in the engine’s seat ordering).
### 3.3 Saving, resuming, and replaying solitaire
The local save format is JSON containing the seed, the accepted intent history, and the house rules. It does not serialize mutable game state. Loading recreates the game from the seed and replays every action through the rules engine. If an old history reaches an action that is no longer legal, replay stops there instead of forcing an invalid state.
The browser also stores the current local game and resumes it automatically when the page opens without an explicit seed in the URL. A downloaded save is named `station-master-seed<seed>-day<day>.json`.
**Undo is solitaire-only.** It removes the final accepted intent and rebuilds the game from the earlier history. Random outcomes are not rerolled: replaying the same action consumes the same seeded result. Undo can therefore change the player’s decision after seeing an outcome, but cannot fish for a different timetable die roll.
### 3.4 Engine game modes and endings
The engine defines Solitaire, Competitive, and Co-op modes. The browser exposes only Solitaire.
| Length | Target | Days |
| --- | ---: | ---: |
| Short | 10 | 3 |
| Standard | 20 | 5 |
| Campaign | 45 | 10 |
With `firstToTarget`, competitive mode ends when any individual reaches the target; co-op uses target × player count and total Revenue. With `highestAfterDays`, competitive mode requires the table’s combined Revenue to reach `3 × players × days`; otherwise everyone loses. If that floor is met, the highest individual score wins. Solitaire and co-op win only if their score reaches their target at the end of the length.
Competitive mode also ends in a collective loss after three collisions in one Day.
The configuration contains four optional-rule flags. Only two have engine effects: **Reduced Visibility** gives five rather than six switching Moves in Stages 1, 2, 3, 11, and 12; **Emergency Toolbox** initially sets the Red Flags hand-limit status, allowing four cards. Sister Trains and Employee Rotation are represented in configuration/state design but are not executed by v0.4.5.
### 3.5 Multiplayer setup status
There is no multiplayer lobby, room creation flow, remote server, invitation flow, or network session in v0.4.5. The engine can be called with multiple player names (and rejects a Solitaire configuration with more than one), but the delivered page always calls it for one local player. See section 6.
## 4. Basic game mechanics
### 4.1 The Stage sequence
Each of 12 Stages follows this sequence:
```text
1. Local Operations — each player, starting with the Superintendent and proceeding left
2. New Train — make up due timetabled trains, then queued second sections and Extras
3. Mainline — automatic train movement in numeric order
4. Load/Unload — each player, starting with the Superintendent and proceeding left
5. Shift Change — expedited departures, clocks/workers, and possibly the Fedora
```
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. At Day end, dispatch-device use resets, collision count resets, the Day and Stage roll over, and victory is checked.
### 4.2 Local Operations: choose one option
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 can reorder a consist for one Move. Flying Switch spends one Move to roll a tail cut into a connected Freight Facility.
**Draw.** Take one card from the face-down Home Office or the exposed top of one Department pile. During this option, play eligible cards and/or discard cards to Department piles, then finish at the hand limit. Track, facilities, offices, modifiers, enhancements, and train cards have the placement or scheduling rules in the deck references. Mainline modifiers are played from this option as well.
**Freight Agent.** Make one of these operations, then the turn ends: stock one green outbound box from a matching loaded Division Yard car; clear one red inbound box to the Classification Yard; unjam one outbound, inbound, or MEN | AT | WORK load to the Classification Yard; or explicitly end without acting. Freight can be stocked only when an unclaimed empty matching car is already spotted at that industry. Passengers may wait in a green Office box without a train present.
Implementation note: `card.discard` is accepted by the v0.4.5 engine during Local Operations without checking that Draw was chosen. This unusual implementation behavior is not a separate published turn option.
### 4.3 Track, switching, and local safety
The Running Track cannot be broken by a curve or other card lacking its east–west through route. Local branches use turnouts and 45° curves. Cards that touch vertically connect only if their 45° legs share the correct diagonal; mere adjacency is not rail connection.
An industry’s track is locked while any load is on its MEN | AT | WORK pipeline. Trains cannot enter, cross, pick up from, or set cars out there. When it is unlocked, an industry track is ordinary Operating Rail and holds up to four cars, the same as any other non-turnout, non-Limits card: a crew may set out one, two, three or four cars there, whatever it is carrying. An industry’s green and red boxes are how much work it can hold, not how much rail it has, and a Modifier beside it adds a box without adding room for a car.
Train-card restrictions also apply while switching. No-switching trains cannot move, set out, or sort locally. Trains 3/4 share a one-freight-car budget for pickup or setout at each grid location. Trains 7/8 cannot set out their coach. X13 cannot pick up, and X22 may pick up empty cars only.
### 4.4 New Train phase
At the Stage shown on the timetable, the engine makes up the matching timetabled train if a Crew Tray is free. It starts at the Division Point appropriate to its direction. A train may receive matching loaded or empty cars from the Division Yard until its listed maximum consist is reached or no suitable car remains. It is permitted to leave under-strength.
A Second Section order on the due train creates another identical timetabled train behind it when a free tray is available. A played Extra is made up after timetabled trains and Second Sections when a tray is free; v0.4.5 automatically sends every Extra east from the Western Division Point.
### 4.5 Mainline movement and Office arrival
Mainline movement is automatic and processes lower train numbers first; a timetabled train outranks an Extra with the same number. A train at a Division Point, at an Office A/D track, or already crossing a Mainline card attempts its applicable movement.
Crossing time comes from the Mainline card and train speed. On a normal card, a train must check the entire next Subdivision before entering it:
- an opposing train normally blocks entry;
- a following same-direction train asks the Superintendent to allow or deny clearance;
- Red Flags or ABS Signals hold the follower automatically; and
- passing cards allow entry without this occupancy check.
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 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.
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).
### 4.6 Passenger work
Passenger work occurs during Load/Unload, at a Depot, Station, or Terminal. Each Porter may take one action per Stage.
- **Board:** replace an empty coach on an eligible train at the Office with a loaded coach from a green outbound box. The removed empty coach goes to the Classification Yard. Earn configured passenger Revenue.
- **Detrain:** replace a loaded coach on an eligible train with an empty coach from the Division Yard, placing the loaded coach into an available red inbound box. Earn configured passenger Revenue.
Crack Limited trains permit passenger work at Terminals only. Military Train and Director’s Private Car permit no passenger work. A Whistle Post has no Porters.
### 4.7 Freight work
Freight work also occurs during Load/Unload. Each Laborer can take one action per Stage. Every freight facility has MEN, AT, and WORK boxes.
For an **outbound load**, first use the Freight Agent to put a matching loaded car into the green box, with an unclaimed empty matching car spotted on the industry track. A Laborer starts the load on MEN, then advances it to AT, then WORK, then onto the empty car. Completing the last step changes that car to loaded, sends the replaced empty car to the Classification Yard, and earns configured freight Revenue.
For an **inbound unload**, a matching loaded car must be spotted at an inbound-capable industry and the Division Yard must contain a matching empty car. A Laborer replaces the spotted loaded car with that empty car and starts the load on WORK. Laborers move it backward through AT and MEN into a free red inbound box. That final placement earns configured freight Revenue. Clearing the red box later with the Freight Agent pays no additional Revenue.
## 5. Solitaire
The implemented game is solitaire: one named player, one Office Area, local browser execution, Standard length, highest-after-days victory, and the default house rules unless changed in New Game. The game has no AI opponent. “Multiplayer” gameplay does not occur locally by simulating other players.
Solitaire-specific features are:
- a local browser save, automatic resume, and JSON download/load;
- unlimited step-by-step Undo back through accepted action history; and
- a seed/rules URL suitable for sharing or reproducing a game.
The active deck removes all 22 opponent-directed cards. Therefore, the defensive cards whose only purpose is to answer them (Facing Point Locks and Water Column) can be placed but have no opportunity to fire; Overpass has no effect at all. The game still includes shared-rail mechanics such as Mainline clearance, but with one player no other player can occupy the Division.
To win the default game, finish Day 5 with at least 20 Revenue. A result below 20 is a loss. Train-transit Revenue defaults to zero, so the default score must principally come from passenger and freight work.
## 6. Multiplayer
### 6.1 What the engine supports
The engine has player, seat, score, Office Area, Director/Division, phase-order, co-op, and competitive-mode data for multiple named players. It deals each player a hand, creates one Office Area per seat, starts acting order at the Superintendent and proceeds left, and models the following multiplayer-specific outcomes:
- the D12 seating and Superintendent rolls described in section 3;
- individual Revenue in competitive play and shared total Revenue in co-op;
- a competitive collective loss after three collisions in one Day;
- a collective Revenue floor for competitive highest-after-days games; and
- train-transit Revenue awarded to every player, if that revenue setting is nonzero.
The engine’s setup checks only that there is at least one player and that Solitaire has exactly one player. It does not enforce a maximum player count, although the test and design material exercise two through four players.
### 6.2 What is not delivered in v0.4.5
There is no implemented multiplayer game setup for end users: no server, lobby, invitation, room code, player join flow, authoritative remote state, or remote Session. The browser page creates a local single-player session only. Accordingly, there is no supported procedure for resuming a multiplayer game, and no multiplayer Undo.
The 12 space-use cards and 10 action cards are not dealt in competitive or co-op either. The engine rejects attempts to play either category. Thus, **no card can currently be played on another player**. This includes Derail, Broken Coupler, Railroad Crossing, score-penalty cards, Vandalism, Hotbox, Outlawed, and all table-space cards. Facing Point Locks and Water Column are implemented only as dormant defences for these unavailable effects.
### 6.3 Difference from solitaire, if a multi-player engine session is created
Players have separate local districts, hands, and scores, but they share the Home Office deck, Department piles, yards, timetable, Mainline, and traffic consequences. Turn order is sequential; one current actor acts at a time. The Superintendent role is attached to a player while Offices are attached to fixed seats. Employee Rotation is not active, so players do not actually change seats in v0.4.5.
The deck is still 213 cards and still excludes opponent-directed content. Therefore multi-player engine mode changes shared traffic, scores, turns, and win/loss evaluation—not card attacks or a remote user experience.
## 7. Frequently asked questions
### Is the same seed always the same game?
Only with the same house rules, player configuration/order, and action history. The seed drives all engine randomness, but the starting hand and Revenue settings also change the game.
### Why can’t I end my draw turn?
Your hand exceeds three cards, or four when the Red Flags hand-limit status is active. Play or discard down to the limit.
### Why can’t my train move onto a turnout?
It may move through a turnout but cannot end a Move there because turnouts are not Operational Rail.
### Why can’t a train enter my industry?
Its MEN | AT | WORK pipeline contains a load, which locks the track; the rail geometry does not connect; another train occupies it; mandatory coupling would exceed four cars; or four cars are already standing there, which is as many as any card holds.
### Why won’t an industry accept a new outbound load?
The green box may be full, the Division Yard may lack a matching loaded car, or there is no unclaimed empty matching car already spotted on that industry’s track.
### Why does an inbound freight unload require an empty car in the Division Yard?
The engine immediately replaces the spotted loaded car with a matching empty car. It will not create a new car from nothing.
### Why did an arriving train collide instead of wait?
The Office had no free A/D track and no Interlocking. A full Office is an automatic local collision. An Interlocking holds the arrival at the Limits.
### Why is a train held at an Office even though the Mainline is clear?
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?
Expedite means it departs in the Stage it arrived, but v0.4.5 waits until Shift Change so it remains present for that Stage’s Load/Unload phase.
### Can I choose the direction of an Extra or a Heavy Grade?
Not in v0.4.5. The engine launches Extras eastbound from the Western Division Point. Heavy Grade orientation is seeded automatically at setup.
### Can I use an Interchange to reorder a train?
No. The card is catalogued with a sorting concept, but there is no implemented Interchange sorting action. Small Yard is the available local sorting mechanism.
### Can I play attack cards on another player?
No. All action and space-use cards are excluded from every dealt deck in v0.4.5 and their play is rejected.
### Is multiplayer playable?
No. Multi-player state and rules-engine support exist, but the lobby, server, remote client, and opponent-directed card mechanics are not implemented.
+3 -1
View File
@@ -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,
+16 -12
View File
@@ -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
+31 -1
View File
@@ -36,6 +36,34 @@ and `<ip>:<port>` are both expected — and browser storage is scoped to the ori
at one address must come back to that address, or they are a stranger with no token. Say so in the
UI at join time rather than letting someone discover it when they cannot get back in.
**A lost token is recoverable, administratively** (Gitea#33). Everything above makes the token the
single point of failure: it lives in one browser's storage, and a cleared profile, a private window or
a different browser ends the seat with the game still running and the session still on disk. Seen at a
real table — the returning player met an empty lobby while their token sat intact in `sessions.json`,
and the only way back was an administrator reading the file off the volume and the player pasting it
into a devtools console.
So there is a supported path, in two halves that are gated differently on purpose:
```
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
POST /api/claim { code } → { token, gameId, player, gameCode }
```
**The link carries the code, never the token** — which is the rule three paragraphs up, applied. A
recovery link is exactly the sort of thing that gets pasted into a chat, so what travels in the URL is
single-use and expires in thirty minutes (`server/claims.ts`), and the page trades it for the real
token over a POST as it loads (`?claim=` in `web/main.ts`, which strips it from the address bar either
way). A leaked code is worthless once spent; a leaked token is the seat for the rest of the game.
**Minting is administrative; spending is not.** Deciding that a particular person has lost a
particular seat is a judgement no route can make safely — anyone able to mint their own code could
take any chair at the table. Spending needs no secret because the player following the link is the one
person in the story who holds none; the code *is* the authorisation, and it is the same shape
(unguessable, one-time) as the token it hands back. The codes are held in memory: they are minted on
demand and spent within minutes, so a restart dropping them is the right failure, and persisting them
would put a credential-equivalent on the volume to solve a problem measured in seconds.
Real accounts can be layered on later without touching the rules engine, which is exactly why
[`overview.md`](overview.md) keeps that boundary sharp.
@@ -79,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
+10 -3
View File
@@ -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.
+118
View File
@@ -0,0 +1,118 @@
# Station Master — Components and Markers
**Version 0.8.6** · 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
[rules book](rules.md).
The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in
`src/engine/content.ts`. They are physical inventory rather than deck tuning, which is why they are
printed here at all — per-category CARD counts are not published, because they move with play balance.
## Rolling stock
Rolling stock has a type and a load state. In the interface, coloured cars are loaded and white cars are empty. The Division Yard starts with the following supply.
| Type | Loaded | Empty | Total | Use |
| --- | ---: | ---: | ---: | --- |
| Coach | 8 | 8 | 16 | Passenger work. |
| Boxcar | 10 | 10 | 20 | Freight House outbound/inbound; Grocer’s Warehouse inbound. |
| Hopper | 8 | 8 | 16 | Mine Tipple outbound; Power Plant inbound. |
| Reefer | 5 | 5 | 10 | Packing Sheds outbound; Grocer’s Warehouse inbound. |
| Tank car | 6 | 6 | 12 | Refinery outbound; Power Plant inbound. |
| Caboose | 6 | 0 | 6 | Required or permitted at the rear of the applicable train cards. |
| **Total** | **43** | **35** | **78** | |
The engine is not rolling stock and does not count against the four-car Crew Tray limit.
## Other supplies
| Component | Count | Note |
| --- | ---: | --- |
| Crew Trays | players + 3 | Engine and tray are one combined resource; there is no "engine without a tray". |
| 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
### Division Yard
The Division Yard is the active supply. It provides:
- loaded cars to a Freight Agent stocking a green outbound box;
- empty cars for building up trains;
- an empty coach when passengers detrain; and
- an empty matching freight car when a loaded freight car begins unloading.
When a train completes a run or is destroyed, its caboose returns to the Division Yard. When a car is taken from the Division Yard and that yard has become entirely empty, every car from the Classification Yard moves back into the Division Yard immediately.
### Classification Yard
The Classification Yard collects used rolling stock: cars displaced by boarding passengers, cars
cleared from inbound red boxes, cars removed by unjamming a facility, ordinary cars from completed
or destroyed trains, and empty cars replaced by a completed outbound freight load. It is not a
player-selectable source. It returns to service only when the Division Yard is empty.
> **This is a one-way ratchet for COACHES, and it decides games.** Boarding sends an emptied coach
> here; detraining takes a fresh empty out of the *Division* Yard. Nothing returns a coach to the
> Division Yard except the bare-yard refill — and a yard kept topped up with freight cars returning
> from industries may never run bare. Measured over one three-Day game, every coach was here by the
> middle of Day 2 and stayed. See [Rules](rules.md) §4.6.
## Crew Trays and trains
Crew Trays represent an engine and its coupled consist. The game creates **players + 3** free trays at setup. A tray is scarce: a due train without a free tray is held for that Stage, and Extras wait until one becomes free.
Within a tray, the engine can pull, push, or be between cars while switching. To depart an Office for the Mainline, it must be made up with the engine at an outer end; if it has a caboose, the caboose must be at the opposite outer end. A train may have at most four cars. A scheduled train can run under-strength but never with more than its card allows.
## Fedora
The **Fedora** is the physical marker for the Superintendent. The player with it resolves
following-train clearance decisions, takes the Yard Office and Red Flag questions, and is the player
every round round the table starts with. A Mainline collision is their fault by §10, and costs 5
Revenue. The initial holder is the first player tied for the highest Superintendent setup D12 roll.
It passes at the end of Stages 3, 6, 9 and 12 — every third Stage, at the Supervisor Shift. 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
The D12 is used by the engine for:
- the initial seating roll in a multi-player engine game;
- the initial Superintendent roll;
- a timetabled train’s timetable slot; and
- 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.
+37 -24
View File
@@ -20,13 +20,31 @@ These stay as-is. Everything below is derived from them.
> placeholder for exactly this material, and the balance measurements in Gap 12 were taken against a
> ruleset that does not match the design.
## For players and testers
Written to be handed to somebody who is about to play, rather than to somebody building the game.
| Document | What it is |
| --- | --- |
| [`quickstart.md`](quickstart.md) | **Start here if you have never played.** The point of the game, how a Stage runs, what is on screen, how you win, a first twenty minutes, and what to report. |
| [`rules.md`](rules.md) | **The rules in full**, as the engine actually runs them, with a FAQ. |
| [`home-deck.md`](home-deck.md) | How the Home Office deck is dealt, drawn and played out. |
| [`mainline-deck.md`](mainline-deck.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. |
| [`components.md`](components.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
**None of these carry a version in the filename**, and that is deliberate (Jesse, 2026-09-21): they
are kept current with every release rather than published as editions, so the name is always the
latest and each says at the top which build it describes. Four of them were stamped `v0.4.5` until
v0.8.0.17 — the prototype rules edition they were first written against, never the version they
described — which read as though they documented a build five minor versions old.
## Rules
| Document | What it is |
| --- | --- |
| [`rules/rules-v0.1.md`](rules/rules-v0.1.md) | Faithful markdown transcription of the PDFs. No corrections. The baseline everything diffs against. |
| [`rules/rules-v0.2.md`](rules/rules-v0.2.md) | **The working ruleset.** v0.1 with all ten gaps resolved, each change marked with its gap number. |
| [`rules/card-reference.md`](rules/card-reference.md) | What is printed on every card, plus the economy summary. The spec an engine or a print-and-play layout consumes. |
| [`rules/card-reference.md`](rules/card-reference.md) | **⚠ SUPERSEDED** — an invented 52-card placeholder, kept for its economy summary and its history. For what is printed on every card, read 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. |
@@ -49,23 +67,20 @@ must do.
## Current status
**v0.4.3.** Rules formalized, card faces specified, architecture documented, and the game playable
solitaire in a browser. 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 hand-maintained tally is what drifted last time.
**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
hand-maintained tally is what drifted last time.
**What is built.** The rules engine, the developer bot, the balance harness, the replay viewer and
the playable page — components 1–7, 17 and 18 of
[`architecture/components.md`](architecture/components.md). A game can be saved, shared, replayed and
stepped back through. **493 tests.**
**What is built.** The rules engine, the developer bot, the balance harness, the replay viewer, the
playable page — and the server: lobby, game codes, seating, bots, per-seat reconnection, persistence
by replaying the intent history, and an ordered replay of other players' turns on each player's own
screen. It ships as a StartOS package. **999 fast tests and 35 simulation tests.**
**What is not.** The server. Phases 0 and 1 of
[`architecture/multiplayer.md`](architecture/multiplayer.md) landed in v0.4.0 — seat and player are
separate, turn state is per player, and the page talks to a `Session` rather than to the engine, so a
`RemoteSession` drops in without the page changing. Phase 2 onward is **deliberately held** until the
two provisional rules introduced in v0.3.0 have been played at a table: changing a rule after the wire
format is live costs far more than changing it before. Also unbuilt: the 22 opponent-directed cards
and real audio.
**What is not.** The 22 opponent-directed cards — the Action and Space-use categories — are held out
of every dealt deck until they have an implementation, along with the two defensive cards whose only
purpose is to answer them. Real audio: everything the game plays is synthesised from oscillators.
**Balance is not where it should be, and no conclusion should be read from the revenue numbers yet.**
The rebalance pass is deliberately deferred until the rules stop moving — card counts, industry counts
@@ -85,17 +100,18 @@ on that. [`architecture/protocol.md`](architecture/protocol.md) §3 has the reas
`test/events.test.ts` pins it.
**The economy, in one line:** Local Operations actions are the main currency — one per Stage, twelve
per Day — but **inbound work bypasses them**, which is where the game's variance comes from. See
`card-reference.md` §7.
per Day — but **inbound work bypasses them**, which is where the game's variance comes from.
**Stack: TypeScript**, chosen so the engine runs in both the server and the browser — one
implementation of the movement rules, and instant affordances without a round-trip. Node 22 runs
TypeScript natively, so there is no build step during development, which also means **erasable syntax
only**: no `enum`, no parameter properties, no namespaces.
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.
Running alongside, and independent of all of it: **print-and-play components.**
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]`.
@@ -104,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.
+398
View File
@@ -0,0 +1,398 @@
# Station Master — Home Deck
**Version 0.8.6** · 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.
> **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.
>
> **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.
## The piles
- **Home Office deck** — face down. The pile a Draw comes from.
- **Three Departments** — face-up discard piles. A discard goes onto one, which is precisely so a
rival may take it; a Draw may take the top card of a Department instead of the deck. **Taking the
last card off a Department refills it immediately** from the Home Office deck (§6.2), so there
are always three piles to choose from; see the Rules.
- **Salvage Yard** — where a played-out card ends up. An Extra's card goes here after its run.
When the Home Office deck runs out it is rebuilt from the Salvage Yard and **all three Departments
in full**, reshuffled from the seeded stream. A **spent timetabled train** is not collected — its
number is on the timetable and it cannot run twice — but a *discarded* train was never played and
is still runnable, so it comes back.
## Hand and turn
The hand limit is **three**, or four while you hold a Red Flag. You may not end a turn over the
limit: play a card or discard one to a Department. Some cards cannot be discarded at all — an Extra
never can, and a timetabled train cannot when the `discardTimetabled` house rule is off — so a hand
of nothing but those has exactly one way forward, which is to play one.
The opening deal is a house-rule choice made when the game is dealt. The default (`threeRandom`) is
three cards from one shuffled deck; `threeTrackThreeOther` deals three track and three others from
two separately shuffled piles, over the hand limit, so the first turn is spent choosing
which district you can afford to build.
Drawing is one of the three Local Operations options — see [Rules](rules.md)
§4.2. Taking the option lets you draw **and** play or discard within the same turn.
## Track cards
Track cards are ordinary Home Office cards, not a separate personal supply.
- A placed card must **connect to existing rail**: at least one neighbour must join it.
- The **Running Track** is the row from Limit to Limit. A card placed there must carry an east–west
through route, or it breaks the main.
- Placing track **on a Limit sign** extends the Running Track and moves that sign outward. The sign
is a physical card, so it moves rather than being left stranded mid-track.
- **Nothing may be placed outside your Limits** — track, industries and 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. 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
Porters, no passenger slots, and not a Control Point.
Office cards are **upgrades in strict sequence** — Whistle Post → Depot → Station → Terminal — and
each upgrades the Office in place rather than replacing its card or its attached track. Modifiers
already beside it are preserved. Each tier adds an A/D track, a Porter, and an outbound and inbound
passenger slot; a Depot and above is a Control Point and a Passenger Facility.
An upgrade takes no placement: the Office is where it already is.
<!-- 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.
**An industry track holds four cars, like any other card.** It is *not* sized by the industry's box
count. That distinction was a real bug: box count is how much WORK an industry can hold, not how
much RAIL it has, and conflating the two invented a printed siding no industry card carries.
<!-- 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 **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.
<!-- 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
Playing a **timetabled** train rolls the seeded D12 and puts its number in the first open timetable
slot at or after the result, wrapping around the 12-slot chart. It then runs at that Stage **every
Day**.
Playing an **Extra** queues it; it is made up when a Crew Tray comes free, and **the player who
played the card chooses where it starts and loads it as they choose** (§7). Where it may start is a
house rule — `divisionPointsOnly`, `ownOffice`, or `anyOffice` (the default) — and the Interchange is
also available, because it is the one Mainline card with a yard. An Extra runs once and its card
goes to the Salvage Yard.
The listed consist is a **maximum, not a minimum**. A train may depart with fewer cars, but not with
more, not of the wrong category, not with a car behind the caboose, and not with the engine buried
among its own cars. A Crew Tray holds four pieces, and a caboose counts toward the four.
A train made up short of what its card calls for is reported as such, with what it wanted and why
the yard could not supply it — see Rules §4.4.
**Per-train consists and printed rules 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. 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
played into your district**: it goes onto a **Mainline card** — any one of them — and stops
trains there rear-ending each other. It takes no square and is not part of anybody's Office
Area. See [Mainline deck](mainline-deck.md).
- **Mainline modifiers** are played onto a Mainline card: the Heavy Grade helpers and Realignment.
See [Mainline deck](mainline-deck.md).
- **Maneuvers** are held and spent: **Red Flags** and **Flying Switch** have their own actions.
Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
implement.
<!-- 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
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.
+165
View File
@@ -0,0 +1,165 @@
# Station Master — Mainline Deck
**Version 0.8.6** · 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.
> **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**, so no Division can hold
two of a card printed once. The two Division Points are the fixed ends of the Division and are not
in the deck: `buildDivision` lays them itself.
A card does not belong to either neighbouring Office. It is shared Division.
### Crossing time is REGIONS, not mph
A card is divided into **regions**, and a train advances **one region per Stage**. Crossing time is
therefore `regions − startRegion`, and nothing else. **The printed mph is scenery.**
This is the part most likely to be remembered wrong. 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:
1. **The card's own back region.** The Uncontrolled Siding and the Interchange print a back region
that is not part of the road, so a train running through begins past it.
2. **A card that prints a Fast and a Slow start — and only Hilly does.** A fast train starts one
region along and crosses in 1 Stage; a slow one takes 2. **No other card reads a train's
Fast/Slow rating at all.**
3. **The permanent Heavy Grade modifiers**, each moving a train one region up the hill.
4. **Occupancy**: arriving to find the card occupied can put a train in the siding, a region behind.
No crossing ever takes less than one Stage.
### Traffic
Only **Double Track** lets two trains stand on one card, so a following train is not held behind a
slower one. Every other card holds one train at a time. The **Uncontrolled Siding** is not a passing
card: arriving to find a train already there puts you in the siding a region behind it — you do not
run into it, and it costs you the extra Stage instead.
Whether a following train may enter an occupied card at all is the Superintendent's ruling (§8.1,
Rules §4.5). Getting it wrong is what causes collisions.
## The deck
Ten drawable cards — Plains twice, the other eight once each — plus the two Division Point cards,
which are not drawn.
| Card | Copies |
| --- | ---: |
| Plains | 2 |
| Curves | 1 |
| Hilly | 1 |
| Heavy Grade | 1 |
| Double Track | 1 |
| Uncontrolled Siding | 1 |
| Tunnel | 1 |
| Trestle | 1 |
| Interchange | 1 |
| East / West Division Point | 1 each, not dealt |
The PDF art labels the Interchange "Yard". This reference uses **Interchange** throughout, to keep
it apart from the Division Yard, the Classification Yard, the Yard Office and the Small Yard — five
different things.
**Region counts and entry points per card are in 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. 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.
## Heavy Grade modifiers
Home Office cards, played onto a Mainline card during a player's Draw option.
| Card | Placement and effect |
| --- | --- |
| Brakeman | Heavy Grade only. A **downhill** train starts one region further on. |
| Airbrakes | Heavy Grade only, and **Brakeman must already be on that card**. A downhill train starts one region further again. |
| Helpers | Heavy Grade only. An **uphill** train starts one region further on. |
| Realignment | Only onto an **unoccupied** Mainline card, 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
that is not played into a district.** It goes onto a **Mainline card — any of them**, not only a
grade — and takes no square in anybody's Office Area.
What it does: trains on that card **will not rear-end each other**. A following train is held short
of the one ahead instead of running into it, so the Superintendent has no §8.1 judgment to make and
no collision is scored. It is the only thing besides a Red Flag that prevents a rear-end collision
out on the Mainline, and unlike a Red Flag it stays on the card for the rest of the game.
On the board a card carrying it draws a **signal mast with a lit lamp** at its top-right corner; the
three grade helpers draw as **BRK**, **AIR** and **HLP** beside the card's name. Realignment draws
nothing, because a realigned card simply becomes the card it was converted into.
## Heavy Grade orientation is settled, not missing
Heavy Grade orientation is **rolled from the seed**, not chosen by a player. **This is a decision,
not a gap.**
The card prints "(Up)" and "Player sets orientation", which assumes the card has an owner. This one
does not: the Division is laid `DP · Mainline · Office · Mainline · … · DP`, so a Heavy Grade always
sits **between two districts**, or beyond an end Division Point beside one — never inside a single
player's district.
Orientation is not cosmetic. Brakeman and Airbrakes help a train running **downhill**, Helpers helps
one running **uphill**, and odd-numbered trains run west while even run east. Turning the card
around decides which modifiers are worth anything and which direction of traffic is favoured, for
the whole game. Handing that to one of two neighbours advantages them over the other, and neither
has a fair claim to it.
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.
+7 -6
View File
@@ -361,14 +361,15 @@ Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...
> **Read those two, not this**, when building steps 2-7. The differences that matter:
>
> - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts`
> object. Their contents sit at the top level. **All 37 properties, which is the same list as
> object. Their contents sit at the top level. **All 38 properties, which is the same list as
> `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`,
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
> `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
> `collisionsToday`, `collisionsTotal`, `status`, `outcome`, `extraDays`, `extensionVotes`,
> `collisionsToday`, `collisionsPrevDay`, `collisionsTotal`, `status`, `outcome`, `extraDays`,
> `extensionVotes`,
> `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`,
> `districts`. The first 35 come from `projectSharedTable`; `division` and `districts` are added
> `districts`. The first 36 come from `projectSharedTable`; `division` and `districts` are added
> by `PublicFrame` itself.
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it
> in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second,
@@ -887,7 +888,7 @@ Cover:
## Step 5 — Minimal visual-only Jitsi engine
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1027,7 +1028,7 @@ Port/adapt the sibling repository’s proven tests for:
## Step 6 — Chromium publisher supervisor
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1160,7 +1161,7 @@ Use fake child processes and fake control sockets to test:
## Step 7 — Configuration, lifecycle, packaging, and observability
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
+113
View File
@@ -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.
+211
View File
@@ -0,0 +1,211 @@
# Station Master — Quickstart
**Version 0.8.6** · 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.
---
## 1. What the game is
You are a railroad **Office** — a town — on a shared east–west main line called the **Division**.
Everyone's Office sits in a row along it, west to east, with **Mainline cards** between them.
Two jobs run at once:
**Your own job, in your district.** Build track. Build industries and a passenger platform. Shunt
cars around with a switching crew to get the right car to the right place, so that freight can be
loaded and unloaded and passengers can get on and off. Every one of those completed pieces of work
pays **Revenue**, which is the score.
**The shared job, out on the Division.** Scheduled trains run across everybody's territory on a
timetable. They arrive at your Office, and you work them. When two trains want the same stretch of
track, the player wearing the **Superintendent's Fedora** rules on whether the second may follow the
first. Rule wrong and they collide, which costs 5 Revenue and counts against a limit that can end
the game.
The tension the game is built around: **the useful work is local and slow, and the trains are shared
and do not wait.**
---
## 2. How you win
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, 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.
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. 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.
### Where Revenue comes from
| Work | Pays |
| --- | --- |
| A passenger boarding at your platform | 1 |
| A passenger getting off at your platform | 1 |
| Completing an outbound freight load | 1 |
| Completing an inbound freight unload | 1 |
| A train completing its run across the whole Division | paid to every player; usually set to 0 |
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.
---
## 3. The shape of a Stage
Every Stage runs five phases in this order. Only two of them are your turn.
| # | Phase | What happens |
| --- | --- | --- |
| 1 | **Local Operations** | **Your turn.** Choose ONE of three things (below). Each player in turn, starting with the Superintendent and working eastward. |
| 2 | **New Train** | Trains due this Stage are made up from the Division Yard. The table takes turns adding one car each. |
| 3 | **Mainline** | Automatic. Trains move, lowest number first. This is where clearance rulings and collisions happen. |
| 4 | **Cargo** | **Your turn.** Your Laborers and Porters do their work — the loading, unloading, boarding and detraining that actually pays. |
| 5 | **Supervisor Shift** | Automatic. Laborers and Porters refresh; expedited trains depart. Every third Stage the Fedora passes. |
### Local Operations: you get exactly one of these
- **Switch** — take a crew and shunt. **Six Moves** (five at night under Reduced Visibility). This is
how cars physically get from the yard to an industry and back. Cars you run over are coupled up
automatically, so plan the route.
- **Draw** — take a card, then play and/or discard. This is how your district gets built: track,
industries, Office upgrades, modifiers, train cards.
- **Freight Agent** — one clerical act: stock a green outbound box, clear a red inbound box, or
unjam a facility.
**You cannot do two of them in one Stage.** Choosing is most of the game. A Stage spent drawing is a
Stage not spent switching.
> Passengers are **not** the Freight Agent's job. They board and get off in the **Cargo** phase, with
> a **Porter**. Reaching for the wrong role and finding nothing there is the single most common new
> player mistake, so each role now says on screen what it is for.
---
## 4. The screen
**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
drawn as an arrow (◀ or ▶) showing which way it points.
- **Your Office Area.** Your own grid of track cards. This is where switching happens. It folds away
outside the phases that change it, unless you pin it open.
- **History.** What has happened, most recent first.
**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.
- **Cards in My Hand**, and the **Department decks** — three face-up discard piles anyone may draw
the top of.
- **The Yards.** The **Division Yard** is the live supply of cars. The **Classification Yard** is
where used cars go, and it comes back **only when the Division Yard runs completely bare**.
- **Timetable** — who is due out and when.
- **Blocked — why nothing is moving.** *Read this panel.* When something will not work, this is
where the game explains why, in rules terms.
- **Facilities** — the load pipelines at each industry.
- **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. 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 **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
**every Day**.
5. **When a train arrives at your Office**, switch cars to it or from it, and do the paying work in
the **Cargo** phase.
6. Watch the **Blocked** panel whenever you are stuck. It is usually one missing thing: no empty car
spotted, no loaded car in the yard, a full box, a locked industry track.
---
## 6. Things that surprise new players
- **A turnout cannot be stopped on.** You may run through it; you may not end a Move there.
- **Coupling is mandatory.** Run over a standing car and you take it, whether or not you wanted it.
- **An industry track locks while a load is on its MEN | AT | WORK boxes.** No train can enter,
cross or work there until it clears.
- **A modifier adds a BOX, never room for a car.** It raises how much work an industry can hold, not
how much rail it has.
- **Nothing may be built outside your Limits** — track, industries and modifiers alike. Your district
ends at its sign, and the sign moves outward as your Running Track grows.
- **A train must be made up to leave.** Engine at one end; if it has a caboose, the caboose at the
far end. A train shunted out of shape sits at your Office until you fix it — a **Small Yard** will
re-order a consist for one Move, and each option tells you whether the result can leave.
- **Passengers may dry up completely.** Coaches move one way — boarding sends the emptied coach to
the Classification Yard, and it only comes back when the Division Yard is bare, which may never
happen. When it does, the yard panel warns you and passenger trains are made up empty. **This is
the rules working as designed.**
- **Expedited trains leave the same Stage they arrived**, after the Cargo phase. Ordinary ones wait.
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.
---
## 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) |
| 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) |
+481
View File
@@ -0,0 +1,481 @@
# Station Master — Rules
**Version 0.8.6** · 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 — 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).**
## 1. Overview and background
Station Master is a railroad-operations game set in the timetable-and-train-order era. Each player operates an Office Area on a shared east–west Division. The local work is building rail, switching cars, moving passengers and freight through facilities, and keeping the Office clear. The shared work is dispatching scheduled trains across the Division. The player holding the Superintendent’s Fedora decides whether to clear a following train into an occupied Subdivision; an unsafe decision can cause a collision.
Revenue is the score. Passenger boarding and detraining, freight loading and unloading, completed runs, collisions, and victory conditions are all resolved by the rules configured when the game is dealt.
This book is divided as follows:
1. overview and scope;
2. definitions;
3. creating, saving, replaying, and configuring games;
4. the shared basic game mechanics;
5. the implemented solitaire game;
6. the implemented multiplayer/engine status; and
7. FAQs and implementation limits.
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
[Home deck](home-deck.md) and [Mainline deck](mainline-deck.md).
## 2. Definitions
| Term | Meaning |
| --- | --- |
| A/D track | An Office arrival/departure capacity. A Whistle Post has 1; Depot, Station, and Terminal have 2, 3, and 4. |
| Card location | A square in an Office Area grid. A train may end a switching Move only on Operational Rail. |
| Classification Yard | The holding yard for used rolling stock. It refills the Division Yard only when that yard is completely empty. |
| Control Point | A Depot, Station, or Terminal. It breaks the shared Mainline into Subdivisions. A Whistle Post is not one. |
| Crew Tray | An engine plus up to four coupled rolling-stock cars. The engine itself does not count as a car. |
| Department pile | One of three shared face-up card stacks. Draw and discard affect only the top card. |
| Division Point | The western or eastern end of the shared Division, where trains enter and leave it. |
| Division Yard | The active source of rolling stock. |
| Expedited train | A train with the expedite rule. It performs Load/Unload at an Office after arriving, then attempts to depart at the end of that same Stage. |
| Facility | A Passenger Facility at an upgraded Office or a Freight Facility at an industry. |
| Freight Agent | The Local Operations option that stocks one outbound box, clears one inbound box, unjams one load, or ends without acting. |
| Limits | The two signs defining an Office Area’s Running Track. They move outward when that track is extended. |
| Local Operations | The first player-driven phase of a Stage. A player chooses one: switch, draw, or Freight Agent. |
| Mainline | The shared cards between Offices and Division Points. |
| MEN \| AT \| WORK | A three-box freight pipeline. Any load on it locks its industry track. |
| Operational Rail | A card on which a train may finish a Move. Turnouts are pass-through only; a locked industry is not usable at all. |
| Running Track | The east–west track between an Office Area’s Limits, including its Office. |
| Secondary Track | All local rail inside the Limits that is not Running Track. |
| Stage | One of twelve turns in a Day. Its phases are Local Operations, New Train, Mainline, Load/Unload and Shift Change. **On screen the last two are labelled "Cargo" and "Supervisor Shift"** — same phases, the names the interface uses. |
| Subdivision | Mainline between Division Points or Control Points. Clearance checks look through the whole next Subdivision. |
| Superintendent | The player with the Fedora. The role decides same-direction clearances and rotates every three Stages. |
| Timetabled train | A numbered train card scheduled to one of the 12 Stage slots, then due at that slot each Day. Odd numbers go west; even numbers go east. |
## 3. Create, save, replay, and game configuration
### 3.1 Starting a new game in the shipped client
The front page has three doors: **Play multiplayer**, **Play solitaire**, and **Browse replays**.
**Solitaire** runs entirely in your own browser and needs nothing from the server. Select **New
game**, then choose:
1. a numeric seed, or leave it blank for a fresh browser-generated seed;
2. a starting hand;
3. passenger, freight, and train-transit Revenue rates;
4. the number of Days, and the optional rules.
**Multiplayer** goes to the lobby — see §3.5. It needs the server, because the game is authoritative
there rather than in any one browser.
The browser writes those choices into the URL. A particular game is defined by the **seed plus these house rules**, not the seed alone.
The three starting-hand choices are:
| Choice | Setup result |
| --- | --- |
| Three random cards | Default. Deal three cards from one shuffled deck. |
| Six random cards | Deal six cards from one shuffled deck. The player begins over the normal hand limit and must play/discard down before ending the first Draw turn. |
| Three random track and three random non-track cards | Shuffle track and non-track piles separately for the opening deal; then recombine and shuffle their remainders for all later draws. |
The three Revenue values are independently rounded and clamped to 0–5:
- passenger Revenue per coach, paid on boarding and again on detraining;
- freight Revenue per load, paid on completion of an outbound load and again on completion of an inbound unload; and
- train Revenue per transit, paid to every player when a train leaves the Division.
Defaults are 1 / 1 / 0. Zero turns that revenue source off; it does not prevent the underlying operation.
### 3.2 Deterministic setup and the seed
The engine uses one seeded random stream for shuffles, Mainline selection, opening D12 rolls, timetable rolls, and automatic Heavy Grade orientation. Given the same seed, configuration, player names/order, and accepted action history, it reconstructs the same state.
At setup it creates each Office Area as `Limit — Whistle Post — Limit`, makes the Division Yard, creates players + 3 Crew Trays, turns up three Department cards, and constructs the shared Division.
In a multi-player engine game, every player receives two seeded D12 rolls:
- The **division roll** orders seats from low west to high east; equal results put the lower player index farther east.
- The **Superintendent roll** gives the initial Fedora to the first player tied for highest.
The opening deal starts at the Superintendent’s seat and proceeds **eastward** — increasing seat index, which is how the Division map draws the table.
### 3.3 Saving, resuming, and replaying solitaire
The local save format is JSON containing the seed, the accepted intent history, and the house rules. It does not serialize mutable game state. Loading recreates the game from the seed and replays every action through the rules engine. If an old history reaches an action that is no longer legal, replay stops there instead of forcing an invalid state.
The browser also stores the current local game and resumes it automatically when the page opens without an explicit seed in the URL. A downloaded save is named `station-master-seed<seed>-day<day>.json`.
**Undo is solitaire-only.** It removes the final accepted intent and rebuilds the game from the earlier history. Random outcomes are not rerolled: replaying the same action consumes the same seeded result. Undo can therefore change the player’s decision after seeing an outcome, but cannot fish for a different timetable die roll.
### 3.4 Game modes and endings
Three modes: **Solitaire**, **Competitive** and **Co-op**. All three are playable.
**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:**
1. **The timetable runs out** at the end of the last Day. Then:
2. **The combined Revenue floor** is checked first — `3 × players × days`. Fall short and
**everybody loses**, whatever anyone individually scored.
3. **Co-op** wins as a table if the floor is met.
4. **Competitive** is won by the **highest individual Revenue** once the floor is met.
5. **Collisions end it early.** Breaching the per-Day or total collision limit ends play at once in a
collective loss — the railroad has been declared unsafe.
**Extended play (Gitea#11).** Both days-based endings — running out of timetable, and closing short
of the Revenue floor — offer **another Day**, because they are the same event seen twice: the last
Day ended, and this is what the books say. A collision ending is **not** extendable, and neither is a
collision breach during an extended Day. The official result is frozen when the timetable first ran
out, so a railroad declared unsafe on Day 9 does not retract who won on Day 5.
**Three optional rules, all implemented:**
| Rule | Effect |
| --- | --- |
| Reduced Visibility | Five switching Moves instead of six, in Stages 1, 2, 3, 11 and 12 — the night Stages. |
| Employee Rotation | Every player moves one chair at the end of each Day. Revenue and the Fedora travel with the player; the Office Areas stay with the seats. |
| Emergency Toolbox | Starts every player holding the Red Flags status, so the hand limit opens at four. |
(There is no "Sister Trains" flag. It appeared in an earlier draft of this document and never in the
configuration.)
### 3.5 Multiplayer setup
Multiplayer is delivered and is what this package is for. The flow:
1. **Create or join.** The host creates a game and gets a **game code**; everyone else joins with
that code. Seats fill as people arrive, and any seat left empty can be **filled with a bot**.
2. **Start.** Once the host starts, that same page is where every player plays their turns and
watches the table. There is nothing else to open.
3. **The server is authoritative.** The game lives on the server, not in a browser: it survives a
page reload, a browser restart and a service update, replaying its intent history to get back to
where it was.
4. **Your seat is a token in YOUR browser**, scoped to the origin you joined at. A reload finds it
and puts you straight back. Clearing site data, a private window, or a different browser does not:
the seat is still yours and still on the server, but that browser can no longer prove it is you.
An administrator can mint a **single-use recovery link** (StartOS action **Restore a Seat**) that
trades a code for the token and expires in 30 minutes.
5. **Watching the table.** Other players' turns arrive as an ordered replay rather than as a board
that has silently rearranged itself, with a `[N behind]` counter, Pause and Skip.
Solitaire needs none of this and runs with the page alone.
## 4. Basic game mechanics
### 4.1 The Stage sequence
Each of 12 Stages follows this sequence:
```text
1. Local Operations — each player, starting with the Superintendent and proceeding eastward
2. New Train — make up due timetabled trains, then queued second sections and Extras
3. Mainline — automatic train movement in numeric order
4. Load/Unload — each player, same order. On screen: "Cargo"
5. Shift Change — expedited departures, workers. On screen: "Supervisor Shift"
```
**"Proceeding left" is seat order, west to east**, which is how the Division map draws it — the
screen says "eastward" for that reason, because a table has no shared left.
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. 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
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 may also place cars **ahead
of the engine** — which is how a cut is set up to be shoved into a facing industry. Each option on
the menu shows the train it would build, laid out west to east as the board draws it, and says
whether the result may leave the Office or would be held there. Flying Switch spends one Move to
roll a tail cut into a connected Freight Facility.
**Draw.** Take one card from the face-down Home Office or the exposed top of one Department pile. During this option, play eligible cards and/or discard cards to Department piles, then finish at the hand limit. Track, facilities, offices, modifiers, enhancements, and train cards have the placement or scheduling rules in the deck references. Mainline modifiers are played from this option as well.
> **A Department that is emptied is refilled at once.** §6.2: *"If any of the Department decks is
> empty, draw a Home Office card and place it in the empty spot."* So taking the last card off a
> pile does not leave a gap — a fresh card is turned face up from the Home Office deck to replace
> it, and there are always three Departments to draw from. The refill happens **only** when the
> 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.
>
**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.
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.
### 4.3 Track, switching, and local safety
The Running Track cannot be broken by a curve or other card lacking its east–west through route. Local branches use turnouts and 45° curves. Cards that touch vertically connect only if their 45° legs share the correct diagonal; mere adjacency is not rail connection.
An industry’s track is locked while any load is on its MEN | AT | WORK pipeline. Trains cannot enter, cross, pick up from, or set cars out there. When it is unlocked, an industry track is ordinary Operating Rail and holds up to four cars, the same as any other non-turnout, non-Limits card: a crew may set out one, two, three or four cars there, whatever it is carrying. An industry’s green and red boxes are how much work it can hold, not how much rail it has, and a Modifier beside it adds a box without adding room for a car.
Train-card restrictions also apply while switching. No-switching trains cannot move, set out, or sort locally. Trains 3/4 share a one-freight-car budget for pickup or setout at each grid location. Trains 7/8 cannot set out their coach. X13 cannot pick up, and X22 may pick up empty cars only.
### 4.4 New Train phase
At the Stage shown on the timetable, the engine makes up the matching timetabled train if a Crew Tray is free. It starts at the Division Point appropriate to its direction. A train may receive matching loaded or empty cars from the Division Yard until its listed maximum consist is reached or no suitable car remains. It is permitted to leave under-strength.
A Second Section order on the due train creates another identical timetabled train behind it when a
free tray is available. A played Extra is made up after timetabled trains and Second Sections when a
tray is free, and **the player who played the card chooses where it starts** — either Division
Point, the Interchange, or an Office, according to the `extraStart` house rule — and loads it as they
choose rather than going round the table.
A timetabled train's consist is built by the table: **starting with the Superintendent and working
eastward, each player adds ONE car**, going round again until the train is full or the Division Yard
holds nothing it can take.
**When the yard can supply nothing, the game says so.** The round is skipped — there is no point
asking for a car that cannot be given — and the train is reported as made up short, naming what its
card wanted, how many such cars are waiting in the Classification Yard, and how far the Division Yard
is from bare. See §4.6 for why that happens to coaches in particular.
### 4.5 Mainline movement and Office arrival
Mainline movement is automatic and processes lower train numbers first; a timetabled train outranks an Extra with the same number. A train at a Division Point, at an Office A/D track, or already crossing a Mainline card attempts its applicable movement.
**Crossing time is the card's REGIONS**, one per Stage — not its printed mph, which is scenery, and
not the train's Fast/Slow rating, which only **Hilly** reads. See the
[Mainline deck](mainline-deck.md) reference for what moves a train's entry
point. On a normal card, a train must check the entire next Subdivision before entering it:
- an opposing train normally blocks entry;
- a following same-direction train asks the Superintendent to allow or deny clearance;
- Red Flags or ABS Signals hold the follower automatically; and
- **Double Track** — the one card two trains may stand on — allows entry without that check. The
**Uncontrolled Siding** is not a passing card: a train arriving to find it occupied takes the
siding a region behind, which costs it the extra Stage instead of a collision.
An Office arrival normally takes a free A/D track. If the Office is full, the inbound train collides and the local Office player loses 5 Revenue; Interlocking instead holds it at the Limits. A 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 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).
### 4.6 Passenger work
Passenger work occurs during Load/Unload, at a Depot, Station, or Terminal. Each Porter may take one action per Stage.
- **Board:** replace an empty coach on an eligible train at the Office with a loaded coach from a green outbound box. The removed empty coach goes to the Classification Yard. Earn configured passenger Revenue.
- **Detrain:** replace a loaded coach on an eligible train with an empty coach from the Division Yard, placing the loaded coach into an available red inbound box. Earn configured passenger Revenue.
Crack Limited trains permit passenger work at Terminals only. Military Train and Director's Private
Car permit no passenger work. A Whistle Post has no Porters.
> **Coaches travel one way, and it is worth knowing before you plan around passengers.** Boarding
> sends the emptied coach to the **Classification** Yard; detraining draws a fresh empty out of the
> **Division** Yard; and §2.2 returns the Classification Yard only when the Division Yard runs
> completely bare. Measured over one three-Day game: sixteen coaches in the Division Yard at setup,
> **none from Day 2 Stage 8 onward**, fifteen piled in Classification while the Division Yard held
> steady at 46–47 freight cars and stopped draining — so the refill never fired and no passenger
> could board or detrain anywhere for the rest of the game, while trains whose cards call for coaches
> were made up empty.
>
> **This is the rules working as printed: 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
Freight work also occurs during Load/Unload. Each Laborer can take one action per Stage. Every freight facility has MEN, AT, and WORK boxes.
For an **outbound load**, first use the Freight Agent to put a matching loaded car into the green box, with an unclaimed empty matching car spotted on the industry track. A Laborer starts the load on MEN, then advances it to AT, then WORK, then onto the empty car. Completing the last step changes that car to loaded, sends the replaced empty car to the Classification Yard, and earns configured freight Revenue.
For an **inbound unload**, a matching loaded car must be spotted at an inbound-capable industry and the Division Yard must contain a matching empty car. A Laborer replaces the spotted loaded car with that empty car and starts the load on WORK. Laborers move it backward through AT and MEN into a free red inbound box. That final placement earns configured freight Revenue. Clearing the red box later with the Freight Agent pays no additional Revenue.
## 5. Solitaire
Solitaire is one named player and one Office Area, running entirely in the browser with no server.
It is **not** the only implemented game any more — see §6 — but it is the one that needs nothing but
the page.
Solitaire-specific features are:
- a local browser save, automatic resume, and JSON download/load;
- **unlimited step-by-step Undo** back through accepted action history — multiplayer has none,
because a shared game cannot be rewound under the other players; and
- a seed/rules URL suitable for sharing or reproducing a game.
Automatic phases get a visible beat in solitaire too, so the board plays its own moves out rather
than jumping.
The active deck removes all 22 opponent-directed cards. Therefore, the defensive cards whose only purpose is to answer them (Facing Point Locks and Water Column) can be placed but have no opportunity to fire; Overpass has no effect at all. The game still includes shared-rail mechanics such as Mainline clearance, but with one player no other player can occupy the Division.
To win the default one-player game, finish Day 5 having met the combined Revenue floor —
`3 × players × days`, which at one player over five Days is **15**. Below it is a loss, and the game
offers you another Day rather than simply ending. Train-transit Revenue defaults to zero, so the
score has to come principally from passenger and freight work.
## 6. Multiplayer
### 6.1 What the engine supports
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:
- the D12 seating and Superintendent rolls described in section 3;
- individual Revenue in competitive play and shared total Revenue in co-op;
- a collective loss on breaching the collision limits;
- the combined Revenue floor, `3 × players × days`; and
- train-transit Revenue awarded to every player, if that revenue setting is nonzero.
The engine’s setup checks only that there is at least one player and that Solitaire has exactly one player. It does not enforce a maximum player count, although the test and design material exercise two through four players.
### 6.2 What IS delivered
Everything in §3.5: a lobby with game codes, seating, bots filling empty chairs, an authoritative
server that survives restarts and updates by replaying its intent history, per-seat reconnection, an
administrator's single-use seat-recovery link, and an ordered replay of other players' turns on each
player's own screen.
The package also exposes administrative **actions** on StartOS — list games in progress, get the
join secret, manage a game, restore a seat — documented in the wrapper repository rather than here.
### 6.3 What is NOT delivered
**No card may be played at another player.** The 12 space-use and 10 action cards are excluded from
every dealt deck in every mode, and `check` rejects playing one. That includes Derail, Broken
Coupler, Railroad Crossing, the score-penalty cards, Vandalism, Hotbox, Outlawed and all
table-space cards. Facing Point Locks and Water Column exist only as dormant defences against
effects nothing can currently cause, and Overpass has no effect at all.
**No multiplayer Undo.** A shared game cannot be rewound under the other players.
### 6.4 How multiplayer differs from solitaire
Players have separate districts, hands and scores, and share the Home Office deck, the Department
piles, the yards, the timetable, the Mainline and every traffic consequence. One player acts at a
time.
**A seat is not a player**, and the distinction is load-bearing. Offices belong to seats; Revenue,
hands, the Fedora and identity belong to players. With **Employee Rotation** on, players move one
chair at the end of each Day and take their Revenue and the Fedora with them, while the districts
stay where they are.
So multiplayer changes shared traffic, scores, turn order and how the game is won or lost — not card
attacks, which do not exist in any mode.
**Saving a multiplayer game.** The server holds the save; **Save replay** downloads your copy. A save
carries the seed, and the seed is the whole deal — every hand and the order of the deck — so in a
**Competitive** game the download is available once the game is over. In **Co-op** it is available
at any time.
## 7. Frequently asked questions
### Is the same seed always the same game?
Only with the same house rules, player configuration/order, and action history. The seed drives all engine randomness, but the starting hand and Revenue settings also change the game.
### Why can’t I end my draw turn?
Your hand exceeds three cards, or four when the Red Flags hand-limit status is active. Play or discard down to the limit.
### Why can’t my train move onto a turnout?
It may move through a turnout but cannot end a Move there because turnouts are not Operational Rail.
### Why can’t a train enter my industry?
Its MEN | AT | WORK pipeline contains a load, which locks the track; the rail geometry does not connect; another train occupies it; mandatory coupling would exceed four cars; or four cars are already standing there, which is as many as any card holds.
### Why won’t an industry accept a new outbound load?
The green box may be full, the Division Yard may lack a matching loaded car, or there is no unclaimed empty matching car already spotted on that industry’s track.
### Why does an inbound freight unload require an empty car in the Division Yard?
The engine immediately replaces the spotted loaded car with a matching empty car. It will not create a new car from nothing.
### Why did an arriving train collide instead of wait?
The Office had no free A/D track and no Interlocking. A full Office is an automatic local collision. An Interlocking holds the arrival at the Limits.
### Why is a train held at an Office even though the Mainline is clear?
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 was I fined for an expedited train?
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. **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.** 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?
No. All action and space-use cards are excluded from every dealt deck in every mode, and playing one
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 — 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?
Almost certainly the coach ratchet in §4.6: every coach has ended up in the Classification Yard,
which comes back only when the Division Yard runs completely bare. The yard panel warns when this
has happened, and a train made up short says so in the log.
### Why is every option in the Small Yard marked "HELD at the Office"?
Because that train is **already made up**, so every re-order on offer would break it — most often by
moving the caboose off the rear, which §8.2 will not let a train depart with. If the train is *not*
currently fit to run, at least one option will be marked "MADE UP, ready to leave".
### 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.
-260
View File
@@ -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 | Sorts cars |
| --- | ---: | ---: | --- | :---: | :---: |
| 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 — the printed speed is scenery. · 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 — the printed speed is scenery. · 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 — the printed speed is scenery. · 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 — the printed speed is scenery. · 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 — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · Cars may be sorted into any new order here.
---
## 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 |
+5 -4
View File
@@ -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
@@ -68,7 +69,7 @@ read "Both" for both of them, and that was the *other half* of the same mistaken
House" named those two, §9.3 had to be describing them, so they had to be two-way. Once the Freight
House is its own card the argument evaporates, and playtesting settled it — "Grocer's Warehouse
should be receive only, does not ship anything out"; "Refinery: only ships out tanks, does not
receive anything" (Jesse). `StationMaster-Home-Deck-v0.4.5.md` prints both that way, and the modifier
receive anything" (Jesse). `home-deck.md` prints both that way, and the modifier
set agrees: all three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1
outbound**, which would be an odd card set for a facility that receives half the time.
<!-- TODO v0.5.0: Mine Tipple, Produce Shed and Power Plant above (3/3/4, 2/2/3, 3/3/4) were NOT
+3 -3
View File
@@ -142,7 +142,7 @@ asked a third time:
rising to 50% at four players. A pre-game interrupt for a rule four games in five never see.
The docs were the actual defect. `README.md` still listed it among three open rules questions (all
three closed in v0.5.0) and `StationMaster-Mainline-Deck-v0.4.5.md` still said "the implementation
three closed in v0.5.0) and `mainline-deck.md` still said "the implementation
needs a player-selection step to match the card". Both now say settled, and why.
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
@@ -555,7 +555,7 @@ Hotel) are what grow them.
now carries **2** coaches and The Sparrow **3**. The table above is left as `Trains3.pdf` prints it,
because that is what this section is for — what the design SAYS. What the game plays is
`src/engine/content.ts`, with the per-card table in
[`../StationMaster-Home-Deck-v0.4.5.md`](../StationMaster-Home-Deck-v0.4.5.md).
[`../home-deck.md`](../home-deck.md).
### Extras (X13–X22) — ten distinct trains, not four generic ones
@@ -910,7 +910,7 @@ any setting** — being a place an Extra can start is part of what upgrading buy
- **The Mainline cards were rolled, not dealt.** `buildDivision` drew uniformly from the nine card
TYPES **with replacement**, so a Division could be dealt two Interchanges or two Tunnels, and
Plains — printed twice in the deck — carried the same weight as cards printed once.
`docs/StationMaster-Mainline-Deck-v0.4.5.md` had already flagged the mismatch as needing
`docs/mainline-deck.md` had already flagged the mismatch as needing
correction; "an Extra may start at the Interchange if one is on the board" is what forced it, since
that only reads as a rule if the board holds at most one. Now dealt from the printed ten-card deck
without replacement.
-301
View File
@@ -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
-656
View File
@@ -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
}
]
}
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.8.0",
"version": "0.8.6",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
@@ -10,7 +10,9 @@
"scripts": {
"typecheck": "tsc --noEmit",
"pretest": "tsc --noEmit && node scripts/build-web.ts",
"test": "node --test test/*.test.ts test/**/*.test.ts",
"test": "node --test $(ls test/*.test.ts test/**/*.test.ts | grep -v '^test/sim.test.ts$')",
"pretest:sim": "tsc --noEmit",
"test:sim": "node --test test/sim.test.ts",
"build:web": "node scripts/build-web.ts",
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
"deploy:web": "node scripts/deploy-web.ts",
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
+59 -100
View File
@@ -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,17 +84,8 @@ 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();
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |');
section('mainline');
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |');
w('| --- | ---: | ---: | --- | :---: | :---: |');
for (const m of MAINLINE_PROFILES) {
const ss = m.speedStarts ? `${m.speedStarts.fast} / ${m.speedStarts.slow}` : '—';
@@ -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}`);
}
+76 -4
View File
@@ -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
@@ -70,11 +73,17 @@ function buildStamp(): string {
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
*
* The version plus the build's own timestamp is always distinct, needs nothing from the
* environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key
* that says so costs one refetch, while one that lies costs a release nobody receives.
* A marker plus the build's own timestamp is always distinct, needs nothing from the environment,
* and stays honest: two builds of the same commit ARE two deploys, and a cache key that says so
* costs one refetch, while one that lies costs a release nobody receives.
*
* NOT THE VERSION, which is what this used to lead with. The stamp below already begins with
* `v${pkg.version}`, so on exactly the builds that take this path — every `.s9pk`, which has no
* `.git` — the header read "v0.8.0.10 · 0.8.0.10-mfq2p1 · …" and the version appeared twice
* (Jesse, playtest 2026-09-16). The timestamp alone carries the uniqueness; the version is
* already said once, properly, at the front.
*/
let git = `${pkg.version}-${Date.now().toString(36)}`;
let git = `nogit-${Date.now().toString(36)}`;
try {
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
.toString()
@@ -182,6 +191,69 @@ if (existsSync(imageSrc)) {
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
}
/**
* The player-facing documentation, RENDERED and published beside the game.
*
* 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.
*
* 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.
*
* 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.
*
* 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[] = 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);
if (!existsSync(src)) {
// Loud rather than silent: a missing document is a broken link on a page already published, and
// the build is the only place that can still notice.
console.error(`WARNING: docs/${rel} is missing — a published link will 404`);
continue;
}
const md = readFileSync(src, 'utf8');
const out = join(dist, rel);
mkdirSync(dirname(out), { recursive: true });
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.
writeFileSync(
join(dist, 'README.txt'),
+197
View File
@@ -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, '&amp;').replace(/</g, '&lt;')}</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>
`;
}
+286
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 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 };
}
+246 -32
View File
@@ -31,12 +31,13 @@ import {
houseRules,
officeProfile,
mainlineProfile,
consistSize,
} from './content.ts';
import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
import type { CarType, Direction, MainlineEntry, MainlineKind } from './content.ts';
import type { GameEvent } from './events.ts';
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
// the legality test cannot disagree about which train is being assembled.
import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts';
import { acceptsCar, areaAtSeat, areaOf, isBeingMadeUp, occupancyFor, trainNeedingCars } from './apply.ts';
import { legalActions } from './legal.ts';
import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts';
@@ -94,6 +95,18 @@ function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
const step = (d: Direction): number => (d === 'east' ? 1 : -1);
/** The Division node a train is standing on — its Office, its Mainline card, or its Division Point. */
function nodeIndexOfTray(s: GameState, tray: CrewTray): number | null {
const at = tray.position;
if (at.at === 'grid') return nodeIndexOfOffice(s, at.seat);
if (at.at === 'mainline') return at.index;
if (at.at === 'divisionPoint') {
const side = at.side;
return s.division.nodes.findIndex((n) => n.kind === 'divisionPoint' && n.side === side);
}
return null;
}
// ---------------------------------------------------------------------------
// advance
// ---------------------------------------------------------------------------
@@ -381,6 +394,45 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
return { events, needsInput: true };
}
/**
* SAY SO WHEN A TRAIN GOT NOTHING, before the round is over and the train runs (playtest,
* 2026-09-16: "train 5, the sparrow, has no coaches, which seems strange").
*
* `trainNeedingCars` returns null both when every consist is full and when the Division Yard holds
* nothing a short train will take — the same answer for "done" and for "cannot be done" — so the
* phase moved on in silence and the only trace was a MADE UP line promising "now taking cars". The
* Sparrow calls for three coaches and left empty twice in one game.
*
* REPORTED HERE RATHER THAN AT THE MADE-UP MOMENT, because a train made up early in the round can
* still be filled by a later placement; only once the round has nothing left to offer is the
* shortfall a fact. This is reached exactly once per Stage — the next line enters the Mainline
* Phase — so the report cannot repeat.
*/
for (const tray of s.trays.values()) {
if (!isBeingMadeUp(tray) || tray.trainNumber === null) continue;
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
if (!profile) continue;
const category = (t: CarType): 'freight' | 'coach' | 'caboose' =>
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
// What the card still wants: asked of `acceptsCar` per category, so a full category and a
// category barred by the card's own rules answer the same way here as they do to a player.
const missing = (['freight', 'coach', 'caboose'] as const).filter((cat) => {
const sample: CarType = cat === 'coach' ? 'coach' : cat === 'caboose' ? 'caboose' : 'boxcar';
return acceptsCar(tray, sample);
});
if (missing.length === 0) continue;
events.push({
type: 'makeUpShort',
trainNumber: tray.trainNumber,
isExtra: tray.trainIsExtra,
placed: tray.consist.length,
wanted: consistSize(profile.consist),
missing: [...missing],
waiting: s.yards.classificationYard.filter((c) => missing.includes(category(c.type))).length,
divisionYardHolds: s.yards.divisionYard.length,
});
}
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
}
@@ -417,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;
@@ -503,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';
/**
@@ -522,14 +611,17 @@ type MoveOutcome = 'moved' | 'held' | 'needsClearance';
*
* This is reachable purely through switching. A train arrives made up, and only comes apart because
* the player took cars onto the nose or picked up a cut in a run-around.
*
* Exported for the switching planner (`sim/switch-planner.ts`), which has to know whether a plan
* leaves a train unable to run — and must ask this rule rather than keep a copy of it.
*/
function badlyMadeUp(tray: CrewTray): string | null {
export function badlyMadeUp(tray: CrewTray): string | null {
const n = tray.consist.length;
if (n === 0) return null;
const pulling = tray.engineAt === 0;
const pushing = tray.engineAt === n;
if (!pulling && !pushing) {
return `not made up — the engine is buried in the train, ${tray.engineAt} car(s) ahead of it`;
return `not made up — the engine is buried in the train, ${tray.engineAt} car${tray.engineAt === 1 ? '' : 's'} ahead of it`;
}
const caboose = tray.consist.findIndex((c) => c.type === 'caboose');
if (caboose === -1) return null;
@@ -638,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,
@@ -770,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({
@@ -834,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',
@@ -906,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,
@@ -1046,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.
@@ -1064,11 +1166,51 @@ function evaluateClearance(
* constrained. Each Office upgrade to a Control Point splits one in two and buys capacity.
*/
const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex];
/**
* ONLY WHAT IS AHEAD (Gitea#26). §8.1 asks about a train the considered train would FOLLOW, and one
* moving TOWARDS it — both of which are ahead of it. A Subdivision runs the length of every Whistle
* Post between two Control Points, so it can hold a train BEHIND the one departing: in the reported
* game X15 highballed west from an Office while X18, also westbound, was still crossing the card to its
* east. Counting X18 put a meaningless ruling to the Superintendent; holding X15 kept the Whistle Post's
* one A/D track full, and X18 arrived into it and was destroyed. A train behind and moving away is no
* threat at all.
*
* "Behind" is strictly behind the card the departing train stands on. A train on that same card is still
* counted, exactly as before: which of two trains sharing a card is in front is `entryConflict`'s region
* question, and this is not the place to answer it.
*/
const from = nodeIndexOfTray(s, tray);
const behind = (onCard: number): boolean =>
from !== null && from >= 0 && (tray.direction === 'east' ? onCard < from : onCard > from);
const occupants: { tray: TrayId; onCard: number }[] = [];
for (const i of subdivision) {
const n = s.division.nodes[i];
if (!n || n.kind !== 'mainline') continue;
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
if (behind(i)) continue;
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 });
}
}
/**
@@ -1095,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';
}
@@ -1239,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';
@@ -1252,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;
@@ -1423,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);
@@ -1452,6 +1651,7 @@ function arriveAtOffice(
trainNumber: tray.trainNumber ?? 0,
consist: tray.consist.map((c) => ({ ...c })),
office: officeProfile(area.tier).name,
owner: playerAtSeat(s, seat),
expedited: isExpedited(tray),
});
@@ -1623,6 +1823,13 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
events.push({ type: 'actorChanged', player: s.clock.superintendent });
/**
* SAID OUT LOUD, as well as recorded. `actorChanged` is turn bookkeeping and the log discards it,
* so this — the one time in three Stages that it means the Fedora moved — had no line anywhere
* (playtest, 2026-09-16). Emitted alongside rather than instead: `actorChanged` still carries the
* cursor, and anything reading it keeps working.
*/
events.push({ type: 'superintendentChanged', player: s.clock.superintendent, stage: s.clock.stage });
}
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
@@ -1632,28 +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;
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
@@ -1686,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 };
}
+475 -72
View File
@@ -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;
@@ -974,6 +1040,16 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
const seen = new Set(i.order);
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
/**
* The engine may finish anywhere in the train, including with cars ahead of it (Jesse,
* 2026-09-17). `engineAt` indexes the SORTED consist, so `consist.length` is legal and means
* the engine on the tail with everything ahead of it — the shoving case a Small Yard exists to
* set up. Refused outside that range rather than clamped: a clamp would silently build a
* different train from the one the player asked for.
*/
if (i.engineAt !== undefined && (i.engineAt < 0 || i.engineAt > tray.consist.length)) {
return 'CONSIST_ORDER';
}
return null;
}
@@ -1072,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';
@@ -1214,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;
}
@@ -1420,27 +1508,64 @@ 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': {
if (!placement) return 'NO_PLACEMENT';
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
/**
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
* halves.
* NEITHER ON THE RUNNING TRACK ROW NOR OUTSIDE THE LIMITS — Jesse's call, both halves, the
* second REVERSED on 2026-09-17 after a Day 3 playtest.
*
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
* standing at the limit has three of its nine spots out there, and refusing them would make
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
* It used to read the other way: a Modifier is not track (§9), so unlike a siding it could
* hang outside the Limits, because a Facility standing at the limit has three of its nine
* spots out there and refusing them would make the card unplayable exactly where the district
* ends. What that argument missed is what the board then shows — Transmission Lines at (-2,4)
* with the sign at column 3 — which reads as building outside your own territory, and §8.1 and
* §10 both reason about what lies inside a player's Limits.
*
* THE UNPLAYABLE CASE WAS CHECKED ON THE REPORTED MOVE, not assumed away: the Power Plant was
* at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were free, legal and inside. Six
* of the nine spots survive a Facility at the limit, and the sign moves outward as the Running
* Track grows (§2.1, Gap 4a), so the ground arrives with the district.
*
* The Running Track row stays barred for its own reason: inside the Limits that row is always
* full, so it bit only beyond the sign, where a Modifier would block the sign from moving
* outward with nothing on screen to warn you. That ground is now out of bounds anyway, which
* makes this the narrower rule rather than a redundant one — the row is barred INSIDE the
* Limits too, where a square can fall vacant if the main is rebuilt around it.
*/
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
// §2.1 — a district's cards belong inside its own sign, Modifiers included since 2026-09-17.
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
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': {
@@ -1451,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':
@@ -1460,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`.
@@ -1508,17 +1633,48 @@ export function selectDestination(
return chosen ?? atTo[0];
}
/**
* ROUTES, WALKED ONCE PER POSITION.
*
* A route walk (`reachableDestinations`) was a third of all simulation time, and most of it was the
* same walk repeated: `legal.ts` walks a tray's routes to list its moves, then `check` walks them again
* for every one of those moves, and `applyIntent` walks the chosen one a third time in `execute`.
* Profiled 2026-09-14 with inlining off: `reachableDestinations` 34% inclusive, garbage collection 34%.
*
* So while one position is being examined — a legal-action listing, or the check and execute of one
* intent — a walk is kept and reused. Both scopes read the state and never write it, the key names
* everything the walk depends on besides that state, and the cache is keyed to the state OBJECT and
* cleared when the scope ends, so a hit returns exactly what a fresh walk would have. Nothing may
* mutate a returned route; nothing does.
*/
let routeCache: { state: GameState; routes: Map<string, MoveDestination[]> } | null = null;
export function withRouteCache<T>(s: GameState, fn: () => T): T {
if (routeCache) return fn();
routeCache = { state: s, routes: new Map() };
try {
return fn();
} finally {
routeCache = null;
}
}
function destinationsFor(
s: GameState,
player: PlayerIndex,
trayId: TrayId,
from: GridCoord,
reverse: boolean,
) {
): MoveDestination[] {
const cache = routeCache?.state === s ? routeCache.routes : null;
const key = cache ? `${player}|${trayId}|${from.row},${from.col}|${reverse ? 1 : 0}` : '';
const hit = cache?.get(key);
if (hit) return hit;
const tray = s.trays.get(trayId)!;
const facing = facingPort(s, trayId);
const exit: Port = reverse ? reversePort(s, player, from, facing) : facing;
return reachableDestinations(
const routes = reachableDestinations(
{
area: areaOf(s, player),
occupancy: occupancyFor(s, player, trayId),
@@ -1528,6 +1684,8 @@ function destinationsFor(
from,
exit,
);
cache?.set(key, routes);
return routes;
}
/**
@@ -1588,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()] };
}
@@ -1631,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);
@@ -1647,6 +1843,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
from,
to: i.to,
movesRemaining: turnOf(s, player).movesRemaining - 1,
// "3 of 6" was written with the 6 hardcoded in the narrator, which is wrong on a night
// Stage under Reduced Visibility, where a turn gets five. The turn knows; the event carries.
movesAllowed: turnOf(s, player).movesAllowed,
/**
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
*
@@ -1742,15 +1941,45 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
at: here,
before: tray.consist.map((c) => ({ ...c })),
after: i.order.map((n) => ({ ...tray.consist[n]! })),
// Absent means the nose, which is what every sort did before 2026-09-17 — so an older save
// replays to exactly the train it built.
engineAt: i.engineAt ?? 0,
},
];
}
case 'switch.end':
/**
* The closing summary comes BEFORE `phaseEnded`, so the history reads as the turn ending rather
* than as a postscript to it. Split out of the shared case below for that one line.
*/
case 'switch.end': {
const turn = turnOf(s, player);
const ended: GameEvent = {
type: 'switchingEnded',
player,
movesUsed: turn.movesAllowed - turn.movesRemaining,
movesAllowed: turn.movesAllowed,
...(turn.lastMove ? { lastMove: turn.lastMove } : {}),
};
return [ended, { type: 'phaseEnded', player, phase: 'localOps' }];
}
case 'draw.end':
case 'freightAgent.end':
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
case 'freightAgent.end':
/**
* An idle Freight Agent SAYS SO. §6.3 requires no action and choosing to take none is a real
* decision — see `freightAgentIdled`. Without the line the log announced the option and then
* fell silent, which reads as the game having dropped the turn.
*/
return [
...(turnOf(s, player).freightAgentUsed
? []
: [{ type: 'freightAgentIdled', player } as const]),
{ type: 'phaseEnded', player, phase: 'localOps' },
];
case 'draw.fromHomeOffice': {
const events: GameEvent[] = [
{
@@ -1783,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;
}
@@ -1856,7 +2097,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
? REALIGNMENTS.find((r) => r.from === node.card)?.to
: undefined;
return [
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
{
type: 'mainlineModified',
player,
cardId: i.cardId,
node: i.node,
key,
...(node?.kind === 'mainline' ? { from: node.card } : {}),
...(became ? { became } : {}),
},
];
}
@@ -1904,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': {
@@ -1915,21 +2164,52 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
];
}
case 'newTrain.placeCar':
/**
* THE TRAIN'S NUMBER RIDES ALONG (playtest, 2026-09-16: "I did not see anything in the history
* about making up train 10 and how each person added each car to it").
*
* It was all there — a MADE UP line and one line per car — but every one of those lines read
* "the train being made up", so a player scanning the history for train 10 found nothing under
* that name. The tray id is no use to a reader and the narrator has no state to look it up in,
* so the number travels with the event, exactly as `owner` does on `trainArrived`.
*/
case 'newTrain.placeCar': {
const placeTray = s.trays.get(i.trayId);
return [
{
type: 'carPlacedOnTrain',
player,
trayId: i.trayId,
stock: { type: i.carType, loaded: i.loaded },
trainNumber: placeTray?.trainNumber ?? null,
isExtra: placeTray?.trainIsExtra ?? false,
},
];
}
case 'newTrain.passCar':
return [{ type: 'carPassed', player, trayId: i.trayId }];
case 'newTrain.passCar': {
const passTray = s.trays.get(i.trayId);
return [
{
type: 'carPassed',
player,
trayId: i.trayId,
trainNumber: passTray?.trainNumber ?? null,
isExtra: passTray?.trainIsExtra ?? false,
},
];
}
case 'newTrain.secondSection':
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 [
@@ -2100,7 +2380,10 @@ export function reduce(s: GameState, e: GameEvent): void {
}
// Only the player sitting in this district can be switching this tray, so the Moves come off
// their turn. The event carries no player of its own.
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
const mover = turnOf(s, playerAtSeat(s, seat));
mover.movesRemaining = e.movesRemaining;
// Where the crew was left, for the line that closes the turn — see `switchingEnded`.
mover.lastMove = { trayId: e.trayId, to: e.to };
const area = areaAtSeat(s, seat);
@@ -2168,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);
@@ -2178,11 +2461,20 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'consistSorted': {
const tray = s.trays.get(e.trayId)!;
tray.consist = e.after.map((c) => ({ ...c }));
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
tray.engineAt = 0;
/**
* WHERE THE SORT PUT THE ENGINE — 0 on every sort before 2026-09-17, and on most of them
* since, because putting the engine back on the nose is what a Small Yard is usually for:
* §8.2 will not let a train leave the Office with cars in front of its engine.
*
* It is no longer forced. The design source (`implications.md`) has always said a train here
* "may sort itself into any order, INCLUDING cars ahead of the engine", against a v0.4.5 card
* text that says the engine ends at the nose; Jesse settled it for the source. A numbered
* train left nose-loaded is held at the Office by §8.2 until it sorts again — see
* `departureRefusal`.
*/
tray.engineAt = e.engineAt;
// "Spends one move in the yard" — the sort costs a Move.
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;
}
@@ -2229,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;
}
@@ -2242,7 +2534,8 @@ export function reduce(s: GameState, e: GameEvent): void {
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
// turned face up as the Departments, the rest face down as the Home Office deck. The
// Departments start one deep again, exactly as at setup.
s.decks.salvageYard = [];
// The spent trains stay where they are; everything else in the Yard has just been swept up.
s.decks.salvageYard = s.decks.salvageYard.filter((id) => isSpentTimetabledTrain(s, id));
s.decks.departments = [[], [], []];
const order = [...e.order];
for (const pile of s.decks.departments) {
@@ -2428,20 +2721,48 @@ export function reduce(s: GameState, e: GameEvent): void {
const f = facilityAt(s, e.player, e.at)!;
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
if (idx >= 0) f.inboundBox.splice(idx, 1);
// `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts).
s.yards.classificationYard.push(pooled(e.stock));
/**
* THE CAR COMES BACK EMPTY, and until v0.8.1.0 it did not.
*
* The line here said "`pooled` — a car back in a yard is back in the common supply, carrying
* nothing", and `pooled` does no such thing: it strips the `origin` stamp and DELIBERATELY
* keeps `loaded`, because a train can retire at a Division Point with freight still aboard
* (`advance.ts`). So the comment described an intent the call never carried out, and every
* car the Freight Agent cleared went to the Classification Yard still loaded.
*
* That is not cosmetic. `passengersDetrained` needs `type === 'coach' && !loaded` out of the
* Division Yard, and §2.2 refills the Division Yard from Classification — so a coach cleared
* from a red box came back as stock that could never unload another passenger. Measured over
* five three-Day solitaire games before the fix: 18 loaded coaches in the Classification Yard
* against 6 empty, and 9 loaded in the Division Yard against 33 empty.
*
* The red box is where the journey ENDS for what is in it — the Revenue was paid when the
* load arrived or the passengers detrained (`view.ts`, `freightAgent.clearInbound`). Clearing
* it sends the passengers out of the station, or the delivered load into the industry, and
* returns the CAR to the common supply. A car in a yard carrying a load nobody can unload is
* not supply.
*/
s.yards.classificationYard.push({ ...pooled(e.stock), loaded: false });
turnOf(s, e.player).freightAgentUsed = true;
break;
}
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));
@@ -2468,12 +2789,22 @@ 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':
s.timetable[e.slot] = e.trainNumber;
s.rngState = e.rngState;
s.decks.salvageYard.push(`train-${e.trainNumber}`);
/**
* THE CARD IS ALREADY IN THE SALVAGE YARD — `cardPlayed` put it there, by its real id.
*
* This used to push a second, SYNTHETIC `train-<number>` beside it, so scheduling four trains
* left eight entries in a pile holding four cards. Nothing ever read that id: it inflated the
* pile's depth, it displayed as "a card" because no such card exists, and
* `reshuffleIfDepleted` would have swept it into the draw deck to be drawn as an id with
* nothing behind it. Removed 2026-09-10 (Gitea#23).
*/
break;
case 'carPlacedOnTrain': {
@@ -2693,7 +3024,8 @@ export function reduce(s: GameState, e: GameEvent): void {
* index is out of range, which `check` reports rather than silently defaulting — a wrong
* orientation is a different card, not a detail.
*/
function protoCard(
/** Exported for the same reason as `extendLimitsIfNeeded`: the bot builds the card a lay would place exactly as the reducer does. */
export function protoCard(
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
variant: number | undefined,
): TrackCard | null {
@@ -2784,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;
}
@@ -2842,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,
@@ -2913,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);
@@ -2930,9 +3276,46 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
*/
/**
* §6.2, AND THE RULING THAT SETTLES IT — Jesse, 2026-09-10 (Gitea#23).
*
* "Once you've played a regularly scheduled train and it's in the salvage deck, that train is
* already on the timetable. It does not make sense to put that back into a reshuffled home deck to
* get played again. By contrast, a regularly scheduled train that's in a discard pile could
* potentially get reused later, and so should have that capability. Extras run one time and then
* they're done — if they are in the Salvage deck, they should get shuffled back in so that they
* could get run again."
*
* So the test is WHERE the card is, not only what it is. A timetabled train in the SALVAGE YARD was
* played: its number is on the timetable and cannot be scheduled twice, so the card is spent and
* stays out. The same card sitting in a DEPARTMENT was discarded, never played, and its slot is
* still open — so it comes back with everything else. An Extra is a single run rather than a
* standing slot, so a played one is free to be run again.
*/
function isSpentTimetabledTrain(s: GameState, id: CardId): boolean {
return s.cards.get(id)?.kind.kind === 'timetabledTrain';
}
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
if (s.decks.homeOffice.length > taking) return null;
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()];
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().filter((id) => !adjust.exclude.includes(id)),
...adjust.include,
];
if (collected.length === 0) return null;
const rng = createRng(s.rngState);
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
@@ -3059,7 +3442,8 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
*/
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
/** Exported so the bot can score a lay on a copy of the district by the engine's own rule, not a copy of it. */
export function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
if (placed.row !== area.runningRow) return;
if (placed.col <= area.limitsWest.col) {
@@ -3253,16 +3637,35 @@ function limitsCard(): TrackCard {
// Public entry point
// ---------------------------------------------------------------------------
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
const code = check(s, player, i);
if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` };
/**
* THE FIRST HALF OF `applyIntent`: decide, without changing anything.
*
* `check` and `execute` read the same unchanged position, so its routes are walked once between them
* (`withRouteCache`). Never writes `s`. Split out for a caller that decides many intents against ONE
* position and applies each to a COPY of it — the switching planner — which can then share that
* position's routes across every candidate instead of re-walking them on each copy.
*/
export function prepareIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
const prepared = withRouteCache(s, (): { code: RejectionCode } | { events: GameEvent[] } => {
const code = check(s, player, i);
return code ? { code } : { events: execute(s, player, i) };
});
if ('code' in prepared) return { ok: false, code: prepared.code, message: `${i.type} rejected: ${prepared.code}` };
return { ok: true, events: prepared.events };
}
const events = execute(s, player, i);
/** THE SECOND HALF: fold events `prepareIntent` produced into a state equal to the one it read. */
export function commitEvents(s: GameState, events: readonly GameEvent[]): void {
for (const e of events) reduce(s, e);
// Gitea#16 — the intent half of the fold; `advance` does the phase driver's half. See `tally.ts`
// for why it cannot simply live inside `reduce`.
for (const e of events) tallyEvent(s, e);
return { ok: true, events };
}
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
const r = prepareIntent(s, player, i);
if (r.ok) commitEvents(s, r.events);
return r;
}
export { isOperationalRail, destinationsFor };
+131 -10
View File
@@ -289,7 +289,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
* the game with no way to raise the direction it is supposed to use half its capacity on.
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
* `home-deck.md` prints it "Outbound, 1 out / 0 in".
*/
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
@@ -297,7 +297,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
/**
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
* not ship anything out". `home-deck.md` prints it "Inbound, 0 out / 1 in".
*
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
@@ -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
@@ -589,7 +589,20 @@ export type MainlineProfile = {
speedStarts?: { fast: number; slow: number };
/** Double Track: "Trains may pass". */
trainsMayPass: boolean;
/** Interchange: "Sort cars in new order". */
/**
* Interchange only. The card prints "Sort cars in new order" — **and that is not what this flag
* does**, which is why it is worth spelling out where the field is declared.
*
* The printed sorting has never been implemented: nothing reads this to permit a sort, and a
* consist is re-ordered at a Small Yard in a district (`switch.sortConsist`). What this actually
* marks is the one Mainline card with a Yard Limit, and therefore the one an Extra may be made up
* and started on (`apply.ts` § resolveExtraStart, `legal.ts`).
*
* Named for the printed text, and kept that way deliberately — renaming it would lose the link to
* the card face — but the name has already misled once: `mainlineDescription` grew a sentence
* telling players cars could be sorted here, which reached the board and the generated card
* reference before it was caught on 2026-09-20.
*/
sortsCars: boolean;
/** Named entry points printed on the card; some are unlocked by modifier cards. */
entryPoints: readonly string[];
@@ -655,7 +668,7 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
* used it as one: `buildDivision` drew uniformly from those types with replacement, which made two
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
* inventory, transcribed in `docs/StationMaster-Mainline-Deck-v0.4.5.md`, which had already flagged
* inventory, transcribed in `docs/mainline-deck.md`, which had already flagged
* the mismatch as needing correction.
*
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
@@ -772,7 +785,17 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
`${stages(run({}))}.`,
);
} else {
parts.push(`${stages(run({}))} for every train — the printed speed is scenery.`);
/**
* NOT A WORD ABOUT SPEED HERE — Jesse, playtest 2026-09-16.
*
* This read "the printed speed is scenery", which sent a player hunting the card for a number
* that is not drawn on it. The first rewrite said "fast or slow alike", which is true but raises
* the question on thirteen cards in order to answer it. **Exactly one card reads FAST/SLOW**:
* Hilly, the only profile with `speedStarts` (see the note above it). So the explanation belongs
* on that card, where the branch above already gives it, and everywhere else says nothing —
* silence is the honest answer when the rating genuinely does not apply.
*/
parts.push(`${stages(run({}))} for every train.`);
}
if (kind === 'uncontrolledSiding') {
@@ -795,7 +818,25 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
parts.push('One train at a time — anything following has to wait for it to clear.');
}
if (p.sortsCars) parts.push('Cars may be sorted into any new order here.');
/**
* WHAT THE INTERCHANGE ACTUALLY DOES, which is not what it prints.
*
* This said "Cars may be sorted into any new order here." — the printed capability, shown to
* players on the board (`view.ts` renders this as a Mainline card's `what`) and printed in the
* generated card reference. It is not implemented and never has been: nothing reads `sortsCars`
* to permit a sort. Its one live use is identifying the card an Extra may be made up on, because
* the Interchange is the Mainline card with a yard (`apply.ts` § resolveExtraStart).
*
* Found while bringing the reference documents up to date, 2026-09-20. A card that advertises an
* action the game will not offer is worse than one that says nothing — a player goes looking for
* a button that does not exist and concludes the game is broken.
*/
if (p.sortsCars) {
parts.push(
'This is the one Mainline card with a yard, so an Extra Train may be made up and started here. ' +
'Its printed car-sorting is NOT implemented — a consist is re-ordered at a Small Yard in a district.',
);
}
return parts.join(' · ');
}
@@ -967,14 +1008,25 @@ export function enhancementRule(key: string): EnhancementRule | null {
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
}
/**
* THE NAMES HERE ARE WHAT A PLAYER READS, since v0.8.0.17 — `cardName` takes them in preference to
* `prettyKey`'s guess, which cannot recover an acronym (it rendered `absSignals` as "Abs Signals"
* beside a tooltip saying ABS).
*
* So their CASE matters, and three were transcribed in sentence case while the rules, the panels
* and every other line of this repository treat them as proper terms: "Yard Office" outnumbered
* "Yard office" 36 to 2, "Small Yard" 36 to 2, "Water Column" 10 to 2. Corrected here rather than
* special-cased in `cardName`, because this table is the source and a lookup that second-guesses
* its own source is the drift it exists to prevent.
*/
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
{ key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an
// Event held out until built, so at zero it defends against nothing that can be dealt anyway.
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
{ key: 'waterColumn', name: 'Water column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
{ key: 'yardOffice', name: 'Yard Office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
{ key: 'smallYard', name: 'Small Yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
{ key: 'waterColumn', name: 'Water Column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
/**
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
@@ -1172,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.)
*
@@ -1192,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. */
@@ -1215,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,
};
/**
@@ -1234,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. */
@@ -1254,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 },
};
}
@@ -1351,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 },
];
}
+116 -8
View File
@@ -28,6 +28,15 @@ export type GameEvent =
| { type: 'stageBegan'; day: number; stage: number }
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
/**
* §5 — the Fedora passed, at the end of Stage 3, 6, 9 or 12.
*
* ITS OWN EVENT RATHER THAN THE `actorChanged` THIS USED TO RIDE ON. That one is turn bookkeeping,
* fired every time the cursor moves, and `record()` drops it on the floor as noise — so the one
* moment it carried that a player actually needed to see went past in silence. Reported from the
* table (2026-09-16): the Supervisor Shift appears in the history and the handover never does.
*/
| { type: 'superintendentChanged'; player: PlayerIndex; stage: number }
| { type: 'phaseBegan'; phase: string }
| { type: 'actorChanged'; player: PlayerIndex | null }
// -- local operations
@@ -37,7 +46,7 @@ export type GameEvent =
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
* choice the player made invisible in their own log.
*/
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; movesAllowed: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
| {
type: 'carsCoupled';
player: PlayerIndex;
@@ -73,7 +82,66 @@ export type GameEvent =
recoupled?: { at: GridCoord; stock: RollingStock[] };
}
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
| {
type: 'consistSorted';
player: PlayerIndex;
trayId: TrayId;
at: GridCoord;
before: RollingStock[];
after: RollingStock[];
/**
* Where the engine ends up in `after`, counted as an index into it — 0 is the nose.
*
* The Small Yard used to put the engine back on the front unconditionally, which is what the
* v0.4.5 card text says ("reorder its entire consist and put the engine at the nose").
* `implications.md` records the design source saying the opposite — "may sort itself into any
* order, INCLUDING cars ahead of the engine" — and Jesse settled it that way on 2026-09-17.
*/
engineAt: number;
}
/**
* A player finished their switching turn: what it cost, and where the crew was left.
*
* The history panel keeps a switching turn's FIRST move and drops the ones in the middle, so the
* closing line is where "and it ended up here" has to come from. It cannot be recovered by
* revealing the last `trayMoved` after the fact: the log streams to clients as it is written
* (`server/session.ts` § linesSince), and nobody knows a move was the last one until the turn is
* already over and that line has been sent.
*/
| {
type: 'switchingEnded';
player: PlayerIndex;
movesUsed: number;
movesAllowed: number;
lastMove?: { trayId: TrayId; to: GridCoord };
}
/**
* THE FREIGHT AGENT WAS CHOSEN AND DID NOTHING, which is a decision rather than an absence.
*
* §6.3 requires no action, and the bot deliberately takes that route — unjamming a healthy box
* destroys a load that cost a whole Local Operations action to stock, so an idle Stage is
* strictly better. Reported from a table on Day 1 Stage 3 of v0.8.0.16: the log announced
* FREIGHT AGENT work and then said nothing at all, so the turn read as a bug.
*
* Reduces to nothing, like `switchingEnded` above: it reports a choice the state already holds.
*/
| { type: 'freightAgentIdled'; player: PlayerIndex }
/**
* 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
@@ -87,7 +155,13 @@ export type GameEvent =
| { type: 'deckReshuffled'; order: CardId[]; rngState: number }
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
/**
* `from` is the card's kind BEFORE the change, carried so the log can say what was realigned
* rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline
* card 3 curves was converted to plains"). Events are derived by replaying a save, never stored,
* so widening one strands nothing on disk.
*/
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string }
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
@@ -108,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
@@ -127,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. */
@@ -153,7 +227,13 @@ export type GameEvent =
* switched normally like any other arrival, but it has to be back on the Office square before the
* next Mainline Phase begins, or `expediteFault` fires.
*/
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
/**
* `owner` is WHOSE Office it reached — the district's player, not whoever is acting. The Mainline
* Phase has no actor, so nothing else in the line could name the seat, and the narration said only
* "ARRIVED at the Whistle Post" — every seat's Office has a tier, and at a four-seat table three of
* them are somebody else's (Jesse, playtest 2026-09-16).
*/
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; owner: PlayerIndex; expedited: boolean }
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
/**
* The train ran the length of the Division and left it. `side` is the Division Point it left by,
@@ -174,8 +254,36 @@ export type GameEvent =
at: ExtraStart;
direction: Direction;
}
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock; trainNumber: number | null; isExtra: boolean }
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId; trainNumber: number | null; isExtra: boolean }
/**
* A train was made up and the round could give it NOTHING THE CARD CALLS FOR — the Division Yard
* holds no car of a category it still wants (§7, §8.2 "may depart with fewer").
*
* ITS OWN EVENT BECAUSE THE SILENCE WAS THE BUG (playtest, 2026-09-16: "train 5, the sparrow, has
* no coaches, which seems strange"). `trainNeedingCars` returns null in exactly this case, so the
* phase never stops, nobody is asked for a car, and the only trace was a MADE UP line promising
* "now taking cars" with nothing after it. The train then ran the whole Division empty.
*
* CARRIES WHY, not just that. The shortage is a standing condition rather than a moment — §2.2
* returns the Classification Yard only when the Division Yard runs bare — so the counts that
* explain it have to travel with the event: what is still wanted, how many such cars are waiting
* in Classification, and how far the Division Yard is from empty.
*/
| {
type: 'makeUpShort';
trainNumber: number;
isExtra: boolean;
/** How many cars it got, out of what the card calls for. */
placed: number;
wanted: number;
/** The categories the card still wants and the Division Yard cannot supply. */
missing: ('freight' | 'coach' | 'caboose')[];
/** Cars of those categories sitting in the Classification Yard. */
waiting: number;
/** §2.2 — Classification comes back only when this reaches zero. */
divisionYardHolds: number;
}
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
+19 -2
View File
@@ -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';
// ---------------------------------------------------------------------------
@@ -47,7 +47,19 @@ export type Intent =
* order, including cars in front of the engine". This is the designed answer to §A.3's
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
*/
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
/**
* §Enhancements, Small Yard — one Move to re-make a train standing on the yard.
*
* `order` is a permutation of the current consist, nose first. `engineAt` is where the LOCOMOTIVE
* ends up in it: 0 puts it back on the front, which is what the v0.4.5 card text describes and
* what this action did unconditionally until 2026-09-17. `implications.md` records the design
* source saying a train here "may sort itself into any order, including cars ahead of the engine",
* and Jesse ruled that way — so it is a number now, and a train left nose-loaded is one §8.2 will
* not let out of the Office until it is sorted again.
*
* Optional, defaulting to 0, so every save written before this replays exactly as it did.
*/
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[]; engineAt?: number }
| { type: 'switch.end' }
// -- draw (§6.2)
| { type: 'draw.fromHomeOffice' }
@@ -301,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'
+151 -63
View File
@@ -12,9 +12,9 @@
* 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 } from './apply.ts';
import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
import { coordKey, seatOf } from './state.ts';
@@ -53,13 +53,145 @@ const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 't
/** Every intent `player` may legally submit right now. */
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
return candidates(s, player).filter((i) => check(s, player, i) === null);
// One position, examined many times over: its routes are walked once (`withRouteCache`).
return withRouteCache(s, () => candidates(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;
/**
* §6.1 — the switching half of the Local Operations candidates, in the order `legalActions` offers
* them. Split out so the switching planner (`sim/switch-planner.ts`) can ask for just these without
* `check` running over every draw and Freight Agent candidate at each of the thousands of positions it
* tries — that was about a quarter of all planning time. Still no rules here: `check` decides.
*/
function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
const out: Intent[] = [];
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
const dests = destinationsFor(s, player, trayId, from, reverse);
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
// tell apart, `to` already does that.
const byCoord = new Map<string, MoveDestination[]>();
for (const d of dests) {
const k = coordKey(d.coord);
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
}
for (const group of byCoord.values()) {
for (const d of group) {
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
}
}
}
for (let n = 1; n <= tray.consist.length; n++) {
out.push({ type: 'switch.dropCars', trayId, count: n });
// Off the nose as well as the tail — the only way to get cars back off the front of a train
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
}
/**
* Small Yard: enumerating every permutation would explode, so offer the useful ones — bringing
* each car to the droppable end, plus a full reversal. `check` validates any order, so a UI may
* submit an arbitrary permutation.
*
* NOTHING THAT RE-ORDERS NOTHING. Bringing the LAST car to the end is the identity, and a
* two-car train's reversal repeats its only real option — so the menu carried a move that spent
* one of six Moves to leave the train exactly as it was, beside a duplicate of the move next to
* it. Both were invisible while the labels were index lists (playtest, 2026-09-17); both are
* plainly wrong once the label reads as a train. Filtered by the ORDER rather than by the case
* that produced it, so a new generator cannot reintroduce either.
*/
const n = tray.consist.length;
const identity = [...Array(n).keys()];
if (n > 1) {
const orders: number[][] = [];
for (let k = 0; k < n; k++) {
const order = identity.filter((x) => x !== k);
order.push(k);
orders.push(order);
}
orders.push([...identity].reverse());
// Nothing that re-orders nothing: the current train is the one thing on offer that costs a
// Move and changes the board not at all. Keyed by (order, engine position) together, since
// since 2026-09-17 the same car order at a different engine position is a different train.
const seen = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
const offer = (order: number[], engineAt: number): void => {
const key = `${order.join(',')}|${engineAt}`;
if (seen.has(key)) return;
seen.add(key);
out.push({ type: 'switch.sortConsist', trayId, order, engineAt });
};
// The car orders, each leaving the engine on the nose — the Small Yard's ordinary use.
for (const order of orders) offer(order, 0);
/**
* A MADE-UP ORDER IS ALWAYS AMONG THESE, which is worth saying because it looks as though it
* might not be (Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the
* back").
*
* A yard sort serves two errands — pulling one car out to an end so it can be spotted, and
* putting the train back together to leave — and the orders above are written for the first.
* They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
* so bringing the CABOOSE to the tail is always one of them, and with the engine on the nose
* that is a train §8.2 will let out of the Office.
*
* An explicit "make it up to leave" option was written here and deleted: it produced exactly
* the k-is-the-caboose order and was dropped by the dedupe every time. The one case where no
* made-up order appears is a train that is ALREADY made up, where such an option would be the
* identity — and the labels say which is which, so a player can see that every offer would
* break a train that is currently fit to run.
*/
}
/**
* WHERE THE ENGINE GOES, as its own short list rather than multiplied through the one above
* (Jesse's call, 2026-09-17: "a separate engine control").
*
* Offering every car order at every engine position is the honest enumeration and it is
* unreadable: a four-car consist would go from four options to twenty, which is the labelling
* problem that prompted all of this. So the engine positions are offered against the consist AS
* IT STANDS — pick an order, or pick where the engine sits, each one Move. A player who wants
* both spends two, which is the same price the yard charges for any second sort.
*
* OFFERED FOR A ONE-CAR TRAIN TOO, unlike the car orders: a single car ahead of the engine or
* behind it is exactly the difference between shoving it into a facing industry and pulling it.
*/
if (n >= 1) {
const seenEngine = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
for (let k = 0; k <= n; k++) {
const key = `${identity.join(',')}|${k}`;
if (seenEngine.has(key)) continue;
seenEngine.add(key);
out.push({ type: 'switch.sortConsist', trayId, order: identity, engineAt: k });
}
}
}
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
for (const cardId of s.decks.hands.get(player) ?? []) {
const k = s.cards.get(cardId)?.kind;
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
for (let count = 1; count <= tray.consist.length; count++) {
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
}
}
}
}
}
out.push({ type: 'switch.end' });
return out;
}
/** The switching intents `player` may legally submit right now — exactly `legalActions`' switching subset. */
export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent[] {
return withRouteCache(s, () => switchCandidates(s, player).filter((i) => check(s, player, i) === null));
}
/**
* Candidate generation. Over-generates freely — `check` is the authority, so a candidate that
* turns out to be illegal simply gets filtered. Being generous here is what stops this module
@@ -138,62 +270,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
const area = areaOf(s, player);
// -- switch (§6.1)
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
const dests = destinationsFor(s, player, trayId, from, reverse);
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
// tell apart, `to` already does that.
const byCoord = new Map<string, MoveDestination[]>();
for (const d of dests) {
const k = coordKey(d.coord);
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
}
for (const group of byCoord.values()) {
for (const d of group) {
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
}
}
}
for (let n = 1; n <= tray.consist.length; n++) {
out.push({ type: 'switch.dropCars', trayId, count: n });
// Off the nose as well as the tail — the only way to get cars back off the front of a train
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
}
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
// so a UI may submit an arbitrary permutation.
const n = tray.consist.length;
if (n > 1) {
for (let k = 0; k < n; k++) {
const order = [...Array(n).keys()].filter((x) => x !== k);
order.push(k);
out.push({ type: 'switch.sortConsist', trayId, order });
}
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
}
}
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
for (const cardId of s.decks.hands.get(player) ?? []) {
const k = s.cards.get(cardId)?.kind;
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
for (let count = 1; count <= tray.consist.length; count++) {
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
}
}
}
}
}
out.push({ type: 'switch.end' });
out.push(...switchCandidates(s, player));
// -- draw (§6.2)
out.push({ type: 'draw.fromHomeOffice' });
@@ -233,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
@@ -295,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`
-6
View File
@@ -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);
}
+40 -8
View File
@@ -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,
@@ -230,7 +258,7 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
* `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so
* a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the
* deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory
* (`docs/StationMaster-Mainline-Deck-v0.4.5.md`, which flagged this as needing correction), and the
* (`docs/mainline-deck.md`, which flagged this as needing correction), and the
* deal is now a deal: take cards out of it and do not put them back.
*
* The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is
@@ -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);
@@ -431,6 +462,7 @@ export function createGame(opts: SetupOptions): GameState {
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(),
collisionsToday: 0,
collisionsPrevDay: 0,
collisionsTotal: 0,
status: 'active',
outcome: null,
+36
View File
@@ -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. */
@@ -875,6 +877,25 @@ export type FinalReport = {
export type TurnState = {
option: 'switch' | 'draw' | 'freightAgent' | null;
movesRemaining: number;
/**
* What `movesRemaining` started at this Stage — six, or five under Reduced Visibility at night.
*
* CARRIED RATHER THAN ASSUMED. Every reader of `movesRemaining` that wanted to say "3 of 6" had
* hardcoded the 6, which is simply wrong on a night Stage, and the only other way to recover it is
* to re-derive `movesForStage` outside the phase driver that owns it. It also makes "is this the
* FIRST move of the turn?" a comparison rather than a guess, which is what the history panel needs
* to keep the opening move of a switching turn and drop the ones in the middle.
*/
movesAllowed: number;
/**
* The last square this player's crew moved to this Stage, and which crew it was.
*
* Switching ends with a summary line, and "where did the train end up" is the half of it a player
* actually wants. It cannot be recovered from the log: the line naming the last move is written
* before anyone knows it was the last, and the log streams to clients as it is written, so a line
* already sent cannot be revised afterwards.
*/
lastMove?: { trayId: TrayId; to: GridCoord };
drawnThisTurn: boolean;
freightAgentUsed: boolean;
/**
@@ -1057,6 +1078,7 @@ export function freshTurn(moves: number): TurnState {
return {
option: null,
movesRemaining: moves,
movesAllowed: moves,
drawnThisTurn: false,
freightAgentUsed: false,
freightWorked: {},
@@ -1119,6 +1141,20 @@ export type GameState = {
movedThisPhase: Set<TrayId>;
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
collisionsToday: number;
/**
* What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately
* before the reset.
*
* The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER
* the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no
* matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it —
* "it shows a total of two collisions, but zero today ... that does seem to be a contradiction".
*
* NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in
* multiplayer the push that reports the new Day is the same push that reports the reset — a client
* may never see the ended Day's final count to remember it.
*/
collisionsPrevDay: number;
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
collisionsTotal: number;
/**
+60 -18
View File
@@ -377,7 +377,7 @@ export function reachableDestinations(
start: GridCoord,
initialExit: Port,
): MoveDestination[] {
return exploreMoves(ctx, start, initialExit).destinations;
return exploreMoves(ctx, start, initialExit, false).destinations;
}
/**
@@ -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 };
/**
@@ -434,12 +446,19 @@ export function exploreMoves(
ctx: MoveContext,
start: GridCoord,
initialExit: Port,
/**
* False when only the destinations are wanted (`reachableDestinations`, every legality check): the
* rejections are then not recorded at all. They never change a destination, and building them was
* pure allocation on the hottest path in the engine.
*/
collectBlocks = true,
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
const { area, occupancy } = ctx;
const results: MoveDestination[] = [];
const blocked: MoveBlock[] = [];
const noted = new Set<string>();
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
if (!collectBlocks) return;
const k = coordKey(coord);
if (noted.has(k)) return;
noted.add(k);
@@ -459,10 +478,25 @@ export function exploreMoves(
couples: RollingStock[];
/** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */
origins: string[];
/** Cards visited on THIS route, start included. A per-path set, not a global one — see the
* module doc comment on `MAX_ENUMERATED_FRONTIER` for why a global one would forbid the very
* routes this walk exists to find. */
visited: Set<string>;
};
/**
* Has THIS route already used `to`? Per-path, not global — see the doc comment on
* `MAX_ENUMERATED_FRONTIER` for why a global set would forbid the very routes this walk exists to
* find.
*
* Read off the route's own `path` instead of a Set copied at every step, which was a large share of
* the walk's garbage. It answers exactly as that Set did: the start square, then every square
* enqueued along the route AFTER the first hop, including this node's own — the first hop's square
* was never added, and `path[0]` is that square, so the scan begins at 1.
*/
const onRoute = (node: Frontier, to: GridCoord): boolean => {
if (sameCoord(to, start)) return true;
if (node.path.length > 0 && sameCoord(to, node.coord)) return true;
for (let k = 1; k < node.path.length; k++) {
if (sameCoord(node.path[k]!.coord, to)) return true;
}
return false;
};
// The very first hop is checked here because `start`'s card is not itself enqueued; every later
@@ -493,13 +527,14 @@ export function exploreMoves(
path: [],
couples: ownCut,
origins: ownCut.map(() => startKey),
visited: new Set([startKey]),
},
];
let enumerated = 1;
while (queue.length > 0) {
const node = queue.shift()!;
// FIFO by index rather than `shift()`, which re-packs the array on every pop. Same order.
let head = 0;
while (head < queue.length) {
const node = queue[head++]!;
const card = cardAt(area, node.coord);
if (!card) continue;
@@ -592,17 +627,15 @@ export function exploreMoves(
// direction; it never says without repeating ground, but a train cannot occupy the same
// track twice at once either). Per-path, not global — a DIFFERENT route may legitimately
// pass through a card this one already used.
const toKey = coordKey(to);
if (node.visited.has(toKey)) continue;
if (onRoute(node, to)) continue;
if (enumerated >= MAX_ENUMERATED_FRONTIER) break;
enumerated++;
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
const visited = new Set(node.visited);
visited.add(toKey);
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins, visited });
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins });
}
}
if (!collectBlocks) return { destinations: results, blocked };
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
// from a bad angle first and a good one later.
const reached = new Set(results.map((r) => coordKey(r.coord)));
@@ -663,10 +696,19 @@ export function carriesThroughTrack(card: TrackCard): boolean {
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
* neighbourhood, are usable from the first Stage.
*
* MODIFIERS ARE NOT SUBJECT TO THIS, and are not track: §9 places one on any of the nine spots
* around a Facility, and a Facility standing at the limit has three of its nine outside them.
* Jesse's call. `check` bars them from the Running Track ROW instead, which is the ground the main
* grows onto.
* MODIFIERS ARE SUBJECT TO THIS TOO, since 2026-09-17 — REVERSING an earlier call of Jesse's that
* exempted them. The exemption reasoned that §9 places a Modifier on any of the nine spots around a
* Facility, so a Facility standing at the limit has three of its nine outside them and bounding the
* card would make it unplayable exactly where a district ends. Play showed the cost of that the
* other way round: a Transmission Lines card went down at (-2,4) with the sign at column 3, which
* reads at the table as building outside your own territory, and §8.1 and §10 both reason about
* what is inside a player's Limits.
*
* THE FEARED CASE DID NOT ARISE, and was measured on the move that prompted the change rather than
* argued: the Power Plant sat at (-1,3) against a sign at 3, and (-2,2) and (-2,3) were both free,
* legal and inside. A Facility at the limit keeps six of its nine spots, and the Limits move outward
* as the Running Track grows (§2.1, Gap 4a), so the ground for a Modifier arrives with the district.
* `check` bars them from the Running Track ROW as well, which is the ground the main grows onto.
*/
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
+83
View File
@@ -0,0 +1,83 @@
/**
* SEAT RECOVERY CODES — Gitea#33.
*
* A session token is the only identity the game has (`lobby-and-sessions.md` §1) and it lives in
* exactly one place the player controls: their browser's `localStorage`, scoped to the origin they
* joined at. Lose that — a different browser, a cleared profile, a private window — and the seat is
* unreachable, because there is nothing else on the server that will accept a claim to it. Seen at a
* real table on 2026-09-16: the joining player came back to an empty lobby while their token sat
* intact in `sessions.json`, and the only way in was an administrator reading the file off the data
* volume and the player pasting it into a devtools console.
*
* THE CODE IS NOT THE TOKEN, AND THAT IS THE WHOLE POINT. §1 says to keep the token out of URLs so it
* is not shoulder-surfed or pasted into a chat — and a recovery link is exactly the kind of thing
* that gets pasted into a chat. So an administrator mints a SHORT-LIVED, SINGLE-USE code, the player
* opens a link carrying that, and the page trades it for the real token over the same connection it
* would have used anyway. A code that leaks after it is spent is worth nothing; a token that leaks is
* worth the seat for the rest of the game.
*
* PURE ON PURPOSE, like `lobby.ts` beside it: no sockets, no filesystem, no clock of its own. `now`
* is passed in so expiry is testable without faking timers, which is the only reason this file can be
* tested at all — nothing in this repo stands an HTTP server up to make requests against it.
*
* IN MEMORY, NOT ON DISK, which is a deliberate limit rather than an oversight. A restart drops every
* outstanding code, and that is the right failure: the codes are minted on demand and spent within
* minutes, the administrator is by definition present, and persisting them would put a credential-
* equivalent on the volume to solve a problem measured in seconds.
*/
import { randomUUID } from 'node:crypto';
/**
* Long enough to walk to the other room and read it out; short enough that a link left in a chat
* window is useless by the time anyone scrolls back to it.
*/
export const CLAIM_TTL_MS = 30 * 60 * 1000;
export type ClaimStore = {
/** Mint a code for one seat's token. Returns the code and when it stops working. */
mint(token: string, gameId: string, now: number, ttlMs?: number): { code: string; expiresAt: number };
/**
* Spend a code. Returns the seat it names, or null when the code is unknown, already spent or
* expired — deliberately one answer for all three, so a caller cannot probe which it was.
*/
redeem(code: string, now: number): { token: string; gameId: string } | null;
/** Outstanding, unexpired codes. For tests and for anything that wants to report the store's size. */
outstanding(now: number): number;
};
export function createClaimStore(): ClaimStore {
const claims = new Map<string, { token: string; gameId: string; expiresAt: number }>();
/** Expiry is lazy: there is no timer to own, start, stop or leak across a server's lifetime. */
const prune = (now: number): void => {
for (const [code, claim] of claims) if (claim.expiresAt <= now) claims.delete(code);
};
return {
mint(token, gameId, now, ttlMs = CLAIM_TTL_MS) {
prune(now);
// The same primitive the session tokens themselves use (`lobby.ts`), for the same reason: it
// has to be unguessable, and inventing a second scheme here would be inventing a weaker one.
const code = randomUUID();
const expiresAt = now + ttlMs;
claims.set(code, { token, gameId, expiresAt });
return { code, expiresAt };
},
redeem(code, now) {
prune(now);
const claim = claims.get(code);
if (!claim) return null;
// SINGLE USE. Deleted before the caller can do anything with it, so two browsers racing on the
// same link cannot both be seated — and a link that stays in someone's history is spent.
claims.delete(code);
return { token: claim.token, gameId: claim.gameId };
},
outstanding(now) {
prune(now);
return claims.size;
},
};
}
+262 -28
View File
@@ -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';
@@ -37,6 +37,7 @@ import {
writeLobby,
writeSessions,
} from './persistence.ts';
import { createClaimStore } from './claims.ts';
import { createSession } from './session.ts';
import type { GameSession, Push } from './session.ts';
import {
@@ -84,18 +85,69 @@ const MIME: Record<string, string> = {
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
/**
* The Quickstart guide, published by `build-web.ts` as `quickstart.md`.
*
* text/plain ON PURPOSE. The fallback below is `application/octet-stream`, which makes a browser
* DOWNLOAD the file instead of showing it — so without this line the splash page's "read the
* guide" link hands a tester a file to save rather than a page to read.
*/
'.md': 'text/plain; charset=utf-8',
};
const HEARTBEAT_MS = 20_000;
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);
@@ -140,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');
@@ -166,10 +222,20 @@ 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;
/**
* Outstanding seat recovery codes — Gitea#33, `claims.ts`.
*
* In memory and not on the volume, deliberately: a code is minted on demand and spent within
* minutes with the administrator standing right there, so a restart dropping them all is the right
* failure. Persisting them would put a credential-equivalent on disk to solve a problem measured
* in seconds.
*/
const claims = createClaimStore();
const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
@@ -265,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) => {
@@ -322,6 +409,17 @@ export function startServer(opts: ServerOptions): void {
gameId,
gameCode: codes.get(gameId) ?? null,
state: 'running' as const,
/**
* WHICH SEATS A PERSON IS SITTING IN — Gitea#33.
*
* `playerNames` cannot answer it: a bot's name is just a name, and telling the two apart
* by matching "Bot 1" would be guessing at a label. `sessions` holds humans and only
* humans, so this is the fact rather than an inference — and it is what lets the seat
* recovery action offer real players instead of chairs no token was ever issued for.
*/
seatedPlayers: [...sessions.values()]
.filter((s) => s.gameId === gameId)
.map((s) => s.player),
...g.summary(),
}));
// A lobby has no game to summarize yet — it is reported as what it is, so an
@@ -340,14 +438,48 @@ export function startServer(opts: ServerOptions): void {
return;
}
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname);
const match = /^\/api\/games\/([^/]+)(\/save|\/claim)?$/.exec(url.pathname);
const gameId = match?.[1];
// Compared explicitly rather than tested for truthiness: with two suffixes in the group, a
// bare `match?.[2]` would let a GET on `/claim` fall into the `/save` branch below.
const suffix = match?.[2];
if (!gameId) {
sendJson(res, 404, { error: 'no such route' });
return;
}
if (match?.[2] && req.method === 'GET') {
/**
* MINT A SEAT RECOVERY CODE FOR ONE PLAYER — Gitea#33.
*
* The token is the only identity this game has and it lives in one browser's `localStorage`;
* lose it and the seat is unreachable, because nothing else here will accept a claim to it.
* This is the supported way back, and it is administrative on purpose: whoever runs the
* server decides that a particular player has lost their seat, which is a judgement no
* automated route can make safely.
*
* IT HANDS BACK A CODE, NOT THE TOKEN. §1 says keep the token out of URLs, and the code is
* going into one. Short-lived and single-use (`claims.ts`), so a link left in a chat window
* is worth nothing by the time anyone finds it.
*/
if (suffix === '/claim' && req.method === 'POST') {
const body = (await readJson(req)) as { player?: number };
const ps = [...sessions.values()].find((s) => s.gameId === gameId && s.player === body.player);
if (!ps) {
sendJson(res, 404, { error: 'no such seat' });
return;
}
const { code, expiresAt } = claims.mint(ps.token, gameId, Date.now());
sendJson(res, 200, {
code,
expiresAt,
player: ps.player,
displayName: ps.displayName,
gameCode: codes.get(gameId) ?? null,
});
return;
}
if (suffix === '/save' && req.method === 'GET') {
const session = games.get(gameId);
if (!session) {
sendJson(res, 404, { error: 'no such game' });
@@ -508,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,
@@ -516,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.
@@ -523,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 });
@@ -627,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;
@@ -656,6 +808,70 @@ export function startServer(opts: ServerOptions): void {
return;
}
/**
* SPEND A SEAT RECOVERY CODE — Gitea#33, the other half of `/api/games/<id>/claim`.
*
* NOT GATED BY THE ADMIN SECRET, and it must not be: the player following the link is the one
* person in this story who holds no secret at all. The code IS the authorisation — unguessable,
* single-use and short-lived — which is the same shape as the session token it hands back, and
* why minting one is the administrative act rather than spending one.
*
* The token travels in the response BODY of a POST, never in a URL (`lobby-and-sessions.md`
* §1). One answer for unknown, spent and expired codes, so this cannot be used to probe which.
*/
if (url.pathname === '/api/claim' && req.method === 'POST') {
const body = (await readJson(req)) as { code?: string };
const claimed = typeof body.code === 'string' ? claims.redeem(body.code, Date.now()) : null;
const ps = claimed ? sessions.get(claimed.token) : undefined;
const live = ps ? games.get(ps.gameId) : undefined;
if (!claimed || !ps || !live) {
sendJson(res, 404, { error: 'no such claim' });
return;
}
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
sendJson(res, 200, {
token: ps.token,
gameId: ps.gameId,
player: ps.player,
gameCode: codes.get(ps.gameId) ?? '',
});
return;
}
/**
* THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and
* just save it as a JSON file in my Downloads folder").
*
* The administrative export at `/api/games/<id>/save` is gated on the admin secret, which a player
* does not have and should not need: a save is the seed and the moves, and every one of those moves
* is already on this player's screen. So the seat's own session token is the gate, exactly as it is
* for `/api/stream` and `/api/intent` — it proves which game and which chair. The page turns the
* JSON into a file (`main.ts`'s `downloadSave`).
*
* NOT WHILE A COMPETITIVE GAME IS RUNNING (Jesse's ruling, 2026-09-29, TODO #117). "Every one of
* those moves is already on this player's screen" was true of the moves and false of the SEED,
* which a save has to carry to be a save — and the seed is every rival's hand and the deck order
* for the rest of the game, the exact thing `game.ts` strips from the shared log. So a
* Competitive seat gets its save once the game is over; a Co-op table has nothing to hide from
* itself and gets it at any time, as does a one-seat game.
*/
if (url.pathname === '/api/save' && req.method === 'GET') {
const ps = sessions.get(url.searchParams.get('token') ?? '');
const session = ps ? games.get(ps.gameId) : undefined;
if (!ps || !session) {
sendJson(res, 404, { error: 'no such game' });
return;
}
const save = session.exportSave();
const competitive = save.config.mode === 'competitive' && save.playerNames.length > 1;
if (competitive && save.status !== 'finished') {
sendJson(res, 403, { error: 'SAVE_AFTER_FINISH' });
return;
}
sendJson(res, 200, { gameId: ps.gameId, save });
return;
}
if (url.pathname === '/api/stream' && req.method === 'GET') {
const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token);
@@ -706,31 +922,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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+152 -17
View File
@@ -42,6 +42,12 @@ export type DivisionRoster = {
actor: number | null;
/** The player this map is being drawn for. */
viewer: number;
/**
* Division nodes to flash — a Mainline card that has just become a different card (Realignment).
* Playtest, 2026-09-15: the log said a card had been converted and the map said nothing, so the one
* play that changes the Division itself was invisible on the map of it.
*/
flash?: readonly number[];
};
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
@@ -65,6 +71,22 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
* anything outside its own body.
*/
/**
* The Mainline modifiers, as the three letters drawn on a card.
*
* DECLARED INSIDE THIS FUNCTION, like every constant around it, because `sim/replay.ts` emits
* `divisionSvg.toString()` into the replay page — a module-scope const it closed over would be
* undefined there, and the page threw exactly that way before this was moved.
*
* Keyed by the name the VIEW builds (`prettyKey`'d in `view.ts`), because that is what arrives
* here — not the engine's key, which this file never sees. A modifier with no tag draws nothing
* rather than a raw key: `flatMap` over a missing entry yields none, so a card added upstream is
* silently unmarked instead of printing "rotaryDumps" at a player.
*
* ABS Signals is deliberately absent: it has a signal mast of its own.
*/
const MOD_TAGS: Record<string, string> = { Brakeman: 'BRK', Airbrakes: 'AIR', Helpers: 'HLP' };
const CW = { dp: 118, ml: 152, run: 78 };
/**
* TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row.
@@ -91,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.
*
@@ -131,9 +145,17 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
region?: number;
direction?: string;
stagesLeft?: number;
/** Being made up at a Division Point right now, so the map can mark the train you are loading. */
beingMadeUp?: boolean;
}[];
cap: number | null;
tip: string;
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
flash?: boolean;
/** ABS Signals standing on this Mainline card — drawn as a signal mast, not only described. */
abs?: boolean;
/** The Mainline modifiers on this card, as short tags — see `MOD_TAGS`. */
mods?: string[];
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
seat: number | null;
/** Set on an Office cell when a roster was supplied: whose district this is. */
@@ -168,7 +190,11 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.push({ ...c, x: 0, y: 0 });
};
// The node's own index, so a cell can be matched against `roster.flash`. `continue` below skips the
// rest of the body, never this.
let nodeIndex = -1;
for (const n of nodes) {
nodeIndex++;
if (n.kind === 'office') {
const cap = n.capacity;
const ad = n.trains.flat();
@@ -261,6 +287,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
push({
kind: dp ? 'dp' : 'ml',
label: n.label,
...(roster?.flash?.includes(nodeIndex) ? { flash: true } : {}),
sub: n.capacity === null
? 'no limit — trains queue'
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
@@ -276,6 +303,10 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
// words with no gameplay attached — reported exactly that way.
(n.what ? `\n\n${n.what}` : ''),
seat: null,
// Attachments are drawn, not only described: `modifiers` carries ABS Signals alongside the
// Mainline modifiers, and the name is the one the view already built for the tooltip.
abs: !dp && n.modifiers.includes('ABS Signals'),
mods: dp ? [] : n.modifiers.flatMap((m) => MOD_TAGS[m] ?? []),
// A Division Point is one region — the queue trains enter and leave the Division through.
regions: dp ? 1 : (n.regions ?? 0),
gradeUp: dp ? null : (n.gradeUp ?? null),
@@ -356,7 +387,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.forEach((c) => {
const full = c.cap !== null && c.trains.length >= c.cap;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}${c.flash ? ' bs-changed' : ''}" data-tip="${esc(c.tip)}">`;
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
/**
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
@@ -401,6 +432,57 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
out += `</g>`;
}
/**
* ABS SIGNALS, DRAWN RATHER THAN ONLY SAID — reported from a table on Day 1 Stage 1 of
* v0.8.0.16: "when played on the trestle, there was no on-the-card indication; it's only when
* you look at the tooltip for trestle that you see that ABS exists."
*
* Exactly the complaint the grade wedge below answers, and the card it protects is the one a
* player is deciding whether to run a second train onto. A signal is the literal object — ABS
* is Automatic Block Signals — so the mark is a mast with a lit lamp rather than a badge
* reading "ABS", and it needs no room for text, which is what lets it sit clear of a name as
* long as "Uncontrolled Siding" on a 152px card.
*
* TOP-RIGHT, above the rail and clear of the chip registers: the flag owns the cell ends at
* rail height and the grade wedge owns the bottom-right, so this is the corner left.
*/
if (c.abs) {
const mx = c.x + c.w - 10;
const top = c.y + 5;
out += `<g class="bs-abs">`;
out += `<line class="bs-abs-mast" x1="${mx}" y1="${top}" x2="${mx}" y2="${top + 15}"/>`;
// Two lamps, the upper one lit: a signal showing an aspect, not a bare post.
out += `<circle class="bs-abs-lit" cx="${mx}" cy="${top + 3}" r="2.6"/>`;
out += `<circle class="bs-abs-dark" cx="${mx}" cy="${top + 9}" r="2.6"/>`;
out += `</g>`;
}
/**
* THE MAINLINE MODIFIERS, DRAWN RATHER THAN ONLY SAID (Jesse, 2026-09-21) — the other half of
* the ABS report above: "Brakeman, Airbrakes, Helpers and Realignment should also be drawn on
* the card, not just the tooltip."
*
* ONLY THREE OF THOSE FOUR CAN EVER BE HERE. Realignment does not sit on a card — `reduce`
* takes the `became` branch and CHANGES `node.card`, so a realigned Trestle simply is an
* Uncontrolled Siding afterwards and the card face already says so. The other three are
* `gradeOnly`, so in practice this is a Heavy Grade's row.
*
* TAGS, NOT NAMES, and only because the measurements leave no choice: "Brakeman · Airbrakes ·
* Helpers" is 30 characters where about eleven fit beside a card name on a 152px cell. Three
* letters is the most that fits while still mapping to one card each, and the tooltip — which
* has always named them in full — is what expands it. The mark says THAT there is one, which
* is the half that was missing.
*
* Right-aligned on the name row: the only band on the cell that is clear, with the chip
* registers starting at `CHIP_Y` below and the capacity line and grade wedge at the foot.
* Shifted left of the signal when a card carries both.
*/
if (c.mods && c.mods.length > 0) {
out +=
`<text class="bs-mod" text-anchor="end" x="${c.x + c.w - (c.abs ? 20 : 7)}" y="${c.y + 14}">` +
`${esc(c.mods.join('·'))}</text>`;
}
/**
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
*
@@ -502,13 +584,18 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
// THE TRAIN THE MAKE-UP PANEL IS TALKING ABOUT. Amber, because that is what the rest of the
// page uses for "this is the thing you are acting on" (Jesse, playtest 2026-09-16).
const building = t.beingMadeUp === true;
const inRegion = c.regions > 1 && typeof t.region === 'number';
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
const stages =
typeof t.stagesLeft === 'number'
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card`
: '';
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
out += `<g class="bs-train${building ? ' bs-building' : ''}" data-tip="${esc(t.label)}${
building ? ' \u2014 BEING MADE UP NOW: add cars from the Division Yard' : ''
} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
cars.length ? ` (${loaded} loaded)` : ''
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}, counted west to east${dir}` : ''}${esc(stages)}${
// What the card prints. A train on the Mainline is exactly where "why did that leave without
@@ -669,7 +756,17 @@ export function officeSvg(
const c0 = Math.min(...cols);
const c1 = Math.max(...cols);
const width = (c1 - c0 + 1) * (W + PAD);
const height = (r1 - r0 + 1) * (H + PAD) + 4;
/**
* A BAND BENEATH THE BOTTOM ROW FOR THE LIMITS LABELS, and only when there are labels to put in it.
*
* The bottom card's lower edge lands at `height - 7`, and the label's baseline was `height - 4` —
* so its 8px glyphs spanned `height - 12` to `height - 4` and the card's own border ran straight
* through the middle of the word (Jesse, playtest 2026-09-16: *"the text is split by the bottom
* border of the limits card… it should be printed directly beneath the card"*). Raising the text
* instead would have pushed it onto the card, over the rails; the room has to be made below.
*/
const limitBand = limits ? 14 : 0;
const height = (r1 - r0 + 1) * (H + PAD) + 4 + limitBand;
// Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest
// and the district hangs beneath it, as the rules describe it.
@@ -1154,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>`;
@@ -1205,7 +1312,9 @@ export function officeSvg(
if (limits) {
const edge = (x: number, side: string): string =>
`<line class="bs-limitline" x1="${x}" y1="0" x2="${x}" y2="${height}"/>` +
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 4}" ` +
// Baseline inside the band below the cards: the glyphs run from `height - 19` to `height - 11`
// and the bottom row's edge is at `height - 21`, so the whole word clears the card border.
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 11}" ` +
`text-anchor="${side === 'w' ? 'start' : 'end'}">LIMITS</text>`;
out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e');
}
@@ -1248,9 +1357,18 @@ export const BOARD_CSS = `
and leave at the other, and a seated layout must not be read as a ring. */
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
train is measured against, not something to look at instead of the train. */
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
/* A card that has just BECOME a different card (Realignment). The same amber the rest of the page
spends on "it is happening here", pulsing only while the step that did it is on screen — so the
change is seen on the map rather than only read in the log. */
.bs-dcell.bs-changed rect{stroke:#e0a060;stroke-width:2.4;animation:bs-changed-pulse 1.1s ease-in-out infinite}
@keyframes bs-changed-pulse{0%,100%{stroke-opacity:1}50%{stroke-opacity:.35}}
@media (prefers-reduced-motion: reduce){.bs-dcell.bs-changed rect{animation:none}}
/* The vertical bars a Mainline card is divided into (§2.1). They are the ruler the train is measured
against, not something to look at instead of the train — but they were drawn so faint they could
not be made out at all (Jesse, playtest 2026-09-16: "the dividing line is barely visible"). A
ruler you cannot read is not restraint, so this is lifted to the tie colour and given a longer
dash: still quieter than the rail, and now actually there. */
.bs-region{stroke:#98a3b2;stroke-width:1.6;stroke-dasharray:4 2}
/* #94 — the one red mark on the Division map, so it reads as a stop rather than as decoration. */
.bs-flag line{stroke:#9aa3b0;stroke-width:1.6}
.bs-flag polygon{fill:#d2453f;stroke:#7d211d;stroke-width:0.8}
@@ -1283,6 +1401,10 @@ export const BOARD_CSS = `
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
.bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
/* The train the New Train phase is loading, in the page's action amber, so the make-up panel on the
right and the train on the map at the top left are visibly the same subject. */
.bs-train.bs-building rect{fill:#4a3a1c;stroke:#c8912f;stroke-width:2}
.bs-train.bs-building .bs-tlab{fill:#f2d49a}
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
engine is the one that carries the arrow — which is what makes "reverse" mean something. */
@@ -1329,7 +1451,10 @@ export const BOARD_CSS = `
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
/* stroke:none (Gitea#24). A name takes the class \`bs-turn\` while it is that player's move, and \`.bs-turn\` is
also the turn ARROW's rule, which strokes its shape 2.4px grey. Declared after it, this keeps that
outline off the letters, which it smeared into an unreadable blur. */
.bs-name{fill:#e6e9ee;stroke:none;font:600 11px ui-monospace,monospace}
.bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
seconds and which railroad is yours never does.
@@ -1344,6 +1469,16 @@ export const BOARD_CSS = `
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
.bs-mod{font:10px ui-monospace,monospace}
text.bs-mod{fill:#c8a04a}
/* A signal standing on a Mainline card carrying ABS Signals. Green is otherwise unused on the
Division row apart from the Division Point's dashed border, so a lit lamp does not compete with
the amber "it is happening here" or the red flag for meaning. */
/* 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}
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
.bs-enh-spent{fill:#5b6b7d;text-decoration:line-through}
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
+239 -72
View File
@@ -22,6 +22,9 @@
import {
applyIntent,
areaOf,
extendLimitsIfNeeded,
isLockedOut,
protoCard,
canAdvanceLoad,
destinationsFor,
facilityCarTypes,
@@ -29,14 +32,15 @@ import {
ownCutFor,
} from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts';
import type { CarType, Hand, TrackGeometry } from '../engine/content.ts';
import type { CarType, FreightKind, Hand, TrackGeometry } 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 { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
import { canPlaceAt, connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
import type { Port } from '../engine/track.ts';
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
import { planSwitchingTurn, switchFingerprint } from './switch-planner.ts';
export type BotPolicy = {
name: string;
@@ -87,26 +91,14 @@ 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.
*
* Every flag here turns something OFF. That is the opposite of how this started — the tweaks were
* candidates to switch on — and it is the right shape once a candidate has been adopted: what a
* measured heuristic needs afterwards is a way to ask "is this still worth it?" when the deck or
* the rules move under it. Both of these were worth about +1.5 revenue together when adopted; if a
* the rules move under it. The first two were worth about +1.5 revenue together when adopted, and
* planning the switching turn (`noPlanSwitching`) +2.89 on its own; if a
* rebalance changes the economy, that is a claim to re-test rather than to assume.
*
* The candidates that did NOT survive are gone rather than left switched off: preferring coaches at
@@ -121,9 +113,78 @@ export type BotTweaks = {
noTrainCap?: boolean;
/** Draw whenever nothing is urgent, as the bot did before it preferred operating. */
noOperateFirst?: boolean;
/**
* Choose switching Moves one at a time from the rule ladder, as the bot did before it planned the
* whole turn (`switch-planner.ts`). Measured at adoption, 2026-09-14: planning was worth
* +2.89 ± 0.18 revenue a game (t = 15.79) over 1600 paired seeds, 733 better against 21 worse.
*/
noPlanSwitching?: boolean;
/**
* Take a face-up train or industry card whether or not it could be played, as the bot did before
* 2026-09-14. It then took 20.1 trains and 11.9 industries a game off the Departments and discarded
* 20.2 and 11.8, retaking the same card 28.8 times a game. Asking first measured +1.52 ± 0.10
* (t = 15.59) over 1600 paired seeds — this ablation was worse on 880 of them and better on 211.
*/
noPlayableTakes?: boolean;
/**
* Choose where track goes by `bestTrackLay`'s piece rules and the fallback's first legal square, as the
* bot did before 2026-09-15, instead of by what the district can DO afterwards (`bestValuedLay`).
* Scoring the layout measured +0.118 ± 0.029 (t = 4.14) over 6400 paired seeds, and closed run-arounds
* in 22 of 60 districts against 9.
*/
noValueLays?: boolean;
/**
* Let the New Train phase's fallback take `options[0]`, as the bot did before 2026-09-15. Because
* `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, all 26 Second Sections the
* bot ran in 60 games were that accident, and 6 of the 20 collisions followed one. Taking a car, a pass
* or the Extra's start instead measured +0.32 ± 0.09 (t = 3.64) over 400 paired seeds.
*/
noDeliberateNewTrain?: boolean;
};
/**
* A switching turn planned once and then played a step per decision.
*
* Keyed by the tweaks object, because that is what one policy owns — the server shares a single
* `developerBot` across every bot seat, so the plan inside it is kept per player. Each step is
* submitted only while the position still matches the fingerprint the plan expected there; anything
* else replans. A switching turn has no randomness, so in practice a plan is made once a turn.
*/
type ActivePlan = { steps: Intent[]; keys: string[]; next: number; summary: string };
const activePlans = new WeakMap<BotTweaks, Map<PlayerIndex, ActivePlan>>();
function plannedSwitch(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
let mine = activePlans.get(tweaks);
if (!mine) activePlans.set(tweaks, (mine = new Map()));
const here = switchFingerprint(s, player);
let active = mine.get(player);
if (!active || active.keys[active.next] !== here) {
const p = planSwitchingTurn(s, player);
active = {
steps: p.steps,
keys: p.keys,
next: 0,
summary:
`position ${p.rootScore.toFixed(2)} → ${p.score.toFixed(2)} over ${p.expanded} positions` +
(p.complete ? '' : ', search budget reached'),
};
mine.set(player, active);
}
if (active.next >= active.steps.length) {
mine.delete(player);
const end = options.find((i) => i.type === 'switch.end');
return end ? because(`planned switching turn complete — ${active.summary}`, end) : null;
}
const want = JSON.stringify(active.steps[active.next]);
const match = options.find((i) => JSON.stringify(i) === want);
if (!match) {
mine.delete(player);
return null;
}
active.next++;
return because(`step ${active.next} of ${active.steps.length} of a planned switching turn — ${active.summary}`, match);
}
/** The bot as it plays today. Every knob off. */
export const developerBot: BotPolicy = makeDeveloperBot({});
@@ -209,6 +270,13 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
);
if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match);
}
if (!tweaks.noDeliberateNewTrain) {
const move =
pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar', 'newTrain.startExtra') ??
options.find((i) => i.type !== 'newTrain.secondSection' && i.type !== 'maneuver.redFlags') ??
options[0]!;
return because('no car on offer is one our facilities need', move);
}
return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!);
}
@@ -275,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,
@@ -438,7 +479,7 @@ function topOfDepartment(s: GameState, slot: number): string | undefined {
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
* attack — which is why the choice belongs to the discarding player and not to the rules.
*/
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
@@ -448,7 +489,7 @@ function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Inte
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
const top = topOfDepartment(s, i.toSlot);
const wanted = isWorthTaking(s, player, i.toSlot);
const wanted = isWorthTaking(s, player, i.toSlot, tweaks);
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
if (score > bestScore) {
@@ -460,10 +501,39 @@ function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Inte
}
/** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean {
return takingRank(s, player, slot) > 0;
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): boolean {
return takingRank(s, player, slot, tweaks) > 0;
}
/**
* Could an industry of this kind be laid anywhere right now? Asked of the engine's own placement
* rule (`canPlaceAt`) and lockout (`isLockedOut`) rather than a copy: an industry is plain east-west
* track, so the only squares worth asking about are empty ones east or west of a card already down.
*/
function industrySiteExists(s: GameState, player: PlayerIndex, kind: FreightKind): boolean {
const area = areaOf(s, player);
if (isLockedOut(area, kind)) return false;
const probe = {
geometry: { kind: 'facility', facility: kind },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
} as unknown as TrackCard;
for (const key of area.grid.keys()) {
const [row, col] = key.split(',').map(Number);
for (const dc of [1, -1]) {
const at = { row: row!, col: col! + dc };
if (at.row === area.runningRow || area.grid.has(coordKey(at))) continue;
if (canPlaceAt(area, at, probe)) return true;
}
}
return false;
}
/**
* HOW BADLY the face-up card is wanted. 0 means not worth the draw.
*
@@ -471,14 +541,18 @@ function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean
* happened to be scanned first — a coin flip on the card that decides whether the district ever
* becomes a Passenger Facility at all.
*/
function takingRank(s: GameState, player: PlayerIndex, slot: number): number {
function takingRank(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): number {
const id = topOfDepartment(s, slot);
if (!id) return 0;
const k = s.cards.get(id)?.kind;
if (!k) return 0;
if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 0;
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
if (k.kind === 'freightFacility') return 1;
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') {
return !tweaks.noPlayableTakes && trainWouldOverfillTheOffice(s, player, tweaks) ? 0 : 2;
}
if (k.kind === 'freightFacility') {
return !tweaks.noPlayableTakes && !industrySiteExists(s, player, k.facility) ? 0 : 1;
}
return 0;
}
@@ -741,6 +815,104 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
return best;
}
/**
* WHAT A DISTRICT'S TRACK IS WORTH FOR WHAT IT LETS HAPPEN NEXT — the default since 2026-09-15;
* `noValueLays` turns it off.
*
* `bestTrackLay` scores the PIECE — its shape and where it sits — and cannot tell one that opens an
* industry site or closes a run-around from one that merely fills a square. This scores the LAYOUT the
* piece would leave, so a lay is worth the difference it makes. Every term is something the rules turn
* into play: a site is somewhere a held industry can go; a run-around lets a crew pass its own cars
* (§A.5); a way off the main is the only road to either; a Running Track straight is what Interlocking
* needs. The weights are a starting point to measure, not a result.
*/
function layoutValue(area: OfficeArea): number {
let v = 0;
const reachable = reachableOffMain(area);
v += Math.min(reachable.size, 12) * 0.2;
let ways = 0;
let loops = 0;
for (const side of SIDES) {
for (const col of waysOff(area, side)) {
ways++;
if (descendFrom(area, col, side).rejoins.size > 0) loops++;
}
}
v += [0, 1.5, 2, 2.5][Math.min(ways, 3)]!;
if (loops > 0) v += 6 + Math.min(loops - 1, 1) * 2;
// Squares an industry could legally be laid on, joined to track a crew can reach.
const probe = protoCard({ kind: 'freightFacility', facility: 'mineTipple' }, 0)!;
const tried = new Set<string>();
let sites = 0;
for (const key of reachable) {
const [row, col] = key.split(',').map(Number);
for (const dc of [1, -1]) {
const at = { row: row!, col: col! + dc };
const k = coordKey(at);
if (tried.has(k) || area.grid.has(k) || at.row === area.runningRow) continue;
tried.add(k);
if (canPlaceAt(area, at, probe)) sites++;
}
}
v += [0, 2, 3, 3.5][Math.min(sites, 3)]!;
let mainStraight = false;
for (const [key, card] of area.grid) {
if (Number(key.split(',')[0]) !== area.runningRow || card.geometry.kind !== 'track') continue;
if (card.geometry.geometry === 'straight') mainStraight = true;
// A turnout on the main whose leg joins nothing is a hole in the Running Track with no road behind it.
if (card.geometry.geometry === 'turnout') {
const col = Number(key.split(',')[1]);
for (const side of SIDES) {
if (!hasPort(card, legPort(side))) continue;
const beyond = area.grid.get(`${area.runningRow + side},${col}`);
if (!beyond || !joins(card, legPort(side), beyond)) v -= 0.5;
}
}
}
if (mainStraight) v += 1.5;
return v;
}
/**
* The track lay worth most by `layoutValue`, placed on a copy of the district exactly as the reducer
* places it (`protoCard`, `extendLimitsIfNeeded`). With `mustBuild`, only a lay that gains something is
* offered, which is the slot `bestTrackLay` fills; without it, the best of whatever is legal, which is
* the slot the "play what is in hand" fallback fills. Ties go to the square nearer the Office.
*/
function bestValuedLay(s: GameState, player: PlayerIndex, options: Intent[], mustBuild: boolean): Intent | null {
const area = areaOf(s, player);
const base = layoutValue(area);
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'card.play' || i.placement === undefined) continue;
const kind = s.cards.get(i.cardId)?.kind;
if (kind?.kind !== 'track') continue;
const built = protoCard(kind, i.variant);
if (!built) continue;
const after: OfficeArea = {
...area,
grid: new Map(area.grid),
limitsWest: { ...area.limitsWest },
limitsEast: { ...area.limitsEast },
};
after.grid.set(coordKey(i.placement), built);
extendLimitsIfNeeded(after, i.placement);
const gain = layoutValue(after) - base;
if (mustBuild && gain <= 0.1) continue;
const distance = Math.abs(i.placement.row - area.officeCoord.row) * 2 + Math.abs(i.placement.col - area.officeCoord.col);
const score = gain - distance * 0.01;
if (score > bestScore) {
bestScore = score;
best = i;
}
}
return best;
}
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = areaOf(s, player);
@@ -1213,15 +1385,13 @@ function followThrough(
if (!turnOf(s, player).drawnThisTurn) {
const piles = options.filter(
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot),
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
// ever becomes a Passenger Facility is not worth keeping for its own sake.
const useful = piles.sort((a, b) => takingRank(s, player, b.slot) - takingRank(s, player, a.slot))[0];
const useful = piles.sort((a, b) => takingRank(s, player, b.slot, tweaks) - takingRank(s, player, a.slot, tweaks))[0];
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
@@ -1282,7 +1452,7 @@ function followThrough(
// STRAIGHTS that Enhancements require, and no Freight Facility has anywhere to go until a
// district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
// turns and could legally place one on 33.
const track = bestTrackLay(s, player, options);
const track = !tweaks.noValueLays ? bestValuedLay(s, player, options, true) : bestTrackLay(s, player, options);
if (track) return because('lay track — nothing else creates the straights Enhancements need or the spurs freight needs', track);
// Then real development: a card actually laid into the grid. Freight facilities are scored —
@@ -1307,17 +1477,27 @@ function followThrough(
s.cards.get(i.cardId)?.kind.kind !== 'track',
);
if (placed) return because('develop the district with a card that goes on the board', placed);
const play = options.find((i) => i.type === 'card.play');
const play = !tweaks.noValueLays
? options.find((i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind !== 'track') ??
bestValuedLay(s, player, options, false)
: options.find((i) => i.type === 'card.play');
if (play) return because('play what is in hand', play);
const end = options.find((i) => i.type === 'draw.end');
if (end) return because('nothing in hand can be played anywhere legal', end);
return because(
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
bestDiscard(s, player, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
bestDiscard(s, player, options, tweaks) ?? pickFirst(options, 'card.discard') ?? options[0]!,
);
}
case 'switch': {
// The planner decides the whole turn; the rules below are its fallback if the position is ever
// not the one it planned for, and the whole of switching under `noPlanSwitching`.
if (!tweaks.noPlanSwitching) {
const planned = plannedSwitch(s, player, options, tweaks);
if (planned) return planned;
}
/**
* A MOVE THAT DRAGS THE CREW'S OWN CUT BACK ON IS A WASTED MOVE, so take those off the table
* before any heuristic gets to choose one.
@@ -1758,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()) {
+3 -2
View File
@@ -23,6 +23,7 @@
* Run with:
* node src/sim/compare.ts 1600 noTrainCap=1 — what the A/D cap is worth today
* node src/sim/compare.ts 1600 noOperateFirst=1 — what operating before drawing is worth
* node src/sim/compare.ts 1600 noPlanSwitching=1 — what planning the switching turn is worth
*
* The flags are ABLATIONS: they turn off heuristics the bot already plays, so a negative delta is
* the heuristic earning its place. That is what a measured bot needs going forward — the question
@@ -221,7 +222,7 @@ export function formatPaired(r: PairedResult): string {
* against itself and report a confident zero, which is the most expensive way this tool could fail.
*/
export const NUMERIC_TWEAKS = new Set<string>([]);
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst']);
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst', 'noPlanSwitching', 'noPlayableTakes', 'noValueLays', 'noDeliberateNewTrain']);
export function parseTweaks(args: string[]): BotTweaks {
const tweaks: Record<string, number | boolean> = {};
@@ -250,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)));
+322 -42
View File
@@ -15,11 +15,13 @@
* panel cannot drift from the rules.
*/
import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
import { MAINLINE_PROFILES, MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
import { badlyMadeUp } from '../engine/advance.ts';
import type { CrewTray } from '../engine/state.ts';
// ---------------------------------------------------------------------------
// Small formatters
@@ -50,12 +52,38 @@ export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
}
/**
* "a loaded boxcar", "an empty tank" — the article the word actually takes.
*
* The make-up line hard-coded "a" and produced "a empty tank" at the table. Vowel-initial is the
* whole rule here: every car word is ordinary English ('empty', 'loaded', and the car types), so
* there is no 'an hour' case to special-case and inventing one would be the more fragile choice.
*/
export function indefinite(label: string): string {
return `${/^[aeiou]/i.test(label) ? 'an' : 'a'} ${label}`;
}
/** "Train 10" / "Extra X18" — one spelling of a train's name for every line that mentions one. */
export function trainLabel(trainNumber: number | null, isExtra: boolean): string {
if (trainNumber === null) return 'the local crew';
return isExtra ? `Extra X${trainNumber}` : `Train ${trainNumber}`;
}
export function carsLabel(cars: RollingStock[]): string {
if (cars.length === 0) return 'nothing';
return cars.map(carLabel).join(', ');
}
const at = (c: GridCoord): string => `(${c.row},${c.col})`;
/**
* X,Y — EAST/WEST THEN NORTH/SOUTH, exactly as `view.ts` writes it, and NOT the internal row/col
* storage order.
*
* These two disagreed until 2026-09-21: the action menu said "(1,-1)" and the log said "(-1,1)" for
* the same square, side by side on the same screen. `view.ts` carried the comment explaining why
* the display order is X,Y; this one had no comment at all and was simply the storage order
* reaching the page. Jesse's call — the log and the action menu spell a square the same way.
*/
const at = (c: GridCoord): string => `(${c.col},${c.row})`;
const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const;
const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`;
@@ -108,11 +136,39 @@ export type NarrateContext = {
* events has no roster, and "Player 2" is a truthful fallback rather than a broken one.
*/
playerName?: (player: PlayerIndex) => string;
/**
* Names the Facility standing on one of a player's squares, or null where there is none.
*
* Switching lines used to give the bare coordinate — "Set out a loaded hopper at (-1,1)" — which
* is the grid's own notation and means nothing at a table where people are looking at cards. The
* industry is the whole point of the move, so it is what the line should say.
*/
facilityAt?: (player: PlayerIndex, at: GridCoord) => string | null;
/**
* Names the Mainline card at a Division node — "Plains", "Heavy Grade" — or null if it cannot.
*
* A NODE INDEX IS NOT A NAME. The log said "ENHANCEMENT built: abs Signals on Mainline card 7",
* which asks the reader to remember what card 7 was (Jesse, 2026-09-22, playing ABS Signals).
* The SLOT still matters and is kept beside it, because a division can hold two Plains and the
* number is the only thing that tells them apart.
*/
mainlineAt?: (node: number) => string | null;
/** The printed name of an Enhancement or Mainline modifier, by its content key. */
enhancementName?: (key: string) => string | null;
};
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
// The industry on a square when there is one, and the coordinate when there is not — a crew works
// plain track too, and "at nowhere" would be worse than the notation.
const place = (player: PlayerIndex, c: GridCoord): string => ctx.facilityAt?.(player, c) ?? at(c);
/** "Mainline card 7 (Plains)" — the slot, which disambiguates, plus the card, which informs. */
const mainlineCard = (node: number | undefined): string => {
if (node === undefined) return 'the Mainline';
const name = ctx.mainlineAt?.(node) ?? null;
return `Mainline card ${node}${name ? ` (${name})` : ''}`;
};
switch (e.type) {
// -- clock
@@ -127,6 +183,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
.join(' → ')}`,
};
case 'superintendentChanged':
/**
* The Fedora is the only thing in the game that changes hands on a clock rather than because
* somebody did something, so it is the one handover nobody at the table watches happen.
*/
return {
tone: 'clock',
text:
`SUPERINTENDENT — the Fedora passes to ${ctx.playerName?.(e.player) ?? 'the next player'} ` +
`at the end of Stage ${e.stage}. They rule on clearances, take the Yard Office and Red Flag ` +
`questions, and every round that goes round the table now starts with them.`,
};
case 'phaseBegan':
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
// the log read as one undifferentiated column and you could not see where a phase began.
@@ -134,7 +202,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
case 'actorChanged':
return {
tone: 'quiet',
text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`,
text: e.player === null ? 'No player acts — automatic phase' : 'to act',
};
// -- local operations
@@ -146,7 +214,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? 'Chose to SWITCH — six Moves to shunt cars around the yard. Watch the crew chip on the grid: it carries its consist with it, and cars it passes over are coupled automatically.'
: e.option === 'draw'
? 'Chose to DRAW a card'
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
/**
* SAYS WHAT MAY BE DONE, NOT WHAT WAS. This read "one car moved to or from a
* facility" — an assertion — and §6.3 requires no action at all, so when the Freight
* Agent went idle the log claimed a car had moved and then fell silent about which.
* Reported from a table on Day 1 Stage 3 of v0.8.0.16. The work itself is narrated by
* `stockToOutbound`, `inboundCleared` and `facilityUnjammed`, each naming the car and
* the industry; an idle Agent is narrated by `freightAgentIdled`.
*/
: 'Chose FREIGHT AGENT work — may stock a green Outbound box, clear a red Inbound one, or free a jam',
};
case 'trayMoved':
// `via` rides on the event only when there was another legal route to the same square
@@ -155,7 +231,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
return {
tone: 'plain',
where: e.to,
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
// NO TUTORIAL TAIL. "The crew chip on the grid carries the whole train with it" was appended
// to EVERY move — six times a turn, and the opener (`localOpsOptionChosen`) already says it
// once. It also pushed the useful half of the line out of the caption row, which shows one
// step at a time and is the place a player reads a move as it happens.
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${place(e.player, e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of ${e.movesAllowed} Moves left`,
};
case 'carsCoupled': {
/**
@@ -167,22 +247,59 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
const own = e.recoupled?.stock.length ?? 0;
const found = e.stock.length - own;
const parts: string[] = [];
if (own > 0) parts.push(`picked its own ${carsLabel(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
if (found > 0) parts.push(`coupled ${carsLabel(e.stock.slice(own))} standing on the line`);
// `a loaded tank` for one, a bare list for several — "took loaded tank" reads as a telegram.
const some = (cars: RollingStock[]): string =>
cars.length === 1 ? indefinite(carLabel(cars[0]!)) : carsLabel(cars);
if (own > 0) parts.push(`picked its own ${some(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
if (found > 0) parts.push(`took ${some(e.stock.slice(own))} standing there`);
return {
tone: 'plain',
where: e.at,
text:
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
// "1 car(s)" was the plural of a machine. The count is already implied by the cars named
// in `parts`, so the sentence leads with where and which end instead.
`Coupled at ${place(e.player, e.at)}, ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
` — ${parts.join(', and ')}`,
};
}
case 'consistSorted':
case 'consistSorted': {
/**
* WHERE THE ENGINE ENDED UP, and whether the train can still run.
*
* ASKED OF `badlyMadeUp` RATHER THAN RE-DECIDED HERE, which matters because the obvious guess
* is wrong: §8.2 is enforced direction-free, so a train with its WHOLE consist ahead of the
* engine is a pushing train and perfectly fit to leave. What it may not be is broken-backed,
* with the engine buried among its own cars. A copy of that rule in the narrator would have
* told a player their pushing train was stranded when it was not.
*/
const ahead = e.engineAt;
const unfit = badlyMadeUp({ consist: e.after, engineAt: e.engineAt } as CrewTray);
const where =
ahead === 0
? 'so the right car is now on the end and can be spotted'
: unfit === null
? `with the whole consist AHEAD of the engine — it runs as a pushing train`
: `with ${ahead} car${ahead === 1 ? '' : 's'} ahead of the engine — ${unfit}, so it is held at the Office until it is sorted again (§8.2)`;
return {
tone: 'good',
where: e.at,
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], ${where}`,
};
}
case 'switchingEnded': {
/**
* The line that closes a switching turn, and the only one the history keeps from the middle of
* it: what it cost, and where the crew was left standing.
*/
const used = `${e.movesUsed} of ${e.movesAllowed} Move${e.movesAllowed === 1 ? '' : 's'} used`;
if (e.movesUsed === 0) return { tone: 'quiet', text: 'Finished switching without moving a car' };
if (!e.lastMove) return { tone: 'plain', text: `Finished switching — ${used}` };
return {
tone: 'plain',
where: e.lastMove.to,
text: `Finished switching — ${used}, leaving ${train(e.lastMove.trayId)} at ${place(e.player, e.lastMove.to)}`,
};
}
case 'carsDropped':
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
// the train may pull away from cars set out behind it and must couple back up to cars set out
@@ -191,7 +308,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'plain',
where: e.at,
text:
`Set out ${carsLabel(e.stock)} at ${at(e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
`Set out ${carsLabel(e.stock)} at ${place(e.player, e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
};
// -- cards
@@ -211,13 +328,22 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
: `Played ${card(e.cardId)}`,
};
case 'mainlineModified':
case 'mainlineModified': {
// WHICH CARD, NOT JUST WHICH WAY IT WENT. "Mainline card 3 converted to plains" left the reader
// to remember what card 3 had been (playtest, 2026-09-15), and the card it WAS is the half that
// says what the play was worth.
const kindName = (k: string | undefined): string =>
MAINLINE_PROFILES.find((m) => m.kind === k)?.name ?? k ?? 'that card';
return {
tone: 'plain',
text: e.became
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
: `Played ${e.key} on Mainline card ${e.node}`,
? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}`
// Same two faults as `enhancementPlaced` above, in the neighbouring case: a raw key for a
// name, and a slot with nothing to say which card it is when `from` is absent.
: `Played ${ctx.enhancementName?.(e.key) ?? e.key} on Mainline card ${e.node}` +
` (${e.from ? kindName(e.from) : (ctx.mainlineAt?.(e.node) ?? 'that card')})`,
};
}
case 'redFlagSpent':
return {
tone: 'good',
@@ -225,8 +351,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
};
case 'redFlagRuled':
return e.flag
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` }
: { tone: 'plain', text: `Player ${e.player} waved the train through` };
? { tone: 'plain', text: 'Flagged the approaching train' }
: { tone: 'plain', text: 'Waved the train through' };
case 'redFlagsSet':
return {
tone: 'good',
@@ -265,11 +391,17 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
};
case 'enhancementPlaced': {
const name = e.key.replace(/([A-Z])/g, ' $1');
/**
* THE CARD'S OWN NAME, not a key with spaces wedged into it. This was
* `e.key.replace(/([A-Z])/g, ' $1')`, which rendered `absSignals` as "abs Signals" —
* uncapitalised, and bypassing the authored names `cardName` started honouring in v0.8.0.17
* for exactly this reason: an acronym is not recoverable by splitting on capitals.
*/
const name = ctx.enhancementName?.(e.key) ?? e.key.replace(/([A-Z])/g, ' $1');
// Out on the Mainline is not a square in anyone's district, so it is named rather than
// given a coordinate the Office Area does not have.
if (e.at === undefined) {
return { tone: 'good', text: `ENHANCEMENT built: ${name} on Mainline card ${e.node}` };
return { tone: 'good', text: `ENHANCEMENT built: ${name} on ${mainlineCard(e.node)}` };
}
return { tone: 'good', where: e.at, text: `ENHANCEMENT built: ${name} at ${at(e.at)}` };
}
@@ -309,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 {
@@ -337,12 +476,25 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
* arrival; the only difference is what happens if it is left on Secondary Track when the next
* Mainline Phase begins (`expediteFault`).
*/
return {
tone: 'plain',
text: e.expedited
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so keep it on the Office square: parked anywhere else in the district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
};
/**
* WHOSE OFFICE, AND WHOSE TRAIN TO WORK — Jesse, playtest 2026-09-16.
*
* This said "ARRIVED at the Whistle Post" and then "You can work it in Cargo now". Both halves
* are wrong at a table of four: every seat has an Office, so the tier alone does not say which
* district the train is standing in, and the reader is usually NOT its Station Master — the
* line was telling three players they could work a train they cannot touch.
*/
{
const name = ctx.playerName?.(e.owner) ?? null;
const whose = name === null ? `the ${e.office}` : `${name}'s ${e.office}`;
const worker = name === null ? 'Its Station Master' : name;
return {
tone: 'plain',
text: e.expedited
? `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it must stay on the Office square: parked anywhere else in that district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
: `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — it stands there for the rest of this Stage. ${worker} can work it in Cargo now and switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
};
}
case 'expediteFault':
return {
tone: 'bad',
@@ -364,23 +516,58 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
};
// -- freight agent
/**
* THE INDUSTRY, NOT THE COORDINATE — `place` over `at`, for the reason its own comment gives:
* "(-1,1)" is the grid's notation and means nothing at a table where people are looking at
* cards. The switching lines were moved to it and these three were missed, so a Freight Agent
* turn was the one place the log still spoke in coordinates. Asked directly from a table on
* Day 1 Stage 3 of v0.8.0.16: "can we tell what car, what facility and whether it was to or
* from." The car and the direction were already here; the facility was not.
*/
case 'stockToOutbound':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent put a ${carLabel(e.stock)} into the green Outbound box at ${at(e.at)}`,
text: `Freight Agent loaded a ${carLabel(e.stock)} INTO the green Outbound box at ${place(e.player, e.at)}`,
};
case 'inboundCleared':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent cleared a ${carLabel(e.stock)} from the red Inbound box at ${at(e.at)}`,
text: `Freight Agent cleared a ${carLabel(e.stock)} OUT of the red Inbound box at ${place(e.player, e.at)}`,
};
case 'facilityUnjammed':
return {
tone: 'bad',
text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
where: e.at,
text: `UNJAMMED ${at(e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
};
case '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',
text:
'Freight Agent found nothing worth doing — no green box could be stocked, no red box needed ' +
'clearing, and no load was jammed. §6.3 requires no action, and unjamming a healthy box ' +
'would destroy a load that cost a whole action to stock.',
};
// -- trains
@@ -396,9 +583,39 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
};
}
case 'carPlacedOnTrain':
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
// NAMES THE TRAIN. "the train being made up" was true and useless: a player looking back for
// what happened to train 10 found four lines that never said 10 (playtest, 2026-09-16).
return {
tone: 'plain',
text: `Added ${indefinite(carLabel(e.stock))} to ${trainLabel(e.trainNumber, e.isExtra)}`,
};
case 'carPassed':
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
return {
tone: 'quiet',
text: `Passed on ${trainLabel(e.trainNumber, e.isExtra)} — no suitable car in the Division Yard`,
};
case 'makeUpShort': {
// What it wanted, in the words the card uses, so the line can be checked against the card.
const names: Record<'freight' | 'coach' | 'caboose', string> = {
freight: 'freight car',
coach: 'coach',
caboose: 'caboose',
};
const wants = e.missing.map((m) => names[m]).join(' or ');
const got = e.placed === 0 ? 'NO CARS AT ALL' : `only ${e.placed} of the ${e.wanted} its card calls for`;
// §2.2 is the whole explanation and it is not guessable from the board: the cars are visible
// in the Classification Yard, and why they will not come back is not.
const why =
e.waiting > 0
? ` ${e.waiting} sit in the Classification Yard, which comes back only when the Division Yard is bare — and it still holds ${e.divisionYardHolds} cars.`
: ' There are none in the Classification Yard either.';
return {
tone: 'bad',
text:
`${trainLabel(e.trainNumber, e.isExtra).toUpperCase()} WAS MADE UP WITH ${got} — the ` +
`Division Yard holds no ${wants} it can take, so nobody was asked for one.${why}`,
};
}
case 'dispatchBonusUsed':
return {
tone: 'good',
@@ -416,12 +633,31 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
const wrecked = e.trains
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
.join(' and ');
/**
* WHOSE OFFICE, AND WHO PAYS (playtest, 2026-09-16: "it doesn't say who suffers the revenue
* loss… we need to know which player received the penalty and why").
*
* `player` is the seat at fault, and for everything that happens inside a district that is the
* district's owner — so it names the place as well as the payer. A Mainline collision is the
* Superintendent's by rule (§10), which is a different sentence: it happened on open road, not
* in anybody's Office. The 5 points ride in a separate `revenueChanged`, which is why the line
* never mentioned them; a player should not have to add two log entries together.
*/
const who = ctx.playerName?.(e.player) ?? null;
const mainline = e.where === 'the Mainline';
const place = who === null || mainline ? e.where : `${who}'s ${e.where.replace(/^the /, '')}`;
const cost =
who === null
? ' 5 Revenue is lost.'
: mainline
? ` ${who} loses 5 Revenue: §10 makes a Mainline collision the Superintendent's fault.`
: ` ${who} loses 5 Revenue — it happened in their district.`;
return {
tone: 'bad',
text:
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
`Yard, all other cars to the Classification Yard. A Timetabled train card returns ` +
`to its slot and runs again next Day; an Extra is gone for good.`,
`COLLISION at ${place} — ${wrecked} destroyed: ${why}.${cost} Engines and cabooses go back ` +
`to the Division Yard, all other cars to the Classification Yard. A Timetabled train card ` +
`returns to its slot and runs again next Day; an Extra is gone for good.`,
};
}
@@ -491,18 +727,30 @@ 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: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
// -- §3.3, extended play (Gitea#11)
case 'extensionVoted':
return e.agree
? { tone: 'plain', text: `Player ${e.player} would play one more Day` }
: { tone: 'plain', text: `Player ${e.player} called time — the game ends here` };
? { tone: 'plain', text: 'Would play one more Day' }
: { tone: 'plain', text: 'Called time — the game ends here' };
case 'dayExtended':
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
case 'playConcluded':
@@ -511,8 +759,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
// -- §11, the Yard Office (Gitea#5)
case 'yardOfficeRuled':
return e.take
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` }
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` };
? { tone: 'plain', text: `Sent ${train(e.trainId)} into the Yard Office` }
: { tone: 'plain', text: `Kept ${train(e.trainId)} at the Train Order Office` };
}
}
@@ -641,6 +889,38 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
* that actually refused rather than a second guess at it.
*/
if (f.kind === 'passenger') {
/**
* NOBODY TO PUT ON THE PLATFORM, AND NO WAY TO SEE WHY (playtest, 2026-09-16).
*
* "He would like to have two passengers waiting in his depot… but the only option he had was
* bringing a tank load into the refinery." His Depot had a Restaurant and a Hotel beside it and
* three outbound slots — capacity was never the problem. §6.3 stocking takes a LOADED car of
* the facility's type out of the Division Yard, and there was not a loaded coach in it: six
* were sitting in Classification, which §2.2 returns only when the Division Yard runs bare.
*
* Jesse's ruling (2026-09-16) is the same one Gitea#2 got: the shortage stays, because running
* out is part of the game. What must not stay is the silence — an action with no legal target
* is simply absent from the menu, so the player is left to guess whether they misunderstood the
* rules or the game is broken.
*/
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) {
const loadedCoaches = s.yards.divisionYard.filter((c) => c.type === 'coach' && c.loaded).length;
if (loadedCoaches === 0) {
const waiting = s.yards.classificationYard.filter((c) => c.type === 'coach' && c.loaded).length;
out.push({
where: `${name} ${key}`,
why:
`room for ${f.capacity.outbound - f.outboundBox.length} more passenger` +
`${f.capacity.outbound - f.outboundBox.length === 1 ? '' : 's'} to wait, but no loaded ` +
`coach in the Division Yard for the Freight Agent to bring over` +
(waiting > 0
? ` — ${waiting} ${waiting === 1 ? 'is' : 'are'} in the Classification Yard, which comes ` +
'back only when the Division Yard is bare'
: ''),
severity: 'waiting',
});
}
}
if (portersLeft(f) > 0) {
const coord = uncoordKey(key);
// Passengers standing on the platform with nothing carrying them away.
+101 -12
View File
@@ -37,7 +37,9 @@ export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
* hundred times will want it off".
* hundred times will want it off". **Multipliers above 1 are supported and expected** — Jesse asked
* for 2 and 3 explicitly after the first play — up to `MAX_PACE`, and every tier scales together so
* their relative weighting survives.
*
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
@@ -46,8 +48,17 @@ export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
export const DWELL: Record<StepKind, number> = {
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
switching: 1000,
/** A card, a car or a load changing hands somewhere visible. */
action: 250,
/**
* A card, a car or a load changing hands somewhere visible — and the announcement of what a
* player is about to do.
*
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
* Jesse, from the first real play on the test server: *"bot play was way too fast. I briefly saw
* that it was the bot's office area then their turn was done."* His instruction had been "start at
* 1s and tune down", and that was applied only to switching while this number was invented.
*/
action: 700,
/**
* An automatic phase that DID something — TODO #18.
*
@@ -107,9 +118,17 @@ export function kindOf(cause: StepCause): StepKind {
case 'redFlag.play':
return 'action';
// Ending a phase or a turn, choosing what to do, voting. The consequences are worth watching;
// the declaration itself is not, and there are more of these than of anything else.
/**
* `localOps.choose` IS AN ANNOUNCEMENT, NOT BOOKKEEPING — moved out 2026-09-09 after the first
* real play. It is the line that reads "Player Bot 1 chose to SWITCH — six Moves to shunt cars
* around the yard": the heading for everything that follows, and at zero dwell nobody ever saw
* it, so a bot's turn began with no indication of what it was about to do.
*/
case 'localOps.choose':
return 'action';
// Ending a phase or a turn, and voting. Nothing to see: the consequences were the thing, and
// there are more of these than of anything else.
case 'loadUnload.end':
case 'draw.end':
case 'switch.end':
@@ -119,9 +138,65 @@ export function kindOf(cause: StepCause): StepKind {
}
}
/** How long to show one step, in ms, at a given speed. `pace` of 0 means "do not animate at all". */
/**
* The widest multiplier that is a speed rather than a mistake.
*
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
* dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
* well short of unusable.
*
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
* held firmly.
*/
export const MAX_PACE = 20;
/**
* The speeds the on-screen control offers, slowest last.
*
* `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has
* seen it a hundred times will want it off". The ladder runs well past 1 because that is what the
* first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took
* effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
* takes.
*/
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
/**
* How long to show one step, in ms, at a given speed.
*
* `pace` scales every tier by the same factor, so **the tiers stay in proportion at any speed** — a
* switching move outlasts an ordinary action at 0.5× and at 3× alike. That is deliberate: the
* relative weighting is the design (a train moving is worth more attention than a card changing
* hands), and the multiplier is only how fast the whole thing runs. `0` means do not animate at all.
*/
export function dwellFor(cause: StepCause, pace = 1): number {
return Math.round(DWELL[kindOf(cause)] * Math.max(0, pace));
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
}
/**
* How much of the speed control a PHASE gets — damped, not the full multiplier.
*
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
* long — at a guess. Maybe use the speed multiplier for that too?"*
*
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
* worst rather than the minutes a full multiplier would have cost.
*
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
* too.
*/
function phaseSpeed(pace: number): number {
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
}
/**
@@ -134,10 +209,25 @@ export function dwellFor(cause: StepCause, pace = 1): number {
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
*/
export function dwellForStep(
step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } },
step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } },
pace = 1,
): number {
if (step.lines.length > 0) return dwellFor(step.cause, pace);
// Off means off, for the clock as much as for anybody's move.
if (pace <= 0) return 0;
/**
* THE SPEED CONTROL IS ABOUT OTHER PEOPLE, NOT ABOUT THE CLOCK.
*
* A phase keeps its tabled beat at every speed. Measured over 40 turns of a real 3-seat game, the
* waiting split almost evenly — 21.0s of other players against 21.0s of phases turning over — so
* scaling both put 105 seconds of clock-ticking into a 5× game, all of it after the player's own
* move and none of it anything to watch. Jesse, from that game: *"after my turn, when I actually
* execute my turn, I'm still subject to that same delay before it moves on. That makes no sense."*
*
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
* to watch a bot shunt cars.
*/
const speed = step.player === null ? phaseSpeed(pace) : pace;
if (step.lines.length > 0) return dwellFor(step.cause, speed);
/**
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
* every phase a visible beat", for New Train, the Mainline and the shift change.
@@ -146,12 +236,11 @@ export function dwellForStep(
* silently killed #18: a phase can move trains without saying anything, and those steps were being
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player
* is being shown, and there are about 180 of those in a full game.
* all of them would cost a quarter of an hour a game.
*/
const table = step.frame.table as Record<string, unknown>;
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
return turned ? dwellFor(step.cause, pace) : 0;
return turned ? dwellFor(step.cause, speed) : 0;
}
/**
+62
View File
@@ -130,3 +130,65 @@ function need<T>(value: T | undefined, what: string): T {
}
return value;
}
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
export type PileKey = 'home' | 'salvage' | `dept${number}`;
/**
* WHICH PILES A STEP MOVED — derived, never sent.
*
* The receiver already holds the frame before a step and the frame after it, so which pile changed
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
* hint would.
*
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
*
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
*
* | intent | piles |
* | ----------------------- | -------------------------------------------------------- |
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
* | `card.discard` | that `dept` |
* | `card.play` | `salvage`, or nothing here when it lands on the board |
* | switching, new trains | nothing here — those show on the board itself |
*/
/**
* Mainline cards that became a different card between two public boards — a Realignment, which is the
* one play that changes the Division itself.
*
* Playtest, 2026-09-15: *"is it possible to flash the mainline card when it gets changed by realignment?
* This would be more obvious to see what's happening on the map."* Detected the same way `changedPiles`
* detects a pile moving — by comparing the two boards the queue already holds — rather than by reading
* the event, so the flash lands with the step that shows it and not when the intent arrived.
*/
export function changedDivisionCards(before: PublicFrame | null, after: PublicFrame): number[] {
if (before === null) return [];
const out: number[] = [];
after.division.forEach((node, i) => {
const was = before.division[i];
if (was && was.kind === 'ml' && node.kind === 'ml' && was.label !== node.label) out.push(i);
});
return out;
}
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
if (before === null) return [];
const out: PileKey[] = [];
if (before.deck !== after.deck) out.push('home');
after.departmentDepth.forEach((depth, i) => {
// The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone
// and changes the card everybody can see, which is the half that matters to a watcher.
if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) {
out.push(`dept${i}`);
}
});
if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) {
out.push('salvage');
}
return out;
}
+5 -6
View File
@@ -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';
@@ -79,6 +76,8 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
const narrateCtx = {
cardName: (id: string) => cardName(s, id),
trainName: (id: string) => trainName(s, id),
// A player index is not a seat index, so the fallback names no number at all — see `web/game.ts`.
playerName: (p: number) => s.players[p]?.name ?? 'another player',
};
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
+1 -1
View File
@@ -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';
+347
View File
@@ -0,0 +1,347 @@
/**
* Component 17b — planning a whole switching turn before making the first Move.
*
* Dev-side, like the rest of the bot. The developer bot's switching branch chooses ONE move at a time
* from a ladder of rules, and its own comment names what that cannot do: "a strong player would use
* the six Moves to re-order the consist — that is the game's central switching puzzle, and this bot
* does not attempt it." This attempts it, for one turn at a time.
*
* WHY SEARCH IS FAIR HERE. A switching turn draws no card and rolls no die, so trying sequences on a
* copy of the game is exactly what a player does by looking at the board. The score below reads only
* what a player can see — the district, the cars on the trains, the facilities — and never the deck.
*
* WHY NOT EVERY SEQUENCE. Measured 2026-09-14 over 30 switching turns from bot games: a median turn
* reaches 229 distinct positions, but 11 of 30 passed 20,000, because setting cars out is free and a
* crew can leave them in a great many places. So the search keeps the best `beam` positions at each
* step and stops at `budget` positions tried. Small turns are searched completely inside that.
*
* THE SCORE IS OF WHERE THE TURN ENDS, not of what it did, and it starts from Jesse's ruling
* (2026-09-14): "players will attempt to deliver / pick up cars even if it delays trains." So a car
* put where it can be worked is worth a point, and a train left away from the Office costs a quarter
* of one. The weights are a starting point to measure, not a result.
*/
import { areaAtSeat, areaOf, commitEvents, facilityCarTypes, prepareIntent, withRouteCache } from '../engine/apply.ts';
import { badlyMadeUp, isExpedited } from '../engine/advance.ts';
import { MAX_CONSIST } from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import { legalSwitchingActions } from '../engine/legal.ts';
import { cloneTally, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export const SWITCH_WEIGHTS = {
/** A car standing where its industry can load or unload it — the point of switching. */
spot: 1.0,
/** The same, past what the industry's boxes can work at once. */
spotBeyondCapacity: 0.25,
/** A car the industry cannot work, taking room on its track. */
junkOnIndustry: -0.5,
/** A finished car — loaded at a shipper, emptied at a receiver — still waiting to be lifted. */
finishedLeft: -0.15,
/** A car on one of this district's trains that some industry here would work. */
carriedWanted: 0.35,
/** The same car left on ordinary track, where a later turn can fetch it. */
stagedWanted: 0.2,
/** A coach kept with its train, or parked at the Office where §A.4 allows it. */
coachWithTrain: 0.3,
/** A coach left anywhere else, where no Porter can work it. */
coachStranded: -0.3,
/** Anything but a coach standing on the Office square — the next arrival collides (§8.3). */
fouling: -3,
/** A train that ends the turn away from the Office and so cannot highball next Mainline Phase. */
trainAway: -0.25,
/** On top of that, an expedited train — Q3 charges a Revenue point every Phase it is away. */
expeditedAway: -1.0,
/** A train that could not leave even from the Office — engine buried, caboose mid-train (§8.2). */
notMadeUp: -0.6,
/** Tie-breaks, so equal outcomes prefer the plan that does less. */
perMove: -0.02,
perSetOut: -0.005,
/** A maneuver card spent — Flying Switch — so the planner plays one only when it buys something. */
cardSpent: -0.1,
} as const;
export type PlanOptions = {
budget: number;
beam: number;
/**
* Search Flying Switch alongside Moves, set-outs and sorts. On by default but UNMEASURED: the card
* is dealt 0 copies (Jesse, 2026-08-26), so over 400 paired seeds turning it on changed nothing —
* it is here so the planner can use the card the day it is dealt again.
*/
flyingSwitch?: boolean;
};
/**
* Measured 2026-09-14, paired over 400 seeds against 3000/48: 2000/32 cost −0.02 ± 0.01 (t = −1.68,
* inside the noise) at half the time per turn; 1000/24 cost −0.06 ± 0.02 (t = −2.65) for little more.
*/
export const DEFAULT_PLAN: PlanOptions = { budget: 2000, beam: 32, flyingSwitch: true };
export type SwitchPlan = {
/** The intents to submit, in order. Empty when nothing beats stopping where the crew stands. */
steps: Intent[];
/** `switchFingerprint` before each step, and after the last — so a caller can tell it is on plan. */
keys: string[];
rootScore: number;
score: number;
/** Positions tried. */
expanded: number;
/** False when the budget ran out before the search did. */
complete: boolean;
};
/**
* A copy of the game that a switching intent can be applied to without touching the original.
*
* NOT `structuredClone`, of the state or even of the district. A switching intent writes only the
* cars standing on cards, the industry tracks, the district's A/D and held lists, the consist and
* position of the trays standing in it, this player's turn and the tally — so exactly those arrays are
* copied and everything else is shared by reference. Measured 2026-09-14, deep-cloning the district
* was 44% of all planning time.
*
* `test/switch-planner.test.ts` proves across real games that planning leaves the original
* byte-identical — which is what fails first if a reducer ever starts writing somewhere new, or
* starts mutating a car or a card in place instead of replacing it.
*/
export function forkForSwitching(s: GameState, player: PlayerIndex): GameState {
const seat = seatOf(s, player);
const area = areaAtSeat(s, seat);
const grid = new Map<string, TrackCard>();
for (const [key, card] of area.grid) {
const f = card.facility;
grid.set(key, {
...card,
standing: [...card.standing],
facility: f ? { ...f, industryTrack: { cars: [...f.industryTrack.cars] } } : f,
});
}
const officeAreas = new Map(s.officeAreas);
officeAreas.set(seat, {
...area,
grid,
adOccupancy: [...area.adOccupancy],
heldAtLimits: [...area.heldAtLimits],
dispatchUsedToday: [...area.dispatchUsedToday],
});
const trays = new Map(s.trays);
for (const [id, t] of s.trays) {
if (t.position.at === 'grid' && t.position.seat === seat) trays.set(id, { ...t, consist: [...t.consist] });
}
const turns = new Map(s.turns);
const turn = s.turns.get(player)!;
turns.set(player, { ...turn, freightWorked: { ...turn.freightWorked } });
// Flying Switch spends its card (`spendCard`): the hand map is rewritten and the Salvage Yard grows.
const decks = { ...s.decks, hands: new Map(s.decks.hands), salvageYard: [...s.decks.salvageYard] };
return { ...s, officeAreas, trays, turns, decks, tally: cloneTally(s.tally) };
}
const carList = (xs: readonly RollingStock[]): string =>
xs.map((c) => `${c.type}${c.loaded ? '+' : '-'}${c.origin ?? ''}`).join(',');
/**
* Everything a switching intent can change, as a string — two positions with the same fingerprint
* are the same position as far as the rest of the turn is concerned. Identical cars are not told
* apart, which is right: no intent names a car.
*/
export function switchFingerprint(s: GameState, player: PlayerIndex): string {
const seat = seatOf(s, player);
const parts: string[] = [];
for (const [id, t] of s.trays) {
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
const { row, col } = t.position.coord;
parts.push(`${id}@${row},${col}/${t.facing}/${t.railFacing ?? ''}/${t.engineAt}:${carList(t.consist)}`);
}
for (const [key, card] of areaOf(s, player).grid) {
const track = card.facility?.kind === 'freight' ? card.facility.industryTrack.cars : null;
if (card.standing.length === 0 && card.standingWest === 0 && (track?.length ?? 0) === 0) continue;
parts.push(`${key}=${carList(card.standing)}|${card.standingWest}|${track ? carList(track) : ''}`);
}
const turn = turnOf(s, player);
parts.push(`m${turn.movesRemaining}`, JSON.stringify(turn.freightWorked), `h${(s.decks.hands.get(player) ?? []).join(',')}`);
return parts.join(';');
}
/**
* §9.3 — an outbound industry loads EMPTY cars of its commodity, an inbound one unloads LOADED ones —
* but never a load that was made in this same district (v0.4.9e, `LOADED_IN_THIS_DISTRICT`).
*/
function works(f: Facility, c: RollingStock, seat: number): boolean {
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
return (!c.loaded && f.allows.outbound) || (c.loaded && f.allows.inbound && c.origin !== seat);
}
/**
* The car an industry has finished with. Only decidable at a one-way industry: at one that both
* ships and receives, a loaded car may be a delivery still waiting to be unloaded.
*/
function finished(f: Facility, c: RollingStock): boolean {
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
if (f.allows.outbound && !f.allows.inbound) return c.loaded;
if (f.allows.inbound && !f.allows.outbound) return !c.loaded;
return false;
}
const same = (a: GridCoord, b: GridCoord): boolean => a.row === b.row && a.col === b.col;
/** How good this district's position is for the rest of the game, in rough Revenue points. */
export function evaluateSwitching(s: GameState, player: PlayerIndex): number {
const W = SWITCH_WEIGHTS;
const area = areaOf(s, player);
const seat = seatOf(s, player);
const officeKey = coordKey(area.officeCoord);
const passengerOffice = area.grid.get(officeKey)?.facility?.kind === 'passenger';
let v = 0;
const withRoom: Facility[] = [];
for (const card of area.grid.values()) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
if (f.industryTrack.cars.length < MAX_CONSIST) withRoom.push(f);
const cap = Math.max(1, f.capacity.outbound + f.capacity.inbound);
let working = 0;
for (const c of f.industryTrack.cars) {
if (works(f, c, seat)) v += ++working <= cap ? W.spot : W.spotBeyondCapacity;
else if (finished(f, c)) v += W.finishedLeft;
else v += W.junkOnIndustry;
}
}
const wanted = (c: RollingStock): boolean => withRoom.some((f) => works(f, c, seat));
for (const [key, card] of area.grid) {
if (card.facility?.kind === 'freight') continue;
const atOffice = key === officeKey;
for (const c of card.standing) {
if (c.type === 'coach') v += atOffice && passengerOffice ? W.coachWithTrain : W.coachStranded;
else if (atOffice) v += W.fouling;
else if (wanted(c)) v += W.stagedWanted;
}
}
for (const t of s.trays.values()) {
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
for (const c of t.consist) {
if (c.type === 'coach') v += passengerOffice ? W.coachWithTrain : 0;
else if (wanted(c)) v += W.carriedWanted;
}
if (t.trainNumber === null) continue;
if (!same(t.position.coord, area.officeCoord)) {
v += W.trainAway;
if (isExpedited(t)) v += W.expeditedAway;
}
if (badlyMadeUp(t)) v += W.notMadeUp;
}
return v;
}
/**
* For ORDERING the beam only, never for choosing the plan: a Move toward an industry changes nothing
* the score can see until the car is set out, so without this the beam would drop the approach in
* favour of positions that merely look tidy.
*/
function approach(s: GameState, player: PlayerIndex): number {
const area = areaOf(s, player);
const seat = seatOf(s, player);
const targets: { at: GridCoord; f: Facility }[] = [];
for (const [key, card] of area.grid) {
const f = card.facility;
if (!f || f.kind !== 'freight' || f.industryTrack.cars.length >= MAX_CONSIST) continue;
const [row, col] = key.split(',').map(Number);
targets.push({ at: { row: row!, col: col! }, f });
}
let bonus = 0;
for (const t of s.trays.values()) {
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
const here = t.position.coord;
for (const c of t.consist) {
let nearest = Infinity;
for (const { at, f } of targets) {
if (works(f, c, seat)) nearest = Math.min(nearest, Math.abs(at.row - here.row) + Math.abs(at.col - here.col));
}
if (nearest !== Infinity) bonus += 0.1 / (1 + nearest);
}
}
return bonus;
}
const SEARCHED = new Set<Intent['type']>(['switch.move', 'switch.dropCars', 'switch.sortConsist']);
type Node = {
s: GameState;
steps: Intent[];
keys: string[];
moves: number;
setOuts: number;
cards: number;
score: number;
rank: number;
};
/** The best way found to spend what is left of this switching turn. Never mutates `s`. */
export function planSwitchingTurn(
s: GameState,
player: PlayerIndex,
opts: PlanOptions = DEFAULT_PLAN,
): SwitchPlan {
const W = SWITCH_WEIGHTS;
const scoreOf = (st: GameState, moves: number, setOuts: number, cards: number): number =>
evaluateSwitching(st, player) + moves * W.perMove + setOuts * W.perSetOut + cards * W.cardSpent;
const searched = (type: Intent['type']): boolean =>
SEARCHED.has(type) || (opts.flyingSwitch === true && type === 'maneuver.flyingSwitch');
const rootKey = switchFingerprint(s, player);
const rootScore = scoreOf(s, 0, 0, 0);
const root: Node = { s, steps: [], keys: [rootKey], moves: 0, setOuts: 0, cards: 0, score: rootScore, rank: rootScore };
let best = root;
const seen = new Set([rootKey]);
let frontier: Node[] = [root];
let expanded = 0;
let complete = true;
search: while (frontier.length > 0) {
const next: Node[] = [];
for (const node of frontier) {
const movesLeft = turnOf(node.s, player).movesRemaining;
// Every candidate is decided against THIS position, inside one route cache, and only then applied
// to its own copy: deciding on the copy would re-walk routes the listing had just walked.
const decided = withRouteCache(node.s, () =>
legalSwitchingActions(node.s, player)
.filter((i) => searched(i.type) && (i.type === 'switch.dropCars' || movesLeft >= 1))
.map((i) => ({ i, r: prepareIntent(node.s, player, i) })),
);
for (const { i, r } of decided) {
if (expanded >= opts.budget) {
complete = false;
break search;
}
expanded++;
if (!r.ok) continue;
const f = forkForSwitching(node.s, player);
commitEvents(f, r.events);
const key = switchFingerprint(f, player);
if (seen.has(key)) continue;
seen.add(key);
const setOut = i.type === 'switch.dropCars';
const moves = node.moves + (setOut ? 0 : 1);
const setOuts = node.setOuts + (setOut ? 1 : 0);
const cards = node.cards + (i.type === 'maneuver.flyingSwitch' ? 1 : 0);
const score = scoreOf(f, moves, setOuts, cards);
const child: Node = {
s: f,
steps: [...node.steps, i],
keys: [...node.keys, key],
moves,
setOuts,
cards,
score,
rank: score + approach(f, player),
};
if (score > best.score + 1e-9) best = child;
next.push(child);
}
}
// A stable sort, so equal ranks keep `legalActions` order and the bot stays deterministic.
frontier = next.length > opts.beam ? next.sort((a, b) => b.rank - a.rank).slice(0, opts.beam) : next;
}
return { steps: best.steps, keys: best.keys, rootScore, score: best.score, expanded, complete };
}
+48 -4
View File
@@ -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',
@@ -55,7 +65,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
// Says WHO builds, which is the question this phase actually raises at a table: the round
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
tip: 'Timetabled trains for this Stage are built: starting with the Superintendent and working left, each player adds ONE car, going round again until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
tip: 'Timetabled trains for this Stage are built: each player adds ONE car at a time, starting with the Superintendent and working eastward, repeating until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
// a locomotive being made up
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
},
@@ -106,7 +116,25 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
* and answers who; `awaiting` says what, because "waiting on Bob" with no more than that is a
* game that looks stuck to everyone except Bob.
*/
const who = actorName ?? 'nobody — the Division is running itself';
/**
* AN AUTOMATIC PHASE WAITS ON NOBODY, so it says what it is DOING instead of apologising.
*
* "nobody — the Division is running itself" reached the answer by negation, and left a player
* reading a line whose subject was an absence (Jesse, playtest 2026-09-16: it should say "waiting
* on <player>", or describe what the engine is doing — "the Division is moving trains during the
* mainline phase"). The phase's NAME is already printed on the line directly above this one, so
* these describe the work rather than repeating the label.
*/
const DOING: Record<string, string> = {
mainline: 'the Division is moving trains',
newTrain: "the Division is building this Stage's trains",
loadUnload: 'the Division is working cargo',
shiftChange: 'the Division is changing shifts',
};
// Local Operations always has an actor, so its entry is the fallback rather than a case.
const who = actorName ?? DOING[f.phaseKey] ?? 'the Division is running itself';
// Only a person is WAITED ON. The Division is not waiting; it is working.
const waiting = actorName === null ? '' : 'waiting on ';
const asked = f.awaiting
? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>`
: '';
@@ -120,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 on <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
@@ -151,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. */
+191 -9
View File
@@ -10,12 +10,13 @@
* drift into two different pictures of the same board.
*/
import { isExpedited, regionOfTransit } from '../engine/advance.ts';
import { badlyMadeUp, isExpedited, regionOfTransit } from '../engine/advance.ts';
import {
areaAtSeat,
areaOf,
destinationsFor,
facilityCarType,
isBeingMadeUp,
laborersLeft,
movesFor,
ownCutFor,
@@ -32,6 +33,7 @@ import {
MANEUVER_CARDS,
MODIFIER_PROFILES,
REALIGNMENTS,
SECOND_SECTION,
OFFICE_ORDER,
SPACE_USE_CARDS,
STAGES_PER_SHIFT,
@@ -276,6 +278,15 @@ export type TrainChip = {
* it belongs in the tooltip, where there is room to say which it is.
*/
stagesLeft?: number;
/**
* BEING MADE UP RIGHT NOW — §7's round, one car at a time, at a Division Point.
*
* The make-up panel names the train and the yard chips load it, and both are in the right-hand
* column; the train itself is drawn on the Division strip at the top left, looking exactly like
* every other chip on the map. So the two halves of the same activity never pointed at each other
* (Jesse, playtest 2026-09-16). Absent rather than false everywhere else, like `region` above.
*/
beingMadeUp?: true;
};
/**
* One card of a player's Running Track, as the Division sees it.
@@ -437,6 +448,8 @@ export type Frame = {
maxCollisionsPerDay: number;
maxCollisionsTotal: number;
collisionsToday: number;
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
collisionsPrevDay: number;
collisionsTotal: number;
status: GameState['status'];
outcome: GameState['outcome'];
@@ -718,6 +731,26 @@ function suppressedGrants(modifiers: string[], f: Facility): string[] {
for (const key of modifiers) {
const m = MODIFIER_PROFILES.find((p) => p.kind === key);
if (!m) continue;
/**
* A WHISTLE POST TAKES NOTHING AT ALL, AND SAYING "IT ONLY RECEIVES" WOULD BE A LIE.
*
* Jesse, playtest 2026-09-16: a Restaurant appeared to do nothing. It does nothing — a Whistle
* Post is not a Passenger Facility, so it allows neither direction and has 0 capacity each way;
* the engine's `usableGrant` discards the capacity while the porter is granted regardless, which
* leaves a porter with nothing to carry. Playing it there stays LEGAL on Jesse's call, so the
* card is not wasted — it starts working the moment the Office is upgraded — but the panel has
* to say so, or the player is left believing the card is broken.
*
* Only a Whistle Post can reach this: every freight flow allows at least one direction, and
* every Office above the first allows both.
*/
if (f.kind === 'passenger' && !f.allows.outbound && !f.allows.inbound) {
out.push(
`${m.name}: DORMANT — a Whistle Post works no passengers at all, so nothing this card ` +
`grants is in use yet. It all starts working when the Office is upgraded to a Depot.`,
);
continue;
}
if (m.addOut > 0 && !f.allows.outbound) {
out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`);
}
@@ -856,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.
@@ -921,9 +954,22 @@ export function describeIntent(s: GameState, i: Intent): string {
? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`)
: undefined;
const upgrade = over?.geometry.kind === 'track';
/**
* NAME THE MAINLINE CARD, for exactly the reason the rotation is named above — reported from
* a table on Day 1 Stage 1 of v0.8.0.16 and the THIRD time this trap has been sprung.
*
* ABS Signals is played on a Division NODE rather than a grid square, so `i.placement` is
* absent and every one of its placements described itself as plain "play ABS Signals". The
* action list drops duplicate labels, so all but the lowest-index Mainline card were discarded
* before the menu saw them: the tooltip promised "any Mainline card" and the board offered
* one. Naming the card is what makes the choice both legible and survivable.
*/
const onNode = i.node === undefined ? undefined : s.division.nodes[i.node];
const mainline =
onNode?.kind === 'mainline' ? ` on the ${mainlineProfile(onNode.card).name}, out on the Mainline` : '';
return (
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
`${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`
`${i.placement ? ` at ${at(i.placement)}` : ''}${mainline}${turn}`
);
}
case 'card.discard': {
@@ -1004,8 +1050,65 @@ export function describeIntent(s: GameState, i: Intent): string {
const end = i.fromNose ? 'off the front' : 'off the back';
return `set out ${carsLabel(cut)} ${end}`;
}
case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`;
case 'switch.sortConsist': {
/**
* THE TRAIN IT WOULD MAKE, DRAWN THE WAY THE BOARD DRAWS IT.
*
* This read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person
* — and the option Jesse wanted was the first of five and unidentifiable (playtest,
* 2026-09-17). Naming the cars fixed that and left a second ambiguity he caught immediately:
* a list "front to back" means nothing at a table looking at a map, because which end is the
* front depends on which way the train is pointed.
*
* SO IT IS LAID OUT WEST TO EAST, exactly as `board-svg.ts` lays the crew strip: the consist
* is stored nose first, and a train facing EAST is reversed so its nose lands at the east end
* where it actually is. The engine is the same ◀ / ▶ arrow the board uses, seated where it
* will be, so "ahead of the engine" and "behind the engine" are read off the picture rather
* than asserted in words — and the button and the board cannot disagree.
*/
const sorting = s.trays.get(i.trayId);
if (!sorting) return `re-order consist [${i.order.join(',')}]`;
const after = i.order.map((n) => sorting.consist[n]!).filter((c) => c !== undefined);
const engineAt = i.engineAt ?? 0;
const facing = railFacingOf(sorting);
const items = after.map((c) => carLabel(c));
items.splice(engineAt, 0, facing === 'w' ? '◀ ENGINE' : 'ENGINE ▶');
// West on the left, like the map and like the crew strip on the board.
const strip = (facing === 'e' ? [...items].reverse() : items).join(' · ');
/**
* WHAT IT WOULD MEAN, from §8.2's own predicate rather than a copy of it: a train with its
* whole consist ahead of the engine is a PUSHING train and fit to run, a buried engine is not,
* and a caboose has to ride at the end away from the engine. Numbered trains only — a local
* crew has no card and never departs, so a departure verdict on one is noise.
*/
const unfit =
sorting.trainNumber === null
? null
: badlyMadeUp({ ...sorting, consist: after, engineAt });
// `badlyMadeUp` leads with "not made up — ", which reads as a stutter in front of HELD. The
// reason after it is the part worth showing, so the prefix comes off.
const because = unfit?.replace(/^not made up — /, '') ?? '';
const verdict =
sorting.trainNumber === null
? ''
: unfit === null
? ' · MADE UP, ready to leave'
: ` · HELD at the Office: ${because}`;
// A sort that only moves the engine says which errand it is running, rather than reprinting a
// car order that has not changed.
const sameOrder = i.order.every((n, at) => n === at);
const lead = sameOrder
? engineAt === 0
? 'pull the engine back to the front'
: engineAt === after.length
? 'put the whole consist ahead of the engine'
: `move the engine behind ${engineAt} car${engineAt === 1 ? '' : 's'}`
: 're-order';
return `${lead} — west to east: ${strip}${verdict}`;
}
case 'freightAgent.stockOutbound': {
/**
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly
@@ -1048,8 +1151,20 @@ export function describeIntent(s: GameState, i: Intent): string {
? 'the Revenue was paid when the passengers detrained'
: 'the Revenue was paid when the load reached the box';
const frees = f?.kind === 'passenger' ? 'more passengers can detrain here' : 'another car can be unloaded here';
/**
* SAY WHAT HAPPENS, NOT WHAT MOVES (Jesse, 2026-09-22). This read "send the loaded coach in
* the red Inbound box to the Classification Yard", which is true of the counters and
* nonsense as a sentence — it sounds like shipping a coachful of people off to a siding.
* What the red box holds is a journey that has ENDED: the passengers are at the station, or
* the load is at the industry, and both were paid for on arrival. Clearing it sends them on
* their way and returns the CAR to the common supply, empty.
*/
const what =
f?.kind === 'passenger'
? `let the passengers out of the station — the ${car ? carLabel(car) : 'coach'} in the red Inbound box at ${at(i.at)} goes back to the Classification Yard empty`
: `send the delivered load into the industry — the ${car ? carLabel(car) : 'car'} in the red Inbound box at ${at(i.at)} goes back to the Classification Yard empty`;
return (
`send the ${car ? carLabel(car) : 'car'} in the red Inbound box at ${at(i.at)} to the Classification Yard` +
`${what}` +
` — pays nothing (${paid}); it frees${full ? ' the last' : ' a'} slot so ${frees}`
);
}
@@ -1594,6 +1709,7 @@ export function projectSharedTable(s: GameState) {
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
maxCollisionsTotal: s.config.maxCollisionsTotal,
collisionsToday: s.collisionsToday,
collisionsPrevDay: s.collisionsPrevDay,
collisionsTotal: s.collisionsTotal,
status: s.status,
outcome: s.outcome,
@@ -1808,15 +1924,53 @@ 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':
case 'maneuver':
case 'action':
return prettyKey(k.key);
/**
* THE PRINTED NAME WINS over `prettyKey`'s guess, on the same reasoning as the facility and
* modifier lookups above: the tables are for names `prettyKey` cannot derive.
*
* It guessed wrong more often than the fallback comment implies. `absSignals` came out as
* "Abs Signals" on the button while the rules, the tooltip and the card face all say **ABS**
* Signals — an acronym no key-splitter can recover — and `brokenCoupler` and `beanHouse`
* were title-cased past their printed "Broken coupler" and "Bean house".
*/
return SIMPLE_CARD_NAMES.get(k.key) ?? prettyKey(k.key);
}
}
/**
* Every authored card name, keyed as the card kinds key themselves.
*
* Built from the content tables rather than restated, so a card renamed there is renamed here and
* the two cannot drift — which is the whole argument of TODO #15a, applied to names.
*/
/**
* The printed name of a card that has one, by its content key, or null.
*
* Exported so the LOG can reach the same table the buttons do. `narrate.ts` rendered its own name
* from the key (`absSignals` → "abs Signals") and so disagreed with the action list one line above
* it; it takes this through its `enhancementName` resolver now.
*/
export function simpleCardName(key: string): string | null {
return SIMPLE_CARD_NAMES.get(key) ?? null;
}
const SIMPLE_CARD_NAMES: ReadonlyMap<string, string> = new Map(
[
...SPACE_USE_CARDS,
...ENHANCEMENT_CARDS,
...MAINLINE_MODIFIER_CARDS,
...MANEUVER_CARDS,
...ACTION_CARDS,
].map((c) => [c.key, c.name]),
);
/**
* What a card actually DOES, in one line.
*
@@ -1894,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.
@@ -1937,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}`;
}
}
}
@@ -2364,6 +2543,9 @@ function trainChip(s: GameState, id: string): TrainChip {
engineAt: at,
facing: railFacingOf(t),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
// Conditional spread, not `beingMadeUp: isBeingMadeUp(t)`: the field is optional-and-true, and
// `exactOptionalPropertyTypes` refuses an explicit `false` for it.
...(isBeingMadeUp(t) ? { beingMadeUp: true as const } : {}),
};
}
+259 -27
View File
@@ -29,7 +29,7 @@ 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 { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
import type { CardId, GameConfig, GameState, GridCoord, PlayerIndex } from '../engine/state.ts';
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
import { playerAtSeat } from '../engine/state.ts';
import { cuesFor, narrate } from '../sim/narrate.ts';
@@ -37,31 +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,
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';
/**
@@ -237,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.
*
@@ -321,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;
}
@@ -343,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).
*
@@ -358,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;
}
@@ -663,6 +684,14 @@ export type Menu = {
makeUp: {
trayId: string;
title: string;
/**
* What the card still wants, after what is already coupled up — "1 boxcar/hopper + 1 caboose".
*
* The title says what the card CALLS FOR and never changes as cars go on, so a player had to
* diff it against the consist drawn on the Division map, in the other column. Null when the
* train is complete and only the send-it-out button is left.
*/
needs: string | null;
cars: MakeUpAction[];
pass: number | null;
/**
@@ -803,6 +832,7 @@ export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
? {
trayId: filling,
title: consistTitle(game, filling) ?? 'Making up the train',
needs: consistNeeds(game, filling),
cars: makeUpCars,
pass,
advice: makeUpAdvice(game, filling, makeUpCars),
@@ -955,6 +985,55 @@ function consistTitle(game: Game, trayId: string): string | null {
return trainCardTitle(tray.trainNumber ?? 0, tray.trainIsExtra);
}
/**
* WHAT THE TRAIN STILL WANTS — the card's demand minus what is already on it.
*
* The heading says "its card calls for 3 boxcar/hopper + 1 caboose" and goes on saying it whether
* you have added none or three; the cars themselves are drawn on the Division map, in the other
* column. So the one question a player actually has while clicking — what is left? — was the one
* thing on screen that had to be worked out by eye, across two panels (Jesse, playtest 2026-09-16).
*
* BY CATEGORY, exactly as `acceptsCar` counts them, so this cannot promise a car the engine would
* then refuse. Null when nothing is outstanding.
*/
function consistNeeds(game: Game, trayId: string): string | null {
const tray = game.state.trays.get(trayId);
if (!tray) return null;
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
if (!p) return null;
const cat = (t: string): 'coach' | 'caboose' | 'freight' =>
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
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[] = [];
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;
}
/**
* "Making up Extra X22 “Pee-Dee”: its card calls for 1 caboose — Per-diem train…"
*
@@ -976,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}`;
}
/**
@@ -1113,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);
@@ -1226,6 +1319,81 @@ function uncapitalise(text: string): string {
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
}
/**
* The Facility on one of a player's squares, or null — for naming the place a switching line is
* about. Shared by the narrator and the filter below, so both agree on what counts as an industry.
*/
function facilityOn(game: Game, player: PlayerIndex, at: GridCoord): string | null {
const card = areaOf(game.state, player).grid.get(`${at.row},${at.col}`);
const f = card?.facility;
// The name printed on the card, not the internal key: `industryProfile` is the one place that
// knows "grocersWarehouse" reads as "Grocer's Warehouse".
if (f) return f.subtype === 'office' ? 'the Office' : `the ${industryProfile(f.subtype).name}`;
/**
* A SMALL YARD IS A PLACE TOO, though it is an enhancement on a plain card rather than a Facility.
*
* It is the one square in a district a crew goes to ON PURPOSE without working an industry — the
* whole point of the trip is to arrive there and re-make the train — so "leaving Train 10 at
* (-1,1)" was the one line most in need of a name. Only this enhancement: the others change what a
* square DOES without being somewhere a player aims a crew at.
*/
return card?.enhancements.includes('smallYard') ? 'the Small Yard' : null;
}
/**
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL (Jesse, 2026-09-17).
*
* All of it did. A six-Move turn wrote a line per move — "Moved Train 10 (0,3) → (-1,-3) — 4 of 6
* Moves left" — plus one per mandatory coupling, so two players shunting filled the panel with
* coordinates and pushed everything else off the top. His ruling, asked as a question from the
* table: a line saying somebody switched, the cars they set out at or picked up from an INDUSTRY,
* and the Small Yard sort. Not every move, and not every coupling.
*
* WHAT STAYS, and why each one earns its line: `localOpsOptionChosen` already says who is switching
* and is left alone; work at an industry is the point of switching and changes what can be loaded
* next; and `consistSorted` spends a Move and changes what the train can do. A plain move along
* one's own track changes nothing anybody needs to read back.
*
* THE LINE IS STILL WRITTEN, MARKED `trace`, AND THAT IS NOT A DETAIL. Dropping these events on the
* floor was the first attempt and the suite caught it: `dwellForStep` gives a step NO dwell when it
* produced no narration, so a switching move with no line became a silent step and the board stopped
* replaying switching altogether — it would have snapped through the very thing v0.8.0 was built to
* let the table watch. The line still rides with its display step and still captions the board as
* the move goes up; only the history panel skips it.
*
* THE REPLAY VIEWER IS UNAFFECTED for the same reason, and it renders through `narrate` directly.
*/
function inHistory(game: Game, e: GameEvent): boolean {
const industry = (player: PlayerIndex, ...coords: GridCoord[]): boolean =>
coords.some((c) => facilityOn(game, player, c) !== null);
switch (e.type) {
/**
* THE FIRST MOVE OF A TURN IS KEPT (Jesse, 2026-09-17: "also keep the first and last move").
*
* It says a crew set off and from where, which is the half of "somebody switched" that the
* opener does not carry. The LAST move cannot be kept the same way — nothing knows a move was
* the last until the turn is over, and by then the line has already been written and streamed to
* every client (`server/session.ts` § linesSince), so it cannot be revised. `switchingEnded`
* carries it instead, as the line that closes the turn.
*
* Recognised by the MOVE COUNT rather than by tracking state: the first move of a turn is the
* one that leaves `movesAllowed - 1` behind it, which the event now carries so this holds on a
* five-Move night Stage too.
*/
case 'trayMoved':
return e.movesRemaining === e.movesAllowed - 1;
// Coupling is mandatory when a crew runs over cars (§A.4), so most of these happen to a player
// rather than being chosen. The ones worth reading are where cars left or joined an industry —
// `from` names the cards the cars were actually lifted off, which is where they had been spotted.
case 'carsCoupled':
return industry(e.player, e.at, ...e.from);
case 'carsDropped':
return industry(e.player, e.at);
default:
return true;
}
}
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
for (const e of events) {
@@ -1234,6 +1402,27 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
const n = narrate(e, {
cardName: (id) => cardName(game.state, id),
trainName: (id) => trainName(game.state, id),
facilityAt: (player, at) => facilityOn(game, player, at),
/**
* Which Mainline card a Division node holds. The log used to print the node INDEX alone —
* "on Mainline card 7" — leaving the reader to remember what card 7 was (Jesse, 2026-09-22).
* The index is kept beside the name because a division can deal two Plains, and then the slot
* is the only thing that distinguishes them.
*/
mainlineAt: (node) => {
const n = game.state.division.nodes[node];
return n?.kind === 'mainline' ? mainlineProfile(n.card).name : null;
},
// The printed name, for the same reason `cardName` stopped trusting `prettyKey` in v0.8.0.17:
// no key-splitter recovers "ABS Signals" from `absSignals`.
enhancementName: (key) => simpleCardName(key),
// Whose district a train reached is not the actor — the Mainline Phase has none — so the
// narration resolves the name itself rather than being prefixed with one by the code below.
// NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate
// under Employee Rotation, which is why `seatOf` exists — so "Seat 3" here would be a wrong
// number dressed as a right one, and `session.test.ts` rightly refuses any raw index shown to
// a person. Every caller passes real names; an unnamed player is anonymous rather than mislabelled.
playerName: (p) => game.state.players[p]?.name ?? 'another player',
});
/**
* A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1).
@@ -1255,9 +1444,33 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
// one seat. Only events the player caused are attributed; the Division running itself is not.
const mine = who !== null && 'player' in e;
const text = mine ? `Player ${who} ${uncapitalise(said)}` : said;
game.log.push({ text, tone: mine ? 'act' : n.tone });
/**
* A RULING IS MADE AS SUPERINTENDENT, NOT AS YOURSELF (playtest, 2026-09-15: "maybe it could say
* 'Superintendent Player Tom', so it's clear they got the move because they're Superintendent").
* These three are the only moves a player makes out of turn, by holding the office: §8.1's
* clearance, §11's Yard Office offer and §Q's Red Flag prompt. `clearanceGiven` carries no
* player at all — the office made it, whoever holds it — so the actor is what names it.
*/
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
const ruling = RULINGS.includes(e.type) && who !== null;
/**
* 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
? `Player ${who} ${uncapitalise(said)}`
: 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.
pushLine(game, text, inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace');
}
game.cues.push(...cuesFor(events));
@@ -1274,9 +1487,28 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
}
/**
* THE FEDORA MOVING IS ANNOUNCED, NOT JUST LOGGED (playtest, 2026-09-16).
*
* It is the one thing in the game that changes hands on the clock rather than because somebody
* did something, so nobody is watching for it — and it decides who rules on clearances and who
* every round starts with. A line in the history is where you find it afterwards; this is what
* tells the table as it happens, the same treatment a completed run already gets.
*/
if (e.type === 'superintendentChanged') {
const name = game.state.players[e.player]?.name ?? 'the next player';
game.announced = `${name} is now the Superintendent — the Fedora passed at the end of Stage ${e.stage}.`;
}
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
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);
}
// ---------------------------------------------------------------------------
@@ -1307,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 });
}
/**
+10
View File
@@ -27,6 +27,11 @@ h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
/* Not a fourth door: reading the guide is not a way to play, and giving it equal weight in the
grid would say it is. A line under the doors, where somebody who does not know what to click
will already be looking. */
.newhere{margin:16px 2px 0;color:var(--dim);font-size:13px;line-height:1.55}
.newhere a{color:#9fb6d8}
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
.door.disabled .go{color:var(--dim)}
a.door.disabled{pointer-events:none}
@@ -96,6 +101,11 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</a>
</div>
<p class="newhere">New to Station Master?
<a href="./quickstart.html">Read the Quickstart guide</a> &mdash; what the game is, how you win,
how a Stage runs, what is on the screen, and a first twenty minutes. About twenty minutes to
read, and it will save you an hour of guessing.</p>
<div class="rule"></div>
<footer>
+20 -3
View File
@@ -107,7 +107,8 @@ function explain(code: unknown, fallback: string): string {
return messages[key] ?? (key !== '' ? key : fallback);
}
async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
/** Exported for `main.ts`'s seat-recovery path (Gitea#33), so there is one JSON POST on this page. */
export async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -130,6 +131,22 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
// stub only part of the DOM. Bail rather than throwing through the module's caller.
if (!has('lobby') || !has('lb-choice-section')) return;
$('lobby').hidden = false;
/**
* THE TWO DOORS COME BACK EVERY TIME THIS SCREEN DOES.
*
* `enterSeating` hides this section, and until v0.8.1.0 the only things that put it back were the
* lobby's OWN leave paths. Leaving a running game is a different route — `main.ts`'s Leave game
* button calls `runLobby` again — so the section stayed hidden from the previous seating and the
* screen came back holding nothing but "Games you are in". Reported 2026-09-22: "I see the games
* that I'm in, and I have the ability to rejoin or forget. But what if I want to join a different
* game or start a new one? How do I get back there?" There was no way: the doors were on the
* page, display:none, with no control that could reveal them.
*
* Reset here rather than in the leave handler, because this function is the one thing every route
* onto the lobby goes through — which is exactly why the two that did it themselves missed a third.
*/
$('lb-choice-section').hidden = false;
if (has('lb-seating-section')) $('lb-seating-section').hidden = true;
const form = settingsForm('lb-');
let source: EventSource | null = null;
@@ -404,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;
@@ -549,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);
};
/**
+613 -46
View File
@@ -25,8 +25,9 @@ import type { LocalSession, Session } from './session.ts';
import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts';
import { createStepQueue } from './step-queue.ts';
import { notice, prefillCode, runLobby } from './lobby.ts';
import { actorOnScreen, createStepQueue } from './step-queue.ts';
import { PACE_LEVELS } from '../sim/pacing.ts';
import { notice, postJson, prefillCode, runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts';
import {
closestPreset,
@@ -53,6 +54,7 @@ const REMOTE_KEY = 'station-master.remote.v1';
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
/**
* Small persisted preferences, kept in a `localStorage` key of their own — separate from
* `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
@@ -147,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.
*/
@@ -165,7 +167,12 @@ let session: Session;
* on the next step instead of the next game. `?pace=` wins over the saved setting for this session
* only.
*/
const stepQueue = createStepQueue(() => PACE_OVERRIDE ?? settings.pace);
const stepQueue = createStepQueue(
() => PACE_OVERRIDE ?? settings.pace,
// Whose moves not to bother replaying — this client's own. Read lazily: `session` is assigned when
// a game starts, long after this queue is built.
() => (session ? session.seat() : null),
);
/**
* Pulls whatever the session has for us into the queue. Called on every push, before rendering.
@@ -177,6 +184,20 @@ function drainIntoQueue(): void {
const reset = session.takeDisplayReset();
if (reset) stepQueue.reset(reset);
stepQueue.push(session.takeDisplaySteps());
/**
* NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL.
*
* Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for
* good — and since "Your Move" is now put away while the board is catching up, that would hide a
* player's own actions permanently, leaving Skip as the only way to play the game. Drawing
* everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback
* rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same
* stub that has been proving this page still starts since long before any of this existed.
*/
if (typeof requestAnimationFrame !== 'function') {
stepQueue.skip();
return;
}
if (stepQueue.busy()) startAnimationLoop();
}
@@ -194,17 +215,25 @@ function drainIntoQueue(): void {
* drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and
* has never needed a private viewer.
*/
function renderWatching(): void {
function renderWatching(f?: Frame): void {
const behind = stepQueue.behind();
const row = $('watching');
// Collapsed whenever the board is level with the game — which in solitaire is nearly always, and
// between turns in multiplayer too. A row that is always there would be a row nobody reads.
if (behind === 0) {
/**
* VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING.
*
* It used to hide the moment `behind` hit zero — which is the moment the LAST step of a burst goes
* up, so the one step a player was most likely to be reading about lost its caption. Collapsed
* otherwise: in solitaire that is nearly always, and between turns in multiplayer too, and a row
* that is always there is a row nobody reads.
*/
if (behind === 0 && !stepQueue.busy()) {
row.hidden = true;
return;
}
row.hidden = false;
$('watching-behind').textContent = `${behind} behind`;
// A held queue stops counting down, so the counter has to say why rather than look stuck.
$('watching-behind').textContent =
(behind === 0 ? 'catching up' : `${behind} behind`) + (stepQueue.paused() ? ' · paused' : '');
/**
* THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
*
@@ -213,8 +242,29 @@ function renderWatching(): void {
* The queue IS that, so the caption simply names the step being shown, and the counter beside it
* says how much of the wait is left.
*/
/**
* WHO, THEN WHAT — Jesse, 2026-09-09: *"it didn't tell me what the actual action was, like who I
* was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I was
* supposed to be looking for."*
*
* The caption was there; it was the wrong half of the sentence. Half the waiting is automatic
* phases, whose narration reads "Mainline" — accurate, and no answer at all to "who am I waiting
* on". So the name goes first, and a phase says so in as many words rather than leaving the reader
* to infer that nobody is acting.
*
* The narrated line is used as it stands otherwise, because `record()` already prefixes it with the
* player — "Player Bot 1 moved Train 3 (−1,−2) → (−1,1)" — so a second name would stutter.
*/
const showing = stepQueue.showing();
$('watching-what').textContent = showing?.lines[0]?.text ?? '';
const said = showing?.lines[0]?.text ?? '';
const who =
showing === null || showing === undefined
? ''
: showing.player === null
? 'The Division'
: (f?.players[showing.player]?.name ?? `Seat ${seatLabel(showing.player)}`);
// A player action already names its actor; a phase does not, so it is introduced.
$('watching-what').textContent = showing?.player === null && said !== '' ? `${who}: ${said}` : said;
/**
* SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION.
*
@@ -225,6 +275,56 @@ function renderWatching(): void {
$('watching-skip').onclick = () => {
if (stepQueue.skip()) render();
};
/**
* PAUSE IS SKIP'S OPPOSITE, and shares its row for that reason.
*
* The label says what pressing it DOES, so it flips to Resume while held — the same rule the
* district's three-mode control settled on, for the same reason: a label that reports state reads
* as a status line and gets skipped over.
*
* `performance.now()` because that is the clock `requestAnimationFrame` hands `advance()`; mixing
* in `Date.now()` would shift the deadline by the page's whole lifetime. Guarded because the
* static build is loaded head-first against a DOM stub with no `performance`.
*/
const pauseBtn = $('watching-pause');
pauseBtn.textContent = stepQueue.paused() ? 'Resume' : 'Pause';
pauseBtn.onclick = () => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (stepQueue.paused()) {
stepQueue.resume(now);
// The loop exits whenever the queue stops being busy; restart it rather than assume it survived.
startAnimationLoop();
} else {
stepQueue.pause(now);
}
render();
};
}
/**
* THE TABLE AS IT IS ON SCREEN — the Day, the Stage, the clock, the phase and the Fedora.
*
* "WHEN PLAYER JESSE IS 5 BEHIND, IT SHOULD ALWAYS LOOK LIKE HE'S 5 BEHIND" (playtest, 2026-09-16).
* The board, the district panel and the caption row have followed the queue since v0.8.0; the turn
* chart never did. So a player watching three bots play out a Stage saw their cards moving under a
* chart that had already ticked over to the next phase — the one part of the screen quietly
* insisting the game was somewhere else. Being behind is fine and is stated plainly by the counter;
* being behind on some of the screen and level on the rest is what makes it unreadable.
*
* Only while the queue is actually behind. At rest this IS the live frame, so nothing downstream
* needs to know which of the two it was handed.
*/
function shownTable(f: Frame): Pick<Frame, 'day' | 'stage' | 'clock' | 'phase' | 'phaseKey' | 'superintendent'> {
const pub = stepQueue.current();
if (!pub || !stepQueue.busy()) return f;
return {
day: pub.day,
stage: pub.stage,
clock: pub.clock,
phase: pub.phase,
phaseKey: pub.phaseKey,
superintendent: pub.superintendent,
};
}
function watchedDistrict(f: Frame): PublicDistrict | null {
@@ -237,6 +337,18 @@ function watchedDistrict(f: Frame): PublicDistrict | null {
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
* which keeps the board where it was instead of snapping home mid-sequence.
*/
/**
* A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`)
* — INCLUDING A LOOK AT YOUR OWN BOARD, which is why this returns rather than falling through.
*
* Falling through sent "show me mine" to the actor logic below, so the one player who could not
* reach their own Office Area was the player waiting on everybody else (playtest, 2026-09-16: Tom,
* hanging about while the board followed Jesse). Null IS your own district: it is what the caller
* draws from `f.cells` when nobody else is being watched.
*/
if (peekPlayer !== null) {
return peekPlayer === f.viewer ? null : (pub.districts.find((d) => d.player === peekPlayer) ?? null);
}
let player: PlayerIndex | null = f.actor;
if (stepQueue.busy()) {
const acting = stepQueue.showing()?.player;
@@ -282,12 +394,25 @@ function startAnimationLoop(): void {
*/
console.error('display queue stopped:', err);
animating = false;
// Do not strand the player behind a queue that can no longer advance: jump the board to the
// live position, which brings "Your Move" back with it.
try {
stepQueue.skip();
} catch {
/* nothing further to try — the authoritative Frame is still what the rest of the page draws */
}
render();
return;
}
if (!stepQueue.busy()) {
animating = false;
// One last render so the "N behind" row collapses the moment the board is level.
renderWatching();
/**
* A FULL RENDER, not just the row. The board being level again is what brings "Your Move"
* back and clears the last lit pile, so redrawing only the catching-up row would leave the
* action list hidden until something else happened to trigger a render — which, when the game
* is waiting on this player, is nothing at all.
*/
render();
return;
}
requestAnimationFrame(tick);
@@ -369,6 +494,22 @@ let lastDay: number | null = null;
* ones. Cleared whenever the named crew stops being one of the choices.
*/
let selectedCrew: string | null = null;
/**
* A ONE-RENDER LOOK AT SOMEBODY ELSE'S OFFICE AREA — Jesse, playtest 2026-09-16.
*
* Deliberately NOT a mode. It survives exactly the render its own click causes and is cleared at the
* end of `renderDistrict`, so the panel is back to following whoever is acting the next time
* anything redraws. That is the whole design, in his words: *"if you want to study someone else's
* office area, you should do it while it's your turn to move, or put the backlog on pause, then look
* at their area, and when you're done looking, resume."*
*
* A sticky pin would have to answer what happens when the game moves on beneath it — and the honest
* answers are all bad: silently snap home, or leave a player staring at a stale board with the game
* waiting on them. Pause already means "hold everything", so it is the right lever for a long look,
* and this stays a glance.
*/
let peekPlayer: PlayerIndex | null = null;
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
/** The crew whose squares the board is drawing: the chosen one, or the only one there is. */
@@ -432,12 +573,28 @@ function piecePreview(links: string[], label: string): string {
* are in the Day the same way and with the same violet highlight. It used to live here alone.
*/
function renderTurnChart(f: Frame): void {
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
// The move on screen, not the live one, while the board is still catching up (Gitea#25).
const { actor, replaying } = actorOnScreen(stepQueue, f.actor);
const actorName = actor === null ? null : (f.players[actor]?.name ?? null);
// The Day, Stage and phase of the step being shown, so the whole screen reports one moment.
const table = shownTable(f);
// Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a
// chip that can never change is a chip to read past.
const superName =
f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null;
$('turnchart').innerHTML = turnChartHtml(f, actorName, 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,
);
}
/**
@@ -503,9 +660,47 @@ function renderGameCard(f: Frame): void {
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
$('gamecardbody').innerHTML =
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
rulesListHtml(config, players, f.days);
rulesListHtml(config, players, f.days) +
GUIDE_HTML;
}
/**
* THE DOCUMENTATION, REACHABLE FROM INSIDE A GAME — asked for directly from a table, 2026-09-21:
* "how can we link the documentation so it can be reached from the gameplay, whether someone is
* playing solitaire or multiplayer?"
*
* IT NEEDS NO MODE AWARENESS, which is the whole reason this is three lines rather than a feature.
* Solitaire and multiplayer are the same page on the same origin — `play.html?solitaire` and
* `play.html?lobby` — and the build publishes the documents beside it, so one relative link
* resolves identically in both, on the public site and on a StartOS box alike.
*
* IN THIS CARD RATHER THAN THE HEADER (Jesse's call, 2026-09-21), for the reason he gave the top
* line in the first place on 2026-08-30: it is "not something that they're likely to need all the
* time", and the header is the line that must not wrap. The card is already where reference lives
* — the seed, the seat, the house rules — and it holds the whole set rather than one door.
*
* NEW TAB, every one of them: a player reading the rules mid-turn must not lose the game behind
* them. `rel="noopener"` because `target="_blank"` without it hands the opened page a handle back.
*
* Built once at module scope — it never varies, and rebuilding it on every frame would be work
* nobody sees, the same reasoning the folded body above already follows.
*/
const GUIDE_DOCS: readonly { href: string; label: string; what: string }[] = [
{ href: './quickstart.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 =
`<div class="guide"><h4>Guide</h4><p>` +
GUIDE_DOCS.map(
(d) =>
`<a href="${esc(d.href)}" target="_blank" rel="noopener" data-tip="${esc(d.what)}">${esc(d.label)}</a>`,
).join(' · ') +
`</p></div>`;
/**
* THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30).
*
@@ -939,6 +1134,7 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
// banner (`#presence`), and it holds a beat so the game visibly begins.
openHandoff();
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
remoteToken = ready.token;
rejoiningRemote = rejoining;
applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
@@ -984,9 +1180,66 @@ function abandonRemote(): void {
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the
* common case and the only one a bare page load has ever needed a decision for), or the lobby.
*/
/**
* A SEAT RECOVERY LINK — Gitea#33.
*
* The token is the only identity this game has, and it lives in one browser's `localStorage`. Lose
* that and the seat is unreachable: nothing else on the server will accept a claim to it. This is the
* supported way back — an administrator mints a short-lived, single-use code (`server/claims.ts`) and
* the player opens a link carrying it.
*
* THE LINK CARRIES A CODE, NEVER THE TOKEN. `lobby-and-sessions.md` §1 says to keep the token out of
* URLs so it is not shoulder-surfed or pasted into a chat — and a recovery link is precisely the sort
* of thing that ends up in a chat. So the code is traded for the token here, over the connection the
* page was going to open anyway, and is dead the moment it is spent.
*
* THE CODE IS STRIPPED FROM THE URL EITHER WAY, so a reload does not re-spend a code that is already
* gone and the address bar stops carrying a credential-shaped string. `replaceState` rather than
* assigning `location.search`, which everywhere else on this page means "navigate" — it reloads, and
* reloading is exactly what must not happen to the session we have just been handed. Guarded like
* `requestAnimationFrame` and `performance` are, because the static build is imported head-first by
* `test/web.test.ts` against a DOM stub that provides neither.
*/
async function claimSeat(code: string): Promise<void> {
showScreen('lobby');
const { status, body } = await postJson('/api/claim', { code });
if (typeof history !== 'undefined' && typeof history.replaceState === 'function') {
history.replaceState(null, '', location.pathname);
}
if (status !== 200) {
runLobby(lobbyHandlers);
notice(
'That restore link has already been used, or it has expired. Ask whoever runs the server for a ' +
'fresh one — each link works once.',
);
return;
}
// `beginRemote` writes the seat into this browser's storage itself, which is the whole point of
// the exercise: the next ordinary reload finds it and goes straight back into the game.
beginRemote(
{
token: body['token'] as string,
gameId: body['gameId'] as string,
gameCode: (body['gameCode'] as string | undefined) ?? '',
seat: body['player'] as PlayerIndex,
},
true,
);
}
function start(): void {
const params = new URLSearchParams(location.search);
/**
* A RECOVERY LINK OUTRANKS EVERYTHING, including a game this browser already remembers: someone
* arriving on one is being handed a seat deliberately, and that is never the load to second-guess.
*/
const claimCode = params.get('claim');
if (claimCode !== null && claimCode !== '') {
void claimSeat(claimCode);
return;
}
/**
* ASKING FOR THE LOBBY BEATS RESUMING A GAME.
*
@@ -1106,6 +1359,14 @@ function start(): void {
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
* question "why not?" every turn, and the honest answer is that the control does not belong there.
*/
/**
* The session token of a server-backed game, or null in solitaire (playtest, 2026-09-15: "most of the
* time, I want to go ahead and just save it as a JSON file"). It is the seat's proof of identity to
* `/api/save`, exactly as it is to `/api/stream` — a save is the seed and the moves, every one of which
* is already on this player's screen.
*/
let remoteToken: string | null = null;
function applyCapabilities(): void {
const c = session.capabilities;
const hide = (id: string, on: boolean): void => {
@@ -1113,7 +1374,7 @@ function applyCapabilities(): void {
if (el) el.hidden = !on;
};
hide('undo', c.undo);
hide('savefile', c.saveLocal);
hide('savefile', c.saveLocal || remoteToken !== null);
hide('newgame', c.newGame);
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
// page offers — same reasoning as `newgame`, and the same capability answers both.
@@ -1236,6 +1497,19 @@ function render(): void {
const f = session.view();
const menu = session.menu();
noteFirstFrame(f, rejoiningRemote);
/**
* A COMPETITIVE SAVE WAITS FOR THE END (Jesse's ruling, TODO #117). The file carries the seed,
* which is every rival's hand, so `/api/save` refuses it while the game runs — and the button
* says so here rather than silently doing nothing when clicked.
*/
const saveBtn = document.getElementById('savefile') as HTMLButtonElement | null;
if (saveBtn && !isLocal(session)) {
const locked = f.mode === 'competitive' && f.status !== 'finished';
saveBtn.disabled = locked;
saveBtn.title = locked
? 'In a Competitive game the save file carries the whole deal, so it can be downloaded once the game is over'
: 'Download this game as a save file you can replay or share';
}
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
@@ -1257,7 +1531,7 @@ function render(): void {
renderTurnChart(f);
renderPresence(f);
renderWatching();
renderWatching(f);
$('revenue').textContent = String(f.revenue);
/**
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
@@ -1286,8 +1560,10 @@ function render(): void {
// -- division
$('division').innerHTML = divisionSvg(f.division, {
players: f.players,
actor: f.actor,
actor: actorOnScreen(stepQueue, f.actor).actor,
viewer: f.viewer,
// A Realignment changes the Division under everyone; flashed only while the step that did it is up.
flash: stepQueue.busy() ? stepQueue.flashing() : [],
});
renderSeatingChain(f);
applyZoom($('division'));
@@ -1477,7 +1753,9 @@ function render(): void {
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
$('depts').innerHTML = pilesHtml(f);
// The pile the move being WATCHED just touched, lit for as long as that step is on screen. Empty
// whenever the board is level with the game, or when the move was this player's own.
$('depts').innerHTML = pilesHtml(f, stepQueue.busy() ? stepQueue.lit() : []);
renderYards(f);
@@ -1538,8 +1816,17 @@ function render(): void {
$('depts').classList.remove('aiming');
}
// -- making up a train: the Division Yard chip that shows the car IS the button.
if (menu.makeUp) {
/**
* -- making up a train: the Division Yard chip that shows the car IS the button.
*
* NOT WHILE THE BOARD IS BEHIND (playtest, 2026-09-16). `renderActions` puts the action list away
* while the queue is catching up — a move offered against a position that has already moved on is
* a move made blind — but this wiring sat outside that guard, so the yard chips stayed lit and
* clickable. Jesse clicked one during a bot's make-up, a coach left the yard, and the train ended
* up with three cars: he had submitted a real intent against a board he could not see. The chips
* follow the same rule as every other control now.
*/
if (menu.makeUp && !stepQueue.busy()) {
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
const node = el as HTMLElement;
const car = menu.makeUp!.cars.find(
@@ -1570,8 +1857,36 @@ function render(): void {
// -- log
const log = $('log');
const allLines = session.lines();
const shownLines = allLines.slice(-60);
/**
* THE LOG IS HELD BACK WITH THE BOARD (playtest, 2026-09-15).
*
* A push carries its narration and its display steps together, so every line of a bot's turn was in
* this panel before the board had drawn a single move of it — the history ran ahead of the "N behind"
* counter it is meant to match. Those lines are the TAIL of the log, so exactly the ones belonging to
* steps still queued are withheld, and each appears as its step goes up.
*/
const heldBack = stepQueue.pendingLines();
/**
* HELD BACK FIRST, THEN THE TRACE LINES DROPPED — the order matters.
*
* `pendingLines` counts lines in the log, including the `trace` ones a switching move writes for
* its caption (`web/game.ts` § inHistory), so the tail has to be cut off the RAW list or the
* arithmetic slips and the panel runs ahead of the board. Filtering afterwards only decides what
* is drawn.
*/
const allLines = (heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines()).filter(
(l) => l.tone !== 'trace',
);
/**
* NINETY LINES, IN THE SAME BOX (Jesse, 2026-09-16: "increase to 90, keep the box the same size").
*
* The panel scrolls already, so a longer tail costs no screen and lets a player scroll further
* back through a Stage they were not watching. It is capped at all only because the list is
* rebuilt on every render; the log itself is uncapped in memory, so the number is a display
* choice rather than a limit. The BOX stays 230px on purpose — growing it would push the newest
* line, the one being read, further from where the eye already is.
*/
const shownLines = allLines.slice(-90);
/**
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
* by the time the board paints the log already has several turns in it and nothing says which of
@@ -1694,31 +2009,81 @@ function renderUndo(): void {
* left, and the moment the Division Yard empties a whole pile comes back at once.
*/
function renderYards(f: Frame): void {
$('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
/**
* THE YARDS BELONG TO THE BOARD ON SCREEN, NOT TO THE GAME (playtest, 2026-09-16).
*
* They were drawn from the live Frame while everything around them was held back, so a player
* five moves behind read yard counts from a future they had not been shown — "the yards may not
* be in sync with the turns behind", and they were not. Same rule as the turn chart: while the
* queue is behind, this is the shown board's yards; at rest the two are the same object.
*/
const pub = stepQueue.current();
const yards = pub && stepQueue.busy() ? pub.yards : f.yards;
$('divyard').innerHTML = yardHtml(yards.division);
$('clsyard').innerHTML = yardHtml(yards.classification);
$('divtot').textContent = `${yards.divisionTotal} cars`;
$('clstot').textContent = `${yards.classificationTotal} cars`;
// The one thing worth calling out: the yard about to turn over.
const bare = f.yards.divisionTotal === 0;
/**
* The one thing worth calling out: the yard about to turn over — or the one that never will.
*
* THE COACH CASE IS ITS OWN WARNING (Jesse's ruling, 2026-09-17: say it loudly, change no rule).
* Coaches travel one way. §9.2 boarding discards the emptied coach into the CLASSIFICATION Yard
* and detraining draws a fresh empty out of the Division Yard, and §2.2 sends Classification back
* only when the Division Yard runs bare. Measured over one three-Day game: sixteen coaches at
* setup, none left by Day 2 Stage 8, fifteen piled in Classification, and the Division Yard
* holding steady at 46-47 freight cars — so the refill will not fire before the game ends.
*
* Every consequence of that is silent on the board. A Depot simply offers no passenger action, and
* the Sparrow — three coaches and nothing else — is made up empty and runs the whole Division that
* way. The cars are in plain sight in Classification; what cannot be seen is that they are not
* coming back. So the panel says it where the pile is.
*/
const held = (row: { type: string; loaded: number; empty: number }[], type: string): number => {
const r = row.find((c) => c.type === type);
return r ? r.loaded + r.empty : 0;
};
const bare = yards.divisionTotal === 0;
const coachesStranded = !bare && held(yards.division, 'coach') === 0 && held(yards.classification, 'coach') > 0;
$('divyard').classList.toggle('bare', bare);
$('yardnote').textContent = bare
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
? `The Division Yard is bare — the ${yards.classificationTotal} cars in Classification return to it now.`
: coachesStranded
? `NO COACHES in the Division Yard — all ${held(yards.classification, 'coach')} are in Classification, ` +
`which comes back only when the Division Yard is bare, and it still holds ${yards.divisionTotal} cars. ` +
'Until then no passengers can board or detrain, and a train whose card calls for coaches is made up empty.'
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
}
function renderDistrict(f: Frame): void {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
// The phase ON SCREEN, so the panel opens for the Local Operations being WATCHED rather than for
// one the game has already moved past — same rule as the turn chart, see `shownTable`.
const open =
districtMode === 'auto' ? FOCUS_PHASES.has(shownTable(f).phaseKey) : districtMode === 'open';
const sec = $('district');
if (open) sec.classList.remove('folded');
else sec.classList.add('folded');
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
/**
* THE SUMMARY COUNTS THE BOARD ON SCREEN, WHICH IS NOT ALWAYS YOUR OWN.
*
* The panel has drawn somebody else's district since v0.8.0 — `watchedDistrict` follows whoever is
* acting — and the heading beside this line says whose it is. The counts were read from `f`, the
* viewer's own Frame, every time: so while a bot's turn played out, the header read "Bot 2's Office
* Area" over a board of Bot 2's cards, with a summary counting YOUR cards, facilities and trains
* (Jesse, playtest 2026-09-16 — "the hidden office summary line describes my district, not the one
* being shown"). One source for the drawing and the counting, so the two cannot disagree again.
*/
const watched = watchedDistrict(f);
const cells = watched?.cells ?? f.cells;
const facilityCount = watched ? watched.facilities.length : f.facilities.length;
const cars = cells.reduce((n, c) => n + c.cars.length, 0);
// Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a
// card-count silently read "1 crew on the board" with two trains standing at a busy Station.
const crew = f.cells.reduce((n, c) => n + c.trains.length, 0);
const crew = cells.reduce((n, c) => n + c.trains.length, 0);
$('districtsummary').textContent =
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
`${cells.length} cards · ${facilityCount} facilities · ${cars} cars standing` +
(crew > 0 ? ` · ${crew} crew on the board` : '');
/**
@@ -1750,6 +2115,55 @@ function renderDistrict(f: Frame): void {
render();
};
}
/**
* ONE BUTTON PER OPPONENT, and nothing at a table of one.
*
* Built with `createElement` and `textContent` rather than interpolated into `innerHTML`, because
* a player's NAME is whatever they typed in the lobby — the one string on this page that comes
* from another person, and so the one that must never be pasted into markup.
*/
const peek = $('districtpeek');
/**
* EVERY SEAT, YOURS INCLUDED, IN MAP ORDER (playtest, 2026-09-16).
*
* Two faults, both reported from one game. There was no button for your OWN district, so a player
* waiting on everybody else could look at any board except the one they were playing — and the
* buttons came out in player order, which is the order people joined, not the order they sit.
*
* Sorted by SEAT, which is west-to-east along the Division exactly as the map draws it, so the row
* reads left to right the way the railroad does. Seat is not player index and must not be assumed
* to be: under Employee Rotation the seating moves, and because this sorts the Frame's own `seat`
* on every render, the buttons rotate with the players rather than having to be told.
*/
const seats = [...f.players].sort((a, b) => a.seat - b.seat);
peek.innerHTML = '';
peek.hidden = seats.length < 2;
const watchedNow = watchedDistrict(f);
for (const p of seats) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ghost';
// NAMES GO IN AS TEXT, NEVER MARKUP: a display name is whatever somebody typed in the lobby.
b.textContent = p.name;
const isYou = p.index === f.viewer;
// Which board is up right now — yours when nothing is being watched, otherwise the watched one.
const showing = watchedNow === null ? f.viewer : watchedNow.player;
// `.seg button[aria-pressed="true"]` already lights the current one — no extra class to style.
b.setAttribute('aria-pressed', String(p.index === showing));
b.title = isYou
? 'Back to your own Office Area.'
: `Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
`redraws — press Pause first if you want to study it.`;
b.onclick = () => {
peekPlayer = p.index;
render();
};
peek.appendChild(b);
}
// SPENT. The look lasted the render it asked for; the next one follows the game again.
peekPlayer = null;
}
/**
@@ -1951,6 +2365,29 @@ function renderActions(
renderEnding(el, f);
return;
}
/**
* YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should
* be hidden while catching up."*
*
* Two reasons, and the second is the one that changed my mind about it. The board on screen is
* behind the game, so a move offered here is a move against a position that has already moved on —
* the menu is computed from the CURRENT state and would be acted on while looking at an older one.
* And the display had grown to four things demanding attention at once — the district, the history,
* the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss.
* Taking the action list out of that competition while there is nothing to decide anyway is the
* cheapest way to quieten it.
*
* NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always
* voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live
* menu over a stale board.
*/
if (stepQueue.busy()) {
el.innerHTML =
'<div class="dim">Catching up on what everyone else did — your move is here when the board is ' +
'level with the game. <b>Skip</b> jumps straight to it.</div>';
return;
}
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
// put its results up unasked again (Gitea#11).
resultsShown = false;
@@ -2063,11 +2500,34 @@ function renderActions(
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
* however it is titled, on screen where a player can act on it.
*/
/**
* WHAT A ROLE IS FOR, said where the role is chosen (playtest, 2026-09-17).
*
* Tom had a train standing at his Depot with a loaded coach on it, picked the Freight Agent to
* unload the passengers, and found nothing that would do it — "didn't have any opportunity to
* unload passengers". Both halves of that were working correctly and neither was visible: the
* Freight Agent stocks a box and never touches a train, and detraining is a PORTER's action in
* the Cargo phase. Why it was impossible as well (§9.2 wants an empty coach from the Division
* Yard) was already in the Blocked panel, off to the side — but a player who has chosen the
* wrong role is not reading a panel about the right one.
*
* A SENTENCE, NOT A HOVER. The tip on the heading would have answered him only if he already
* suspected he was in the wrong place, which is precisely what he did not.
*/
const ROLE_SCOPE: Record<string, string> = {
'freightAgent.':
'The Freight Agent stocks the green Loading box from the Division Yard, and frees a jam. ' +
'Passengers board and detrain in the Cargo phase, with a Porter — not here.',
'porter.': 'A Porter works passengers between a platform and a coach standing at your Office.',
'laborer.': 'A Laborer moves one load one step along MEN → AT → WORK, and loads or unloads a spotted car.',
};
html += menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
(ROLE_SCOPE[g.kind] ? `<p class="dim scope">${esc(ROLE_SCOPE[g.kind]!)}</p>` : '') +
g.actions
.map((a) => {
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
@@ -2095,9 +2555,31 @@ function renderActions(
const addable = menu.makeUp.cars.length;
html +=
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
/**
* WHAT IS STILL WANTED, which is not what the heading says.
*
* The heading names what the card CALLS FOR and goes on saying it unchanged as cars go on, so
* the one question a player has while clicking — what is left? — was the only thing on screen
* that had to be worked out by eye, against a consist drawn in the other column (Jesse,
* playtest 2026-09-16). `consistNeeds` counts by the same categories `acceptsCar` does, so it
* can never ask for a car the engine would then refuse.
*/
(menu.makeUp.needs !== null
? `<div class="makeup-needs">Still needs <b>${esc(menu.makeUp.needs)}</b></div>`
: `<div class="makeup-needs done">Its card's consist is complete — nothing further may be added.</div>`) +
`<div class="dim makeup-note">` +
(addable > 0
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
? `Click a car in the Division Yard below to add it — the ${addable} kind${addable === 1 ? '' : 's'} it may take ` +
`${addable === 1 ? 'is' : 'are'} highlighted in amber there. ` +
/**
* WHY YOUR TURN ENDS AFTER ONE CAR — §7's round, said where the clicking happens.
*
* It was written down only in the turn chart's New Train chip tooltip: hovered once, early
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
*/
`<b>Each player adds one car at a time</b>, starting from the Superintendent and working ` +
`eastward, repeating until the train is full or the Division Yard holds nothing it can take.`
: menu.makeUp.pass !== null
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
@@ -2231,21 +2713,60 @@ function renderActions(
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
* where a rendered page would have been megabytes.
*/
function downloadSave(): void {
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
// here rather than only trusted to the DOM.
if (!isLocal(session)) return;
const data = JSON.stringify(session.save(), null, 1);
function writeFile(name: string, data: string): void {
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
/**
* WHICH GAME, HOW FAR IN, AND WHEN — Jesse, playtest 2026-09-16.
*
* The name was `station-master-day1-stage5.json` for every server game at that point in every
* Stage, so two saves off the same table collided in the downloads folder and neither said which
* table it came from. The join code is the one thing a player already says out loud to identify a
* game, so it leads: `whistle-6945.day1.stage5.2026.09.16.json`.
*
* Lowercased because a filename is not a thing you shout, and dotted because that is the shape
* Jesse asked for. A solitaire game has no join code and falls back to its seed, which is the
* equivalent identity for a game nobody else is sitting at.
*/
function saveFileName(prefix: string, f: { day: number; stage: number }): string {
const d = new Date();
const pad = (n: number): string => String(n).padStart(2, '0');
const date = `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`;
return `${prefix}.day${f.day}.stage${f.stage}.${date}.json`;
}
async function downloadSave(): Promise<void> {
const f = session.view();
/**
* A SERVER-BACKED GAME HAS NO LOCAL SAVE TO HAND OVER, so it asks the server for its own — the seat's
* token is the gate (`/api/save`), the same one the stream and every intent already use. The StartOS
* Manage Game action cannot do this: an action result is text only, with no file member in the SDK.
*/
if (!isLocal(session)) {
if (remoteToken === null) return;
try {
const res = await fetch(`/api/save?token=${encodeURIComponent(remoteToken)}`);
if (!res.ok) return;
const body = (await res.json()) as { save: unknown };
const code = gameCode === '' ? 'station-master' : gameCode.toLowerCase();
writeFile(saveFileName(code, f), JSON.stringify(body.save, null, 1));
} catch {
// Offline, or the game has been ended under us: the button simply does nothing, which is the
// same thing every other server call on this page does when the server is not there.
}
return;
}
const solo = gameCode === '' ? `station-master-seed${session.seed()}` : gameCode.toLowerCase();
writeFile(saveFileName(solo, f), JSON.stringify(session.save(), null, 1));
}
function save(): void {
if (!isLocal(session)) return;
try {
@@ -2281,7 +2802,7 @@ document.head.appendChild(pageStyle);
installTooltips();
const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave;
if (saveBtn) saveBtn.onclick = () => void downloadSave();
/**
* Forget the saved game and deal a fresh one.
@@ -2598,6 +3119,52 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
}
/**
* PLAYBACK SPEED — v0.8.0.3, TODO #13.
*
* Persisted per viewer in `Settings`, so it survives the navigation that was eating `?pace=`. The
* queue reads `settings.pace` through a closure on every step, so a change here takes effect on the
* very next move rather than the next game.
*/
const paceSlowerBtn = document.getElementById('paceslower') as HTMLButtonElement | null;
const paceFasterBtn = document.getElementById('pacefaster') as HTMLButtonElement | null;
const paceLabel = document.getElementById('pacelabel');
if (paceSlowerBtn && paceFasterBtn && paceLabel) {
const nearestPace = (): number => {
// A saved or URL value need not be on the ladder — `?pace=7` and a hand-edited setting are both
// legitimate — so the buttons step from whichever preset is closest rather than refusing to move.
const want = PACE_OVERRIDE ?? settings.pace;
return PACE_LEVELS.reduce((best, p) => (Math.abs(p - want) < Math.abs(best - want) ? p : best), PACE_LEVELS[0]);
};
const paintPace = (): void => {
const p = PACE_OVERRIDE ?? settings.pace;
paceLabel.textContent = p === 0 ? 'off' : `${p}×`;
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
paceSlowerBtn.disabled = i >= PACE_LEVELS.length - 1;
paceFasterBtn.disabled = i <= 0;
// A `?pace=` in the URL wins over the setting, so say so rather than showing dead buttons.
if (PACE_OVERRIDE !== null) {
paceSlowerBtn.disabled = true;
paceFasterBtn.disabled = true;
paceLabel.textContent = `${PACE_OVERRIDE}× (URL)`;
}
};
const stepPace = (by: number): void => {
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
const next = PACE_LEVELS[Math.min(PACE_LEVELS.length - 1, Math.max(0, i + by))];
if (next === undefined) return;
saveSettings({ pace: next });
paintPace();
// The row's countdown is measured in steps that will dwell, so a change to 0 empties it at once.
renderWatching();
};
// Slower is a BIGGER multiplier, so "−" walks up the ladder. Labelled by what it does to the game,
// not to the number: a player pressing "slower" wants to watch for longer.
paceSlowerBtn.onclick = () => stepPace(1);
paceFasterBtn.onclick = () => stepPace(-1);
paintPace();
}
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
const zoomLabel = document.getElementById('zoomlabel');
+96 -11
View File
@@ -52,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
* is out of reach, and choosing where to discard is choosing what to put there.
*/
export function pilesHtml(f: Frame): string {
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
const pile = (
key: string,
label: string,
top: string,
depth: number,
why: string,
extra = '',
slot = -1,
faceDown = false,
): string => {
const tip = [why, extra].filter(Boolean).join(' · ');
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
// viewer draws exactly the same markup and nothing there is clickable.
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
return (
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
return (
/**
* THE HOME OFFICE DECK, which the screen had never drawn.
*
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
*
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
* — the whole point of this pile is that nobody knows what is on top.
*/
pile(
'home',
'Home Office',
'face down',
f.deck,
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
'into it.',
'',
-1,
true,
) +
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pile(
`dept${i}`,
`Dept ${i + 1}`,
d,
depth,
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
})
.join('') +
pile(
'salvage',
'Salvage',
f.salvage.top,
f.salvage.depth,
@@ -180,7 +217,7 @@ export function dayEndHtml(f: Frame): string {
ahead +
standingsHtml(f) +
targetHtml(f) +
collisionsHtml(f)
collisionsHtml(f, ended)
);
}
@@ -238,13 +275,28 @@ function targetHtml(f: Frame): string {
* its config and enforces neither, so reporting a collision budget there would put a rule on
* screen that this game does not have.
*/
function collisionsHtml(f: Frame): string {
function collisionsHtml(f: Frame, endedDay?: number): string {
const scoredOnCollisions =
(f.mode === 'competitive' || f.mode === 'coop') &&
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
return scoredOnCollisions
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
: '';
if (!scoredOnCollisions) return '';
/**
* "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction.
*
* That dialog is drawn from the frame whose `day` went UP — which is the same frame in which
* `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09,
* at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero
* today ... that does seem to be a contradiction."
*
* So when the caller knows which Day just ended it says so by name, and reads the count captured at
* the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has
* not turned over and the word is accurate.
*/
const [count, when] =
endedDay === undefined
? [f.collisionsToday, 'today']
: [f.collisionsPrevDay, `on Day ${endedDay}`];
return `<p>Collisions: <b>${count}</b> ${when}, <b>${f.collisionsTotal}</b> in all.</p>`;
}
/**
@@ -661,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
.handcard.unplayable{color:#7d8794;border-color:#39424e}
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
dimmed voice the rest of the panel uses for "nothing to read here". */
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
border and background stay for exactly as long as the step is up, because the class is on the
element only while that step is the one being shown. */
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
animation:pilepulse 1.15s ease-in-out infinite}
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
office area and history and catch up area all at same time." Something still moving keeps drawing
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
than simply not moving: a solid ring and a brighter fill, held. */
@media(prefers-reduced-motion:reduce){
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
@@ -701,9 +777,18 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
/* And the piles that are NOT targets step back while a discard is being aimed, so the three that
are stand out from the Salvage Yard beside them. */
.cardrow.aiming .handcard:not(.target){opacity:.4}
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
/* A CAR YOU MAY ADD IS AN ACTION, SO IT WEARS THE ACTION COLOUR — Jesse, playtest 2026-09-16: the
highlight was "just a small bold and basically the same color as everything else", which is the
whole difficulty with making the yard chip the button. #c8912f is the border colour that the
action buttons themselves use, so a clickable car looks like every other thing inviting a click,
rather than like a number that happens to be outlined.
THE LOADED/EMPTY TEXT COLOURS ARE LEFT ALONE. Green and blue-grey are what say which of the two
numbers is which; the amber answers "may I click this", which is a different question, and
painting over the first to answer the second would cost real information. */
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;font-weight:700;
outline:2px solid #c8912f;background:rgba(200,145,47,.20);box-shadow:0 0 0 2px rgba(200,145,47,.16)}
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(200,145,47,.38)}
/* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are
dimmed, and a slot the die has just filled flashes once. */
.tt{display:flex;gap:3px;flex-wrap:wrap}
+82 -3
View File
@@ -124,7 +124,8 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
#watching-skip,#watching-pause{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
#watching-who{color:#c9cee0;font-weight:700}
#presence:empty{display:none}
/* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
@@ -183,6 +184,14 @@ button.cardact.discard{color:#d6b48a}
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
.makeup-note{font-size:11px;margin:0 0 5px}
/* WHAT THE TRAIN STILL WANTS. Deliberately NOT amber: amber means "you can click this" everywhere
else on this page, and this line is the REASON for clicking rather than a thing to click — the
cars in the Division Yard are. Brighter than the note beneath it, because it answers the question
the player actually has while looking at it. `done` goes green like `.scheduled`: a complete
consist is good news, not an instruction. */
.makeup-needs{font-size:12px;margin:0 0 5px;color:#e6e9ee}
.makeup-needs b{color:#f2e6cf}
.makeup-needs.done{color:#bfe8cd}
/* WHICH TRAIN AM I SWITCHING. A row of crews rather than a stacked list — they are alternatives,
and the chosen one is the crew whose squares the board is drawing, so it wears the same violet
"you are here" the rest of the page uses. */
@@ -204,6 +213,9 @@ button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);colo
/* actions */
#actions{max-height:none}
.grp{margin-bottom:6px}
/* What a role is for, under its heading — a sentence a player reads once and stops re-asking. Sized
below the buttons so it explains without competing with the thing being chosen. */
.grp .scope{margin:2px 0 4px;font-size:11.5px;line-height:1.35}
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
used nowhere else, so "this is a move" is answered before the label is read. */
@@ -256,6 +268,13 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
#gamecardbody dd.changed{color:#f0b64a}
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
/* THE GUIDE, inside the card rather than the header (Jesse, 2026-09-21). Laid out as one wrapping
line of links rather than a list: six references stacked would make the card scroll past the
house rules it sits under, and these are a shelf to reach for, not a thing to read down. */
#gamecardbody .guide{margin-top:10px;border-top:1px solid var(--line);padding-top:6px}
#gamecardbody .guide p{margin:4px 0 0;font-size:12px;line-height:1.7;color:#8b94a3}
#gamecardbody .guide a{color:#9fb6d8;text-decoration:none;border-bottom:1px solid #33404f}
#gamecardbody .guide a:hover{color:#cfe0f5;border-bottom-color:#5aa9e6}
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
so a disabled button must not wear it. */
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
@@ -324,6 +343,27 @@ ul.blocked li{padding:2px 0}
.lb-door:hover{border-color:#4d6fa8}
.lb-door.active{background:#2f3a4b;border-color:#6f8fc8;color:#cfe0f5;font-weight:600}
.lb-saved{display:flex;align-items:center;gap:10px}
/* THE LOBBY'S ACTION BUTTONS CARRY THE SAME AMBER AS THE BOARD'S (Jesse, 2026-09-22).
`#actions button` has meant "this is a move you can make" all game; the lobby is the one screen
where a player is also being asked to act, and it was styling those buttons as plain chrome. The
selector is a LIST OF THE ACTIONS, not `#lobby button` — the settings form beneath Create is a
field of inputs, and amber on all of it would say everything is a move and so say nothing.
Disabled keeps `button:disabled` above: an unfilled Start game must not advertise a click. */
#lb-look, #lb-join, #lb-create, #lb-start, #lb-copy, #lb-copylink, .lb-known-row .lb-rejoin{
background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
color:#f2e6cf;font-weight:600}
#lb-look:hover, #lb-join:hover, #lb-create:hover, #lb-start:hover, #lb-copy:hover,
#lb-copylink:hover, .lb-known-row .lb-rejoin:hover{
background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
#lb-look:focus-visible, #lb-join:focus-visible, #lb-create:focus-visible, #lb-start:focus-visible,
#lb-copy:focus-visible, #lb-copylink:focus-visible, .lb-known-row .lb-rejoin:focus-visible{
outline:2px solid #f0b64a;outline-offset:2px}
/* Copy is still secondary — it carries the amber so it reads as actionable, but not the weight of
the button that starts a game. `button.ghost` set its own border, so it is overridden here. */
#lb-copy, #lb-copylink{border-width:1px;font-size:11px;box-shadow:none}
/* A disabled action is chrome again, or the amber becomes a promise the button will not keep. */
#lb-start:disabled, #lb-start:disabled:hover{
background:#2a3038;border:2px solid var(--line);box-shadow:none;color:inherit}
.lb-known-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--line)}
.lb-known-row:last-child{border-bottom:none}
.lb-known-row .code{font-weight:700;letter-spacing:.06em;color:#f2e6cf;flex:1}
@@ -401,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>
@@ -422,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. -->
@@ -588,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
@@ -813,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.
@@ -872,6 +932,14 @@ ul.blocked li{padding:2px 0}
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
</span>
<!-- HOW FAST OTHER PLAYERS' TURNS PLAY BACK — v0.8.0.3, TODO #13.
A CONTROL RATHER THAN ONLY A URL PARAMETER. `?pace=` shipped first and is unreachable through
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
game believing he was at 7x when he was at 1x. -->
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Your own moves are never delayed — only theirs. Starts at 1×, which holds a switching move for one second; the slowest setting, 20×, holds it for twenty. Off draws every move at once.">
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
</span>
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
@@ -882,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>
@@ -906,9 +974,16 @@ ul.blocked li{padding:2px 0}
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
almost always. -->
<div id="watching" hidden>
<!-- SKIP FIRST, on the left. It sat on the far right and a player's eye is on the countdown, not at
the other end of the row — Jesse, 2026-09-09: "the skip button should be on the far left, in
front of where it says [the count], so it's always close to where people are looking." -->
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
<!-- PAUSE BESIDE SKIP, not instead of it: they are opposite answers to "that went past too fast".
Skip gives up the animation to reach the game; Pause holds the board on the step being shown
for as long as you want to look at it, and gives the step back the dwell it still had. -->
<button id="watching-pause" class="ghost" type="button" title="Hold the board on the move being shown. Nothing is lost and nothing is hurried — press again to carry on from the same step.">Pause</button>
<span id="watching-behind" class="wbehind"></span>
<span id="watching-what"></span>
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
</div>
<main>
@@ -919,6 +994,10 @@ ul.blocked li{padding:2px 0}
<h2><span id="districtwho">Your Office Area</span>
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
<!-- LOOK AT ANOTHER PLAYER'S OFFICE AREA. Filled by `renderDistrict` with one button per
opponent, and collapsed at a table of one. The look is read-only and lasts a single
render on purpose — see `peekPlayer` in `main.ts`. -->
<span id="districtpeek" class="seg" role="group" aria-label="Look at another player's Office Area"></span>
</h2>
<div id="districtsummary" class="dim"></div>
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
+12 -2
View File
@@ -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,
+4
View File
@@ -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 &mdash; 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>
+6 -1
View File
@@ -56,7 +56,12 @@ type Step = {
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
const steps: Step[] = [];
const ctx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id) };
const ctx = {
cardName: (id: string) => cardName(s, id),
trainName: (id: string) => trainName(s, id),
// A player index is not a seat index, so the fallback names no number at all — see `game.ts`.
playerName: (p: number) => s.players[p]?.name ?? 'another player',
};
const push = (events: ReturnType<typeof pump>): void => {
const lines = events
.filter((e) => e.type !== 'actorChanged')
+51 -12
View File
@@ -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);
+5 -1
View File
@@ -48,6 +48,7 @@ export const FIELDS: readonly Field[] = [
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' },
{ key: 'startWhistlePosts', kind: 'checkbox', id: 'whistlestart' },
];
/**
@@ -73,6 +74,7 @@ const FIELD_LABELS: Record<keyof Settings, string> = {
startingHand: 'Starting hand',
extraStart: 'An Extra may start at',
discardTimetabled: 'A Timetabled train may be discarded',
startWhistlePosts: 'Players start with Whistle Posts, not Depots',
passengerPerCoach: 'Passenger per coach',
freightPerLoad: 'Freight per load',
trainPerTransit: 'Train per transit',
@@ -116,7 +118,7 @@ export function rulesListHtml(config: GameConfig, players: number, days: number)
return (
head +
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart'])}</dl>` +
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart', 'startWhistlePosts'])}</dl>` +
`<h4>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
@@ -209,6 +211,7 @@ export function settingsForm(prefix: string): SettingsForm {
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
discardTimetabled: el<HTMLInputElement>('tossloco')?.checked === true,
startWhistlePosts: el<HTMLInputElement>('whistlestart')?.checked === true,
};
}
@@ -225,6 +228,7 @@ export function settingsForm(prefix: string): SettingsForm {
setChecked('rotation', values.employeeRotation);
setChecked('toolbox', values.emergencyToolbox);
setChecked('tossloco', values.discardTimetabled);
setChecked('whistlestart', values.startWhistlePosts);
}
function setNumber(id: string, value: number): void {
+23
View File
@@ -40,6 +40,29 @@ if (heroImage && lightbox) {
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
*/
/**
* CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3.
*
* Both doors are static hrefs that REPLACE the query string (`play.html?lobby`,
* `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse
* played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The
* durable answer is the speed control on the play screen, which persists per viewer — this keeps the
* URL lever honest for handing two playtesters different speeds, which is the only thing it was ever
* for.
*/
try {
const pace = new URLSearchParams(location.search).get('pace');
if (pace !== null) {
for (const door of Array.from(document.querySelectorAll('a.door'))) {
const href = door.getAttribute('href');
// Only the doors into the game, and only ones that have not been disabled above.
if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`);
}
}
} catch {
// A door that keeps its own href is the status quo, not a broken page.
}
const mpDoor = document.getElementById('door-multiplayer');
if (mpDoor) {
const close = (): void => {
+148 -8
View File
@@ -19,7 +19,8 @@
import type { PublicFrame } from '../sim/view.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import { applyPublicDelta } from '../sim/public-delta.ts';
import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts';
import type { PileKey } from '../sim/public-delta.ts';
import { dwellForStep } from '../sim/pacing.ts';
export type StepQueue = {
@@ -43,22 +44,114 @@ export type StepQueue = {
behind(): number;
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
showing(): DisplayStep | null;
/**
* The piles the step now on screen moved, for the display to light.
*
* Here because this is the only place that holds both the frame before a step and the frame after
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
*/
lit(): readonly PileKey[];
/** True while there is anything left to show. */
busy(): boolean;
/**
* How many narrated lines belong to steps NOT yet shown.
*
* The log and the board are two different moments while the queue is behind: a push carries its
* narration and its steps together, so every line of a bot's turn is in the history panel before the
* board has drawn a single move of it (playtest, 2026-09-15: *"is it possible to stall history so it
* stays in sync with the number behind?"*). Those lines are the TAIL of the log — they arrived last —
* so the caller holds back exactly this many and reveals each as its step goes up.
*/
pendingLines(): number;
/** Division nodes whose card changed in the step now on screen, for the map to flash. */
flashing(): readonly number[];
/**
* HOLD THE PLAYBACK ON THE STEP NOW SHOWING — Jesse, playtest 2026-09-16, asking for a pause
* beside Skip.
*
* Skip is the only control the row has had, and it is one-way and total: the way to look harder at
* a move that just went past was to not be too slow about it. Pause is the opposite lever — the
* board stops where it is and nothing is consumed, so a player can read the caption, look at the
* district and then carry on from exactly that step.
*
* TAKES `now` BECAUSE THE QUEUE OWNS NO CLOCK (see the note at the top of this file). The dwell
* still owing is preserved across the hold rather than being spent while nobody was watching:
* `resume` pushes the deadline out by however long the pause lasted, so a step paused with 200ms
* left resumes with 200ms left instead of vanishing on the next frame.
*
* Returns false when there is nothing to hold, or nothing being held.
*/
pause(now: number): boolean;
resume(now: number): boolean;
paused(): boolean;
};
/** `pace` is read on every step rather than captured, so changing the setting takes effect at once. */
export function createStepQueue(pace: () => number = () => 1): StepQueue {
/**
* WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25).
*
* The game and the board on screen are two different moments. The server plays every bot move the
* instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human —
* while this queue is still replaying the bots, step by step. The turn chart and the Division map's
* move marker read the live actor, so a table of one person and three bots said "waiting on" that
* person throughout, against a playback row naming the bot actually moving.
*
* While the queue is behind or still showing a step, the answer is that step's player — `null` for an
* automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and
* `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on,
* off a screen that has not caught up with it yet.
*/
export function actorOnScreen(
queue: Pick<StepQueue, 'behind' | 'busy' | 'showing'>,
live: number | null,
): { actor: number | null; replaying: boolean } {
if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false };
const shown = queue.showing();
return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true };
}
/**
* `pace` is read on every step rather than captured, so changing the setting takes effect at once.
*
* `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on
* screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue,
* so holding their click for a dwell shows them nothing and delays the thing they actually want to
* watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed
* at them. The step is still APPLIED, because the delta chain runs through it.
*
* Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in
* solitaire where every intent is the viewer's own.
*/
export function createStepQueue(
pace: () => number = () => 1,
viewer: () => number | null = () => null,
): StepQueue {
let shown: PublicFrame | null = null;
let last: DisplayStep | null = null;
let litPiles: readonly PileKey[] = [];
let flashedCards: readonly number[] = [];
let pending: DisplayStep[] = [];
/** When the step now on screen is due to give way. Null when nothing is waiting. */
let dueAt: number | null = null;
/** When the player pressed Pause, so `resume` can give the current step back the time it had. */
let pausedAt: number | null = null;
/** How long this step holds the screen — zero for the viewer's own moves; see above. */
const dwell = (step: DisplayStep): number =>
step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace());
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
const show = (step: DisplayStep): void => {
const before = shown;
shown = applyPublicDelta(shown, step.frame);
last = step;
/**
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
* that gives your own steps no dwell — the display is for watching everybody else.
*/
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
// A Realignment changes the Division under everyone, so it is flashed for the player who did it
// too — unlike a pile, which only tells the drawer what they already know.
flashedCards = changedDivisionCards(before, shown);
};
return {
@@ -66,6 +159,12 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
shown = frame;
pending = [];
dueAt = null;
// A reconnect, an undo or a fresh deal replaces the board outright; a hold on the playback that
// no longer exists would leave the row stuck reading "paused" with nothing behind it.
pausedAt = null;
// Nothing was watched arriving at this board, so nothing on it is lit.
litPiles = [];
flashedCards = [];
// `last` deliberately survives: a reconnect should not blank the caption line, and the
// sentence describing the most recent action is still true.
},
@@ -75,8 +174,14 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
},
advance(now) {
// Held. Nothing is shown and, crucially, nothing is CONSUMED — `dueAt` is left where it was
// and `resume` moves it, so the hold costs the current step none of its dwell.
if (pausedAt !== null) return false;
if (pending.length === 0) {
dueAt = null;
// The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue
// idle the instant that step was shown, which snapped the district panel home before anyone
// could look at it — see `busy()`.
if (dueAt !== null && now >= dueAt) dueAt = null;
return false;
}
// First step of a burst: show it immediately rather than waiting out a dwell for a board the
@@ -84,7 +189,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
if (dueAt === null) {
const first = pending.shift()!;
show(first);
dueAt = now + dwellForStep(first, pace());
dueAt = now + dwell(first);
return true;
}
let drew = false;
@@ -97,7 +202,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
while (pending.length > 0 && now >= dueAt) {
const next = pending.shift()!;
show(next);
dueAt = dueAt + dwellForStep(next, pace());
dueAt = dueAt + dwell(next);
drew = true;
}
if (pending.length === 0 && now >= dueAt) dueAt = null;
@@ -105,6 +210,9 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
},
skip() {
// Skipping while held is a decision to stop watching, so it also lifts the hold — otherwise the
// board would jump to the game and then sit there paused, with a Resume button that does nothing.
pausedAt = null;
if (pending.length === 0) return false;
for (const step of pending) show(step);
pending = [];
@@ -112,9 +220,41 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
return true;
},
pause(now) {
if (pausedAt !== null) return false;
// Nothing on screen owes any time and nothing is queued: there is no playback to hold.
if (pending.length === 0 && dueAt === null) return false;
pausedAt = now;
return true;
},
resume(now) {
if (pausedAt === null) return false;
// Give the step on screen back exactly the dwell it was holding when the player pressed Pause.
if (dueAt !== null) dueAt += now - pausedAt;
pausedAt = null;
return true;
},
paused: () => pausedAt !== null,
current: () => shown,
behind: () => pending.filter((s) => dwellForStep(s, pace()) > 0).length,
behind: () => pending.filter((s) => dwell(s) > 0).length,
showing: () => last,
busy: () => pending.length > 0,
lit: () => litPiles,
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
/**
* STILL SHOWING SOMETHING, not just still holding something back.
*
* This was `pending.length > 0`, which went false the moment the last step of a burst was
* shown — so the animation loop stopped and the district panel snapped back to the viewer's own
* board without that step ever being visible. Reported from real play: "I briefly saw that it was
* the bot's office area, then their turn was done and it pointed back to my office area."
*
* `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".
*/
flashing: () => flashedCards,
busy: () => pending.length > 0 || dueAt !== null,
};
}
+266 -3
View File
@@ -7,12 +7,11 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { advance, badlyMadeUp, pump } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.ts';
import { 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,
@@ -1141,7 +1144,12 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
// which is the Superintendent's call rather than an absolute bar.
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline');
//
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
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', {
@@ -1511,3 +1519,258 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
});
});
// ---------------------------------------------------------------------------
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
/**
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
*/
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
const s = game(7, { days: 5 });
const area = areaOf(s, 0);
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
s.trays.set('departing', {
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'west', facing: 'w',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push('departing');
const card = s.division.nodes.findIndex((n, i) =>
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
const node = s.division.nodes[card];
assert.equal(node?.kind, 'mainline');
s.trays.set('other', {
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
position: { at: 'mainline', index: card }, movesUsed: 0,
});
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
}
s.clock.phase = 'mainline';
return s;
};
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'east', number: 18 });
const r = advance(s);
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'the departing train did not highball with nothing ahead of it',
);
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
});
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
const s = setup({ direction: 'east', side: 'east', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'a train moving away behind it held the departure',
);
});
it('still puts a same-direction train AHEAD to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'west', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
'a train the departing one would follow was not put to the Superintendent',
);
});
});
// ---------------------------------------------------------------------------
/**
* §7's make-up round is silent about what it could NOT do, and that silence was reported from a
* table (2026-09-16): "train 5, the sparrow, has no coaches, which seems strange."
*
* The Sparrow's card calls for three coaches and nothing else. Every coach in that game had ended
* up in the Classification Yard, which §2.2 returns only when the Division Yard runs bare — and the
* Division Yard still held 46 freight cars, so it never would. `trainNeedingCars` therefore answered
* "nothing to ask for" exactly as it answers "everything is full", the phase moved on, and the train
* ran the length of the Division empty with no line anywhere saying why.
*/
describe('a train the Division Yard cannot supply says so', () => {
/** Puts one train in the yard-filling state, with a yard holding only `types`. */
const readyToFill = (yard: { type: string; loaded: boolean }[]) => {
const s = game();
// Stage 1's train is 1/2, the Crack Limited — coaches only, which is the shape that goes short.
s.timetable = Array(STAGES_PER_DAY).fill(null);
s.timetable[0] = 1;
s.yards.divisionYard = yard as never;
s.yards.classificationYard = [{ type: 'coach', loaded: false }] as never;
s.clock.stage = 1;
s.clock.phase = 'newTrain';
return s;
};
it('reports the shortfall, with the cars, the waiting pile and why it will not come back', () => {
// A yard of pure freight: nothing the Crack Limited will take.
const s = readyToFill([
{ type: 'boxcar', loaded: true },
{ type: 'hopper', loaded: false },
]);
const { events } = advance(s);
const short = events.find((e) => e.type === 'makeUpShort');
assert.ok(short, 'a train that could be given nothing reported nothing');
assert.equal(short.type === 'makeUpShort' && short.placed, 0);
assert.ok(
short.type === 'makeUpShort' && short.missing.includes('coach'),
'the report did not name the category the yard could not supply',
);
assert.ok(
short.type === 'makeUpShort' && short.waiting === 1,
'the report did not count the cars waiting in the Classification Yard',
);
assert.ok(
short.type === 'makeUpShort' && short.divisionYardHolds === 2,
'the report did not say how far the Division Yard is from bare, which is what §2.2 turns on',
);
});
it('says nothing when the round can still be asked for a car', () => {
// The same train, with coaches available: the phase must STOP for them rather than report.
const s = readyToFill([
{ type: 'coach', loaded: true },
{ type: 'coach', loaded: true },
]);
const { events, needsInput } = advance(s);
assert.equal(needsInput, true, 'the phase should be waiting for a car to be placed');
assert.equal(
events.some((e) => e.type === 'makeUpShort'),
false,
'a train that can still be filled was reported short',
);
});
});
// ---------------------------------------------------------------------------
/**
* §8.2 and the Small Yard's nose sort, together — Jesse's ruling of 2026-09-17 was "any train may
* sort to the nose, and it is held if it cannot depart".
*
* The holding half already existed and needed no new code, which is worth pinning precisely because
* it is easy to assume otherwise: `badlyMadeUp` is deliberately DIRECTION-FREE, so a train with its
* whole consist ahead of the engine is a PUSHING train and fit to run. What §8.2 refuses is a
* broken-backed train — the engine buried among its own cars — and a caboose anywhere but the end
* away from the engine.
*/
describe('a train sorted to the nose is judged by §8.2, not by where the engine is', () => {
const tray = (consist: { type: string; loaded: boolean }[], engineAt: number) =>
({ consist, engineAt }) as unknown as CrewTray;
it('lets a pushing train run — the whole consist ahead of the engine is made up', () => {
const pushing = tray([{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }], 2);
assert.equal(
badlyMadeUp(pushing),
null,
'a pushing train was refused; §8.2 is enforced direction-free on purpose',
);
});
it('holds a train whose engine is buried among its own cars', () => {
const buried = tray(
[{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: true }, { type: 'caboose', loaded: true }],
1,
);
const why = badlyMadeUp(buried);
assert.ok(why !== null, 'a broken-backed train was allowed to leave the Office');
assert.match(why, /engine is buried/, `the hold did not say why: ${why}`);
});
it('holds a train whose caboose is not at the end away from the engine', () => {
// Exactly the arrangement asked for at the table: caboose second from the rear.
const wanted = tray(
[
{ type: 'hopper', loaded: true },
{ type: 'tank', loaded: false },
{ type: 'caboose', loaded: true },
{ type: 'boxcar', loaded: true },
],
0,
);
const why = badlyMadeUp(wanted);
assert.ok(why !== null, 'a train with its caboose mid-consist was allowed to leave');
assert.match(why, /caboose must be at the rear/, `the hold did not say why: ${why}`);
});
});
// ---------------------------------------------------------------------------
// 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');
});
});
+323 -83
View File
@@ -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,
@@ -159,6 +163,60 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
});
it('a PLAYED timetabled train never comes back, but a discarded one does — Gitea#23', () => {
/**
* Jesse's ruling, 2026-09-10: *"Once you've played a regularly scheduled train and it's in the
* salvage deck, that train is already on the timetable. It does not make sense to put that back
* into a reshuffled home deck to get played again. By contrast, a regularly scheduled train
* that's in a discard pile could potentially get reused later, and so should have that
* capability. Extras run one time and then they're done — if they are in the Salvage deck, they
* should get shuffled back in so that they could get run again."*
*
* So the test is WHERE the card is, not only what it is: the same card is spent in the Salvage
* Yard and still runnable in a Department. That is what this pins, because it is the kind of rule
* a later tidy-up would happily "simplify" into filtering by card kind everywhere.
*/
const s = game();
const kindOfCard = (id: string): string => s.cards.get(id)?.kind.kind ?? '?';
const pool = [...s.decks.homeOffice];
const trains = pool.filter((id) => kindOfCard(id) === 'timetabledTrain');
const extras = pool.filter((id) => kindOfCard(id) === 'extraTrain');
const others = pool.filter((id) => !['timetabledTrain', 'extraTrain'].includes(kindOfCard(id)));
assert.ok(trains.length >= 2 && extras.length >= 1 && others.length >= 5, 'the deal lacks the cards this needs');
const spentTrain = trains[0]!; // played: in the Salvage Yard, its slot taken
const discardedTrain = trains[1]!; // never played: sitting in a Department
const playedExtra = extras[0]!; // a single run, free to run again
s.decks.salvageYard = [spentTrain, playedExtra, ...others.slice(0, 3)];
s.decks.departments = [[discardedTrain], [others[3]!], [others[4]!]];
s.decks.homeOffice = [others[5]!];
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
const recovered = new Set([...s.decks.homeOffice, ...s.decks.departments.flat()]);
const hands = new Set([...s.decks.hands.values()].flat());
// THE RULING, both halves.
assert.ok(!recovered.has(spentTrain), 'a played timetabled train was shuffled back in');
assert.ok(!hands.has(spentTrain), 'a played timetabled train was dealt back into a hand');
assert.ok(
s.decks.salvageYard.includes(spentTrain),
'a played timetabled train should stay in the Salvage Yard, not vanish',
);
assert.ok(
recovered.has(discardedTrain) || hands.has(discardedTrain),
'a DISCARDED timetabled train must come back — it was never played, so its slot is open',
);
assert.ok(
recovered.has(playedExtra) || hands.has(playedExtra),
'a played Extra must come back — an Extra is one run, not a standing slot',
);
});
it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => {
// §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards
// from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office
@@ -182,7 +240,11 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept');
// Swept EXCEPT the trains whose slots are already on the timetable — see the ruling test below.
assert.ok(
s.decks.salvageYard.every((id) => s.cards.get(id)?.kind.kind === 'timetabledTrain'),
'the Salvage Yard must be swept apart from spent timetabled trains',
);
assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established');
assert.ok(
s.decks.departments.every((p) => p.length === 1),
@@ -740,6 +802,44 @@ describe('Freight Agent operations (§6.3)', () => {
);
});
it('returns a cleared car to the yard EMPTY, not still loaded', () => {
/**
* REGRESSION, v0.8.1.0. `inboundCleared` pushed `pooled(e.stock)` under a comment reading
* "a car back in a yard is back in the common supply, carrying nothing" — and `pooled` does not
* do that. It strips the `origin` stamp and keeps `loaded` DELIBERATELY, because a train can
* retire at a Division Point with freight still aboard (`advance.ts`). So every car the Freight
* Agent cleared arrived in the Classification Yard still carrying a load that had already been
* delivered and already paid for.
*
* It bites hardest on coaches: `passengersDetrained` takes `type === 'coach' && !loaded` out of
* the Division Yard, and §2.2 refills the Division Yard from Classification — so a cleared
* coach came back as stock that could not unload a passenger.
*/
const s = game();
const coord = withFacility(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.allows = { outbound: false, inbound: true };
f.inboundBox = [{ type: 'coach', loaded: true }];
const before = s.yards.classificationYard.length;
assert.ok(applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }).ok);
assert.ok(
applyIntent(s, 0, { type: 'freightAgent.clearInbound', at: coord, index: 0 }).ok,
'the Freight Agent could not clear the red box',
);
assert.equal(f.inboundBox.length, 0, 'the red box was not cleared');
assert.equal(s.yards.classificationYard.length, before + 1, 'the car did not reach the yard');
const arrived = s.yards.classificationYard[s.yards.classificationYard.length - 1]!;
assert.equal(arrived.type, 'coach', 'the wrong car reached the yard');
assert.equal(arrived.loaded, false, 'the cleared car arrived in the yard still loaded');
// And a coach that arrives empty is stock a Porter can actually use again.
assert.ok(
s.yards.classificationYard.some((c) => c.type === 'coach' && !c.loaded),
'no empty coach reached the common supply',
);
});
it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => {
// A player with no Facility cannot choose an option that has no possible follow-up.
const s = game();
@@ -1119,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(
@@ -1142,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()]
@@ -1151,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 } };
@@ -1175,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
@@ -1292,38 +1412,62 @@ 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);
buildFacility(s, 'packingSheds', under);
const cardId = modifierOf(s, 'iceHouse');
// `district` leaves the host ON the sign's column, which since 2026-09-17 puts its three eastern
// spots outside the Limits. The question here is whether DIAGONALS are offered at all, so the
// sign goes out one more column and the host keeps all nine.
areaOf(s, 0).limitsEast = at(areaOf(s, 0).runningRow, under.col + 1);
const offered = legalActions(s, 0)
.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('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
/**
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
* Jesse's call, both halves — and the FIRST half reversed on 2026-09-17 after a Day 3 playtest
* put Transmission Lines at (-2,4) with the sign at column 3. It used to read the other way: a
* Modifier is not track (§9), so a host at the limit kept all nine of its spots, because
* refusing the outer three looked like it would make the card unplayable where the district
* ends. What decided it was the board — a card standing outside your own sign, in territory
* §8.1 and §10 reason about — and a count of what is actually lost: six of the nine spots
* survive, and the sign moves outward as the Running Track grows (§2.1, Gap 4a).
*
* The Running Track ROW stays barred for its own reason: it is the ground the main grows onto,
* and a Modifier parked there would block the player's own sign from moving outward.
*/
const s = game();
const under = district(s);
@@ -1332,9 +1476,16 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
const area = areaOf(s, 0);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, area.limitsEast.col + 1) }),
'OUTSIDE_LIMITS',
'a Modifier was allowed to stand outside the district it belongs to',
);
// The spot inside the sign, beside the same host, is the one a player actually has — free,
// adjacent, and on the sign's own column, which `withinLimits` includes.
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row - 1, under.col) }),
null,
'a Modifier beside a host at the limit was refused the spot outside it',
'a Modifier was refused a free, connected spot inside the Limits',
);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
@@ -1367,6 +1518,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
const area = areaOf(s, 0);
const at = { row: -1, col: 4 };
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
// This test is about what a Modifier GRANTS, not about where it may stand, and it arranges a
// host well east of the opening sign. Modifiers have been bounded by the Limits since
// 2026-09-17, so the district has to reach the square the fixture uses or every case here would
// fail as OUTSIDE_LIMITS and prove nothing about flow.
area.limitsEast = { row: area.runningRow, col: at.col + 2 };
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const card = [...s.cards.entries()].find(
@@ -1438,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',
@@ -1452,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);
@@ -1486,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 => ({
@@ -1823,7 +1954,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
*/
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, movesAllowed: 6, facing }) as const;
it('carries the east-west sense across north-south track', () => {
const s = game();
@@ -2348,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
View File
@@ -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`);
}
});
});
+231 -1
View File
@@ -11,7 +11,10 @@
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';
import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
@@ -23,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,
@@ -461,3 +468,226 @@ describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)'
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
});
});
// ---------------------------------------------------------------------------
/**
* The Small Yard's re-order menu, reported from Day 3 of the 2026-09-17 playtest.
*
* Jesse had train 10 on his Small Yard — nose first, `[loaded boxcar, loaded hopper, empty tank,
* caboose]` — wanted the boxcar on the tail, and could not tell which button did it: every option
* read `re-order consist [1,2,3,0]`, the engine's own array indices offered to a person. The move he
* wanted was the FIRST of the five. One of the other four re-ordered nothing at all and would still
* have spent one of his six Moves.
*/
describe('the Small Yard says what each re-order would build', () => {
const smallYard = (standing: RollingStock[] = []): TrackCard => ({
...straight(standing),
enhancements: ['smallYard'],
});
/** Train 10's consist as it actually stood, on a card carrying a Small Yard. */
const onTheYard = (): { s: GameState; trayId: string } => {
const s = game();
row(s, 3);
addCard(s, at(1, 1), smallYard());
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true), car('tank'), car('caboose', true)], 'e');
switching(s);
return { s, trayId };
};
it('never offers a sort that changes nothing', () => {
/**
* THE PAIR IS WHAT COUNTS, since the engine position became part of a sort (2026-09-17). The
* identity car order is now a perfectly good option when it moves the ENGINE — that is the whole
* of the separate engine control — so what must never be offered is the pair that reproduces the
* train already standing there, and no pair may appear twice.
*/
const { s, trayId } = onTheYard();
const tray = s.trays.get(trayId)!;
const offered = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => {
const sort = i as { order: number[]; engineAt?: number };
return `${sort.order.join(',')}|${sort.engineAt ?? 0}`;
});
assert.ok(offered.length > 0, 'no sort was offered at all, so this proved nothing');
const unchanged = `${[...tray.consist.keys()].join(',')}|${tray.engineAt}`;
assert.ok(
!offered.includes(unchanged),
'the menu offered the train as it already stands — a Move spent to change nothing',
);
assert.equal(new Set(offered).size, offered.length, 'the menu offered the same sort twice');
});
it('offers the engine every position, including ahead of its own cars', () => {
// Jesse's ruling, 2026-09-17, following `implications.md` against the v0.4.5 card text: a train
// in a Small Yard "may sort itself into any order, INCLUDING cars ahead of the engine".
const { s, trayId } = onTheYard();
const n = s.trays.get(trayId)!.consist.length;
const positions = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => (i as { engineAt?: number }).engineAt ?? 0);
for (let k = 1; k <= n; k++) {
assert.ok(positions.includes(k), `the engine was never offered position ${k} of ${n}`);
}
});
it('always offers a made-up order to a train that is not in one', () => {
/**
* Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the back… sorting all
* the cars back in, ready to leave the station."
*
* A train with its caboose mid-consist is one §8.2 will not let out of the Office, so at least
* one offer has to put it right. It comes out of the ordinary curated set — "bring car k to the
* tail" is enumerated for every car, and the caboose is one of them.
*/
const s = game();
row(s, 3);
addCard(s, at(1, 1), smallYard());
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('caboose', true), car('hopper', true)], 'e');
const tray = s.trays.get(trayId)!;
tray.trainNumber = 10;
switching(s);
const fit = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.filter((i) => {
const sort = i as { order: number[]; engineAt?: number };
const after = sort.order.map((n) => tray.consist[n]!);
return badlyMadeUp({ ...tray, consist: after, engineAt: sort.engineAt ?? 0 }) === null;
});
assert.ok(
fit.length > 0,
'a train that cannot leave the Office was offered no sort that would make it up',
);
// And the one that does it says so on the button, rather than leaving it to be discovered.
assert.ok(
fit.some((i) => describeIntent(s, i).includes('MADE UP, ready to leave')),
'the sort that makes the train up does not say so',
);
});
it('lays the train out west to east, with the engine where it will be', () => {
/**
* "You specify above that the order is front to back, but on the screen, if it's eastbound or
* westbound, it may look different" — Jesse, 2026-09-17.
*
* `board-svg.ts` draws the crew strip west on the left, reversing a consist for an east-facing
* train so its nose lands at the east end. The button has to read the same way or it describes a
* different train from the one on the board.
*/
const s = game();
row(s, 3);
addCard(s, at(1, 1), smallYard());
// Engine points WEST: the consist is stored nose first, so west to east reads engine first.
const west = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true)], 'w');
switching(s);
const westLabel = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => describeIntent(s, i))
.find((l) => l.startsWith('re-order'));
assert.ok(westLabel, 'no re-order was offered for the west-facing train');
assert.match(westLabel, /west to east: ◀ ENGINE · /, `a west-facing engine was not drawn leading: ${westLabel}`);
// The same train pointing EAST puts the engine at the far end of the same sentence.
s.trays.get(west)!.railFacing = 'e';
s.trays.get(west)!.facing = 'e';
const eastLabel = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => describeIntent(s, i))
.find((l) => l.startsWith('re-order'));
assert.ok(eastLabel, 'no re-order was offered for the east-facing train');
assert.match(eastLabel, / · ENGINE ▶($| ·)/, `an east-facing engine was not drawn at the east end: ${eastLabel}`);
});
it('labels each option with the train it would make, not with array indices', () => {
const { s } = onTheYard();
const labels = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => describeIntent(s, i));
assert.ok(
labels.every((l) => !/\[\d(,\d)*\]/.test(l)),
`a re-order option still reads as a permutation: ${labels.find((l) => /\[\d(,\d)*\]/.test(l))}`,
);
/**
* The one Jesse wanted, written the way the board draws it: this crew faces EAST, so the strip
* runs west to east and the engine sits at the east end with the boxcar now furthest west.
*/
assert.ok(
labels.includes('re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶'),
`the move that puts the boxcar on the tail was not offered in words — got: ${labels.join(' | ')}`,
);
// And the engine's own positions read as what they do, not as an index.
assert.ok(
labels.some((l) => l.startsWith('put the whole consist ahead of the engine —')),
`the shoving sort was not offered in words — got: ${labels.join(' | ')}`,
);
});
});
/**
* 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',
);
});
});
+4
View File
@@ -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 },
};
+143
View File
@@ -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'));
});
});
+244 -1
View File
@@ -15,8 +15,9 @@ 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';
const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8');
@@ -47,6 +48,18 @@ const KNOWN_UNREDUCED = [
'clearanceRequested',
'dispatchBonusUsed',
'expediteFault',
/**
* The Freight Agent chose to do nothing (§6.3 requires no action). Emitted by `freightAgent.end`
* beside the `phaseEnded` that ends the turn, and reduces to nothing itself — exactly the
* `switchingEnded` pattern below.
*/
'freightAgentIdled',
/**
* The New Train Phase's report that it could give a train nothing (playtest, 2026-09-16, the
* Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to
* offer, so it describes rather than reduces, like every entry on this list.
*/
'makeUpShort',
'phaseBegan',
/**
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
@@ -61,12 +74,30 @@ const KNOWN_UNREDUCED = [
// the pattern every entry on this list follows.
'seatsRotated',
'stageBegan',
/**
* §5's Fedora handover, emitted by `shiftChange` on the same mutate-then-describe path as its
* neighbours here: the clock moves the Superintendent and then says so. Added 2026-09-16 because
* riding on `actorChanged` meant the log dropped it as turn bookkeeping.
*/
'superintendentChanged',
/**
* The line that closes a switching turn. Emitted by `switch.end` beside the `phaseEnded` that
* actually ends the turn, and reduces to nothing itself: it reports what the Moves were spent on
* and where the crew was left, both of which the state already holds.
*/
'switchingEnded',
'trainArrived',
'trainCompleted',
'trainDiverted',
'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',
];
@@ -143,3 +174,215 @@ describe('the intents are what reconstructs a game', () => {
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']);
});
});
/**
* A FREIGHT AGENT TURN, AS THE TABLE READS IT.
*
* Reported on Day 1 Stage 3 of v0.8.0.16, watching a bot: "one car moved to or from a facility —
* can we tell what car, what facility and whether it was to or from." Two separate faults sat
* behind that. The choice line ASSERTED a car had moved, which §6.3 does not require and the bot
* deliberately declines; and the three lines that do report the work named the grid coordinate
* rather than the industry standing on it.
*/
describe('a Freight Agent turn says what it did, to what, and where', () => {
const ctx = {
playerName: () => 'Bot 1',
facilityAt: (_p: number, c: { row: number; col: number }) =>
c.row === -1 && c.col === 1 ? 'the Freight House' : null,
};
it('announces the OPTION without claiming a car moved', () => {
const line = narrate({ type: 'localOpsOptionChosen', player: 0, option: 'freightAgent' } as never, ctx);
assert.ok(
!/one car moved/.test(line.text),
`the choice line still asserts an outcome: ${line.text}`,
);
// It must still say what the Freight Agent is FOR, or the option is a bare name.
assert.match(line.text, /Outbound|Inbound|jam/, `the choice line says nothing about the work: ${line.text}`);
});
it('says so when the Freight Agent deliberately does nothing', () => {
// The bot takes this route on purpose: unjamming a healthy box destroys a stocked load. Silence
// here is what made the turn read as a dropped turn.
const line = narrate({ type: 'freightAgentIdled', player: 0 } as never, ctx);
assert.match(line.text, /nothing worth doing/, `an idle Freight Agent is silent: ${line.text}`);
});
it('names the industry and the direction, not a coordinate', () => {
const at = { row: -1, col: 1 };
const stocked = narrate(
{ type: 'stockToOutbound', player: 0, at, stock: { type: 'boxcar', loaded: true } } as never,
ctx,
);
assert.match(stocked.text, /the Freight House/, `no industry named: ${stocked.text}`);
assert.ok(!/\(1, -1\)/.test(stocked.text), `still speaking in coordinates: ${stocked.text}`);
assert.match(stocked.text, /boxcar/, `the car is not named: ${stocked.text}`);
assert.match(stocked.text, /INTO/, `the direction is not stated: ${stocked.text}`);
const cleared = narrate(
{ type: 'inboundCleared', player: 0, at, stock: { type: 'hopper', loaded: true } } as never,
ctx,
);
assert.match(cleared.text, /the Freight House/, `no industry named: ${cleared.text}`);
assert.match(cleared.text, /OUT of/, `the direction is not stated: ${cleared.text}`);
const jam = narrate(
{ type: 'facilityUnjammed', player: 0, at, from: 'menAtWork', stock: { type: 'tank', loaded: true } } as never,
ctx,
);
assert.match(jam.text, /the Freight House/, `no industry named: ${jam.text}`);
/**
* Where there is no industry, the coordinate is still the honest fallback rather than "nowhere".
*
* AND IT IS SPELLED THE WAY THE ACTION MENU SPELLS IT — X,Y, east/west then north/south. The
* log printed the internal row/col order until 2026-09-21, so the same square read "(1,-1)" in
* the menu and "(-1,1)" in the log, side by side. `test/web.test.ts` pins the two against each
* other; this pins the order itself.
*/
const plain = narrate(
{ type: 'stockToOutbound', player: 0, at: { row: -2, col: 4 }, stock: { type: 'boxcar', loaded: true } } as never,
ctx,
);
assert.match(plain.text, /\(4,-2\)/, `the fallback coordinate is not in X,Y order: ${plain.text}`);
});
});
/**
* WHICH MAINLINE CARD, AND WHAT THE CARD IS CALLED.
*
* Reported 2026-09-22, playing ABS Signals: "In the history, it referred to it as Mainline card 7,
* but didn't give the actual card type, which was plains. It should specify both. Note there were
* two plains cards dealt in this hand." Both halves matter for that reason — the name informs, the
* slot is the only thing telling two Plains apart.
*/
describe('the log names the Mainline card an Enhancement was built on', () => {
const ctx = {
mainlineAt: (node: number) => (node === 7 ? 'Plains' : node === 3 ? 'Heavy Grade' : null),
enhancementName: (key: string) => (key === 'absSignals' ? 'ABS Signals' : null),
};
it('gives the slot AND the card type', () => {
const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx);
assert.match(line.text, /Mainline card 7/, `the slot is gone: ${line.text}`);
assert.match(line.text, /Plains/, `the card type is missing: ${line.text}`);
});
it('calls the card by its printed name, not a split key', () => {
const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx);
assert.match(line.text, /ABS Signals/, `not the printed name: ${line.text}`);
assert.ok(!/abs Signals/.test(line.text), `still splitting the key: ${line.text}`);
});
it('still says something useful when the card cannot be resolved', () => {
// No resolver at all — an engine test narrating events has no division to ask.
const bare = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 2 } as never, {});
assert.match(bare.text, /Mainline card 2/, `the slot must survive with no resolver: ${bare.text}`);
});
it('leaves a grid-square Enhancement naming its coordinate', () => {
const line = narrate(
{ type: 'enhancementPlaced', player: 0, key: 'smallYard', at: { row: -1, col: 2 } } as never,
{ enhancementName: (k: string) => (k === 'smallYard' ? 'Small Yard' : null) },
);
assert.match(line.text, /Small Yard/, `not the printed name: ${line.text}`);
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');
});
});
+4
View File
@@ -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,
});
+4
View File
@@ -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,
+5 -1
View File
@@ -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.
*
+92
View File
@@ -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);
+128
View File
@@ -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 &lt; B &amp; C &gt; 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'), /&lt;script&gt;/, '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>/);
});
});
+77 -2
View File
@@ -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");
});
});
+127 -7
View File
@@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DWELL, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
import { DWELL, MAX_PACE, PACE_LEVELS, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
import type { StepKind } from '../src/sim/pacing.ts';
import type { Intent } from '../src/engine/intents.ts';
@@ -50,7 +50,13 @@ describe('pacing — dwell by kind', () => {
assert.equal(kindOf('draw.end'), 'bookkeeping');
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
assert.equal(kindOf('switch.end'), 'bookkeeping');
assert.equal(kindOf('localOps.choose'), 'bookkeeping');
/**
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
* play on the test server. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
* to do.
*/
assert.equal(kindOf('localOps.choose'), 'action');
assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action');
assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
@@ -63,6 +69,30 @@ describe('pacing — dwell by kind', () => {
assert.equal(dwellFor('switch.move'), 1000);
});
it('supports multipliers above 1, and keeps the tiers in proportion at every speed', () => {
/**
* Jesse, 2026-09-09, after the first play: keep switching and ordinary actions at DIFFERENT
* delays, and support 2.0 and 3.0 as well as 1.5. So this pins both halves — that the larger
* multipliers work at all, and that scaling never flattens the tiers into each other, since the
* relative weighting is the design and the multiplier is only how fast it runs.
*/
for (const pace of [0.5, 1, 1.5, 2, 3]) {
assert.equal(dwellFor('switch.move', pace), Math.round(DWELL.switching * pace));
assert.equal(dwellFor('card.play', pace), Math.round(DWELL.action * pace));
assert.ok(
dwellFor('switch.move', pace) > dwellFor('card.play', pace),
`at ${pace}x a switching move no longer outlasts an ordinary action`,
);
assert.equal(dwellFor('draw.end', pace), 0, 'bookkeeping stays free at every speed');
}
// A whole switching exercise at 3x is slow on purpose, and still not absurd.
assert.equal(dwellFor('switch.move', 3) * 6, 18_000);
// And a typo cannot freeze the board: ?pace=300 from somebody meaning 3.00.
assert.equal(dwellFor('switch.move', 300), DWELL.switching * MAX_PACE);
assert.equal(dwellFor('switch.move', MAX_PACE + 5), dwellFor('switch.move', MAX_PACE));
});
it('scales with the viewer\'s pace, and 0 turns it off', () => {
assert.equal(dwellFor('switch.move', 1), 1000);
assert.equal(dwellFor('switch.move', 0.5), 500);
@@ -90,6 +120,37 @@ describe('pacing — dwell by kind', () => {
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
});
it('offers speeds a player actually reached for, and none the code would clamp', () => {
/**
* Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the
* only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving
* from the splash REPLACES the query string. Hence a real control on the play screen, and hence
* this ladder — which must reach the speeds people ask for and must not offer one that
* `dwellFor` would silently clamp.
*/
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
/**
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
* somebody actually reached for and found insufficient.
*/
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
for (const p of PACE_LEVELS) {
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
}
// Strictly increasing, so stepping the control always changes the speed.
for (let i = 1; i < PACE_LEVELS.length; i++) {
assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing');
}
// The slowest rung has to be slow enough to be worth having: six switching moves at the top of
// the ladder is a full minute, which is the "watch them struggle" case.
const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6;
assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`);
});
it('a silent step beats only when the clock turns over — TODO #18', () => {
/**
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
@@ -97,28 +158,87 @@ describe('pacing — dwell by kind', () => {
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
* times per intent — which came to a quarter of an hour a game.
*/
const silent = { cause: 'phase' as const, lines: [] as string[] };
const silent = { cause: 'phase' as const, player: null, lines: [] as string[] };
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
// Narration always earns the dwell of whatever caused it, clock or no clock.
assert.equal(
dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }),
dwellForStep({ cause: 'switch.move', player: 1, lines: ['moved'], frame: { table: {} } }),
DWELL.switching,
);
});
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
/**
* Two complaints, one from each direction, and the answer is between them.
*
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
* player's own turn. So they were pinned at their tabled beat.
*
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
*
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
* the cost stays bounded because phase beats cluster rather than accumulate.
*/
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
const p = dwellForStep(phase, pace);
const t = dwellForStep(theirs, pace);
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
assert.ok(
p < DWELL.phase * pace,
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
);
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
}
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
// follows the multiplier straight, because "faster" should mean everything.
assert.equal(dwellForStep(phase, 0), 0);
assert.equal(dwellForStep(theirs, 0), 0);
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
});
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
// case for one crew and the arithmetic the design promised: ~6s to watch a whole exercise.
// case for one crew: the announcement, six moves, and an end that shows nothing.
const turn: Intent['type'][] = [
'localOps.choose',
...Array<Intent['type']>(6).fill('switch.move'),
'switch.end',
];
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
assert.equal(total, 6000);
assert.equal(watchableCount(turn), 6, 'the choose and the end are not things to watch');
assert.equal(total, DWELL.action + 6 * DWELL.switching);
assert.ok(total > 5_000 && total < 10_000, `a switching turn takes ${total}ms to watch`);
assert.equal(watchableCount(turn), 7, 'the six moves and the announcement; not the end');
});
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
/**
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on the test server:
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
* was done."* This is the shape that turn actually had — no switching in it at all, because
* switching is not legal until there is track down — and under the original values it came to
* 750ms for the whole thing.
*/
const turn: Intent['type'][] = [
'localOps.choose',
'draw.fromHomeOffice',
'card.play',
'draw.end',
'localOps.choose',
'freightAgent.stockOutbound',
];
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
assert.ok(total >= 3_000, `an ordinary bot turn is only ${total}ms — too fast to follow`);
assert.equal(watchableCount(turn), 5, 'only the turn-ending bookkeeping is free');
});
});
+113 -1
View File
@@ -17,7 +17,7 @@ import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'
import { applyIntent } from '../src/engine/apply.ts';
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts';
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts';
import { applyPublicDelta, changedPiles, deltaPublicFrame } from '../src/sim/public-delta.ts';
const config: GameConfig = {
mode: 'competitive',
@@ -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,
@@ -184,3 +188,111 @@ describe('public frame delta', () => {
});
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
describe('which piles a step moved', () => {
/**
* MEASURED FROM REAL PLAY, then pinned. The table in `changedPiles` claims what each action moves,
* and a claim in a comment is worth nothing unless something checks it — so this drives real games
* and asserts the mapping holds, action by action.
*/
it('maps each action to the piles it actually touches', () => {
const seen = new Map<string, Set<string>>();
/**
* TWO PASSES, because a single driver cannot reach every case. Left to itself the bot almost
* never takes a Department card, and a driver that prefers one then never draws from the deck —
* so each preference is played out separately and the assertions below require BOTH to have
* been observed rather than passing on whichever happened to occur.
*/
for (const prefer of ['draw.fromDepartment', 'draw.fromHomeOffice'] as const) {
for (const seed of [1917398, 191056, 4242]) {
const s = newState(seed);
for (let i = 0; i < 400; i++) {
const actor = currentActorOfState(s);
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const chosen =
options.find((o) => o.type === prefer) ??
options.find((o) => o.type === 'card.discard') ??
options[i % options.length]!;
const before = publicSnapshot(s);
const r = applyIntent(s, actor, chosen);
if (!r.ok) break;
pump(s);
const piles = changedPiles(before, publicSnapshot(s)).map((p) => p.replace(/dept\d/, 'dept'));
if (!seen.has(chosen.type)) seen.set(chosen.type, new Set());
for (const p of piles) seen.get(chosen.type)!.add(p);
if (piles.length === 0) seen.get(chosen.type)!.add('(none)');
}
}
}
const of = (t: string): Set<string> => seen.get(t) ?? new Set();
// NOT VACUOUS: the four cases the mapping is actually about must all have happened.
for (const needed of ['draw.fromHomeOffice', 'draw.fromDepartment', 'card.discard', 'card.play']) {
assert.ok(of(needed).size > 0, `${needed} never occurred, so its rule proved nothing`);
}
// A HOME OFFICE DRAW MOVES THE COUNT AND NOTHING ELSE ON A PILE. The card is private; the deck
// getting shorter is not, and it is the only thing a watcher can be shown.
assert.deepEqual([...of('draw.fromHomeOffice')].sort(), ['home']);
// A DEPARTMENT DRAW touches that Department, and sometimes the deck too — the pile refills from
// it. Both are public, so both may light.
for (const p of of('draw.fromDepartment')) {
assert.ok(p === 'dept' || p === 'home', `a Department draw moved "${p}"`);
}
assert.ok(of('draw.fromDepartment').has('dept'), 'a Department draw must light its Department');
// A DISCARD lands face up on a Department, and which one is public.
assert.deepEqual([...of('card.discard')].sort(), ['dept']);
// A PLAYED CARD that does not stay on the board lands face up in the Salvage Yard.
assert.ok(of('card.play').has('salvage'), 'a played card must be able to light the Salvage Yard');
// ENDING A PHASE moves no card anywhere, so nothing should light for it.
for (const quiet of ['draw.end', 'loadUnload.end', 'switch.end', 'localOps.choose']) {
if (of(quiet).size > 0) assert.deepEqual([...of(quiet)], ['(none)'], `${quiet} lit a pile`);
}
});
it('lights nothing without a previous frame to compare against', () => {
// A reset has nothing to have watched arriving, so nothing on it is lit.
const s = newState(4242);
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
});
});
describe('a Mainline card that changed under the players (Gitea#28)', () => {
it('names the node a Realignment converted, and nothing else', async () => {
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
const { REALIGNMENTS } = await import('../src/engine/content.ts');
const s = newState(4242);
const before = publicSnapshot(s);
// Realignment converts a card to another kind (`content.ts`'s table). Applied to the state directly:
// what is being tested is the DETECTOR, not the play that reaches it — which needs the card in hand,
// the draw option taken and no train on the card.
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline' && REALIGNMENTS.some((r) => r.from === n.card));
assert.ok(at >= 0, 'no Mainline card in this Division can be realigned at all');
const node = s.division.nodes[at];
assert.equal(node?.kind, 'mainline');
if (node?.kind === 'mainline') {
node.card = REALIGNMENTS.find((r) => r.from === node.card)!.to;
}
const after = publicSnapshot(s);
assert.deepEqual(changedDivisionCards(before, after), [at], 'the realigned card was not the one reported');
assert.deepEqual(changedDivisionCards(after, after), [], 'an unchanged Division reported a change');
assert.deepEqual(changedDivisionCards(null, after), [], 'a first board has nothing to compare against');
});
it('says nothing when only the trains on a card moved', async () => {
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
const s = newState(1917398);
const before = publicSnapshot(s);
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[at];
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'tray1', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
assert.deepEqual(changedDivisionCards(before, publicSnapshot(s)), [], 'a train arriving flashed the card');
});
});
+7
View File
@@ -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,
@@ -524,6 +528,9 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
// The rules the game was dealt under, and the score.
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal',
// What the Day that just ended finished on. Public for the same reason the running counts are:
// a collision happens on the Mainline in front of everybody.
'collisionsPrevDay',
'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
// Names, seats, revenue and HAND SIZE — never hand contents.
'players',
+81
View File
@@ -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' }));
});
});
+70 -12
View File
@@ -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,
@@ -44,8 +47,12 @@ const SAMPLES: GameEvent[] = [
{ type: 'stageBegan', day: 1, stage: 7 },
{ type: 'phaseBegan', phase: 'mainline' },
{ type: 'actorChanged', player: 0 },
// Sampled rather than left to swell the unsampled count: this sentence is one a player reads at
// the table every third Stage, so its text is worth exercising.
{ type: 'superintendentChanged', player: 1, stage: 6 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5, movesAllowed: 6 },
{ type: 'switchingEnded', player: 0, movesUsed: 3, movesAllowed: 6, lastMove: { trayId: 't0', to: { row: 0, col: 1 } } },
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
@@ -55,11 +62,26 @@ const SAMPLES: GameEvent[] = [
{ type: 'deckReshuffled', order: ['c1', 'c2', 'c3', 'c4'], rngState: 7 },
{ type: 'departmentRefilled', slot: 0, cardId: 'c2' },
{ type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
// Sampled from the start rather than joining the unsampled 25: an idle Freight Agent is the ONE
// line whose whole job is to be read when nothing happened, so an empty or fallback sentence
// would reproduce the silence it exists to fix.
{ type: 'freightAgentIdled', player: 0 },
{ type: '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 } },
{ type: 'carPassed', player: 0, trayId: 't0' },
{ 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 },
{
type: 'makeUpShort',
trainNumber: 5,
isExtra: false,
placed: 0,
wanted: 3,
missing: ['coach'],
waiting: 14,
divisionYardHolds: 46,
},
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
{ type: 'clearanceGiven', trainId: 't1', allow: false },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
@@ -109,7 +131,7 @@ describe('narration', () => {
/**
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
*
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
* `SAMPLES` exercises the TEXT of 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
@@ -146,6 +168,33 @@ describe('narration', () => {
assert.match(loss.text, /COLLISION/i);
});
/**
* WHOSE OFFICE, AND WHOSE TRAIN — Jesse, playtest 2026-09-16.
*
* The line went to every seat reading "ARRIVED at the Whistle Post … You can work it in Cargo now".
* At a table of four that is one true sentence and one false one: every seat has an Office, so the
* tier alone does not say which district the train is standing in, and three of the four readers
* cannot touch it. Constructed here rather than fished out of a game so both halves are pinned
* exactly, and the resolver-less path is checked too — the replay viewers pass no names for a
* table they do not have.
*/
it('names WHOSE Office a train reached, and never tells the table they can work it', () => {
const named = narrate(
{ type: 'trainArrived', trainNumber: 3, consist: [], office: 'Whistle Post', owner: 1, expedited: false },
{ playerName: () => 'Tom' },
);
assert.match(named.text, /Tom's Whistle Post/, `the arrival did not name the Office's owner: ${named.text}`);
assert.match(named.text, /Tom can work it/, `the arrival did not say whose train it is to work: ${named.text}`);
assert.doesNotMatch(named.text, /\bYou can work it\b/i, `the arrival still addresses every reader: ${named.text}`);
// No resolver — still English, and still no raw index leaking into a sentence.
const anon = narrate({
type: 'trainArrived', trainNumber: 3, consist: [], office: 'Whistle Post', owner: 1, expedited: false,
});
assert.match(anon.text, /at the Whistle Post/, anon.text);
assert.doesNotMatch(anon.text, /undefined|\bplayer \d+\b/i, anon.text);
});
it('points at the board cell where something happened', () => {
const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' });
assert.deepEqual(n.where, { row: 1, col: 2 });
@@ -172,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'));
});
@@ -675,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`);
@@ -691,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
+92
View File
@@ -0,0 +1,92 @@
/**
* The engine's speed-ups must not change a single game (2026-09-15).
*
* `applyIntent` became `prepareIntent` (check and execute, sharing one walk of the position's routes)
* followed by `commitEvents` (reduce and tally), so the switching planner can decide every candidate
* against one position and apply each to a copy. Two properties hold that together:
*
* 1. `prepareIntent` never writes the state it reads — including through the route cache it opens.
* 2. Preparing on one state and committing to an EQUAL copy lands on exactly what `applyIntent` does.
*
* Checked at every decision of seeded bot games rather than on hand-built positions, so the intents
* exercised are the ones real play submits.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { applyIntent, commitEvents, prepareIntent } from '../src/engine/apply.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
collectiveRevenueFloor,
lengthProfile,
} from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
const config = (): GameConfig => {
const days = lengthProfile('short').days;
return {
mode: 'solitaire',
days,
minCombinedRevenue: collectiveRevenueFloor(1, days),
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 },
};
};
const serialise = (s: GameState): string =>
JSON.stringify(s, (_k, v) => (v instanceof Map ? [...v] : v instanceof Set ? [...v] : v));
describe('applyIntent split into prepareIntent and commitEvents', () => {
it('prepares without writing, and committing to a copy matches applying in place', () => {
let decisions = 0;
let rejectedSeen = 0;
const s = createGame({ id: 'split-8919', seed: 8919, config: config(), playerNames: ['bot'] });
const policy = {
name: 'split-probe',
choose(st: GameState, player: number, options: ReturnType<typeof legalActions>) {
const chosen = developerBot.choose(st, player, options);
if (decisions < 400) {
decisions++;
const before = serialise(st);
const prepared = prepareIntent(st, player, chosen);
assert.equal(serialise(st), before, `decision ${decisions}: prepareIntent wrote into the state it read`);
assert.ok(prepared.ok, `decision ${decisions}: a legal choice was refused by prepareIntent`);
const viaCommit = structuredClone(st);
const viaApply = structuredClone(st);
commitEvents(viaCommit, prepared.events);
const applied = applyIntent(viaApply, player, chosen);
assert.ok(applied.ok);
assert.deepEqual(applied.events, prepared.events, `decision ${decisions}: the two paths produced different events`);
assert.equal(serialise(viaCommit), serialise(viaApply), `decision ${decisions}: committing to a copy diverged from applying`);
// A refused intent must come back refused from both paths, with nothing written.
const refused = { type: 'switch.end' } as const;
const r = prepareIntent(st, player, refused);
if (!r.ok) {
rejectedSeen++;
assert.equal(serialise(st), before);
assert.equal(applyIntent(structuredClone(st), player, refused).ok, false);
}
}
return chosen;
},
};
const r = playGame(s, policy, pump);
assert.ok(r.finished, 'the probed game did not finish');
assert.ok(decisions > 0, 'no decision was probed');
assert.ok(rejectedSeen > 0, 'no refused intent was exercised');
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Seat recovery codes — Gitea#33.
*
* The properties worth pinning are the ones that make a code safe to put in a link: it is spendable
* exactly once, it stops working on its own, and a bad code is indistinguishable from a spent one.
* `now` is a parameter rather than a clock, so expiry is tested without faking timers.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CLAIM_TTL_MS, createClaimStore } from '../../src/server/claims.ts';
describe('seat recovery codes', () => {
it('mints a code that names the seat it was minted for', () => {
const claims = createClaimStore();
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 1000);
assert.equal(expiresAt, 1000 + CLAIM_TTL_MS);
assert.deepEqual(claims.redeem(code, 1000), { token: 'tok-abc', gameId: 'game-1' });
});
it('spends a code exactly once — a link in a chat log is worth nothing afterwards', () => {
const claims = createClaimStore();
const { code } = claims.mint('tok-abc', 'game-1', 0);
assert.ok(claims.redeem(code, 1));
assert.equal(claims.redeem(code, 2), null, 'the same code was accepted twice');
});
it('stops working once its time is up, without anything having to sweep it', () => {
const claims = createClaimStore();
const { code } = claims.mint('tok-abc', 'game-1', 0);
assert.equal(claims.redeem(code, CLAIM_TTL_MS - 1)?.token, 'tok-abc', 'expired early');
const again = claims.mint('tok-abc', 'game-1', 0).code;
assert.equal(claims.redeem(again, CLAIM_TTL_MS), null, 'a code outlived its expiry');
});
it('answers the same way for unknown, spent and expired codes', () => {
const claims = createClaimStore();
const { code } = claims.mint('tok-abc', 'game-1', 0);
claims.redeem(code, 1);
const expired = claims.mint('tok-abc', 'game-1', 0).code;
assert.equal(claims.redeem('never-existed', 1), null);
assert.equal(claims.redeem(code, 1), null);
assert.equal(claims.redeem(expired, CLAIM_TTL_MS + 1), null);
});
it('gives every mint its own code', () => {
const claims = createClaimStore();
const codes = new Set([0, 1, 2, 3, 4].map(() => claims.mint('tok-abc', 'game-1', 0).code));
assert.equal(codes.size, 5, 'two mints produced the same code');
});
it('forgets expired codes rather than accumulating them', () => {
const claims = createClaimStore();
claims.mint('tok-a', 'game-1', 0);
claims.mint('tok-b', 'game-1', 0);
assert.equal(claims.outstanding(0), 2);
assert.equal(claims.outstanding(CLAIM_TTL_MS), 0, 'expired codes were still being held');
});
it('keeps a short-lived code short-lived when asked for one', () => {
const claims = createClaimStore();
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 500, 60_000);
assert.equal(expiresAt, 60_500);
assert.equal(claims.redeem(code, 60_500), null);
});
});
+190
View File
@@ -0,0 +1,190 @@
/**
* 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('hands a Competitive seat its save only once the game is over, and a Co-op seat at any time', async () => {
// Jesse's ruling on TODO #117: the seed in a save is every rival's hand, so a Competitive
// download waits for the end; a co-operative table has nothing to hide from itself.
const { host } = await table();
assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200);
const refused = await fetch(base + `/api/save?token=${host.token}`);
assert.equal(refused.status, 403, 'a running Competitive game handed out its seed');
assert.deepEqual(await refused.json(), { error: 'SAVE_AFTER_FINISH' });
const coop = await post('/api/lobby/create', { secret: SECRET, config: { ...config, mode: 'coop' }, displayName: 'Host', players: 2 });
const coopHost = coop.json as unknown as Seat;
assert.equal((await post('/api/lobby/join', { secret: SECRET, gameCode: coopHost.gameCode, displayName: 'Guest' })).status, 200);
assert.equal((await post('/api/lobby/start', { token: coopHost.token })).status, 200);
const allowed = await fetch(base + `/api/save?token=${coopHost.token}`);
assert.equal(allowed.status, 200, 'a Co-op seat could not download its save');
const body = (await allowed.json()) as { save: { seed: number } };
assert.equal(typeof body.save.seed, 'number');
});
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');
});
});
+5 -1
View File
@@ -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
});
+59 -1
View File
@@ -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/);
}));
});
+25
View File
@@ -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');
});
});
+106 -9
View File
@@ -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
@@ -230,7 +238,7 @@ describe('card catalogue (component 1)', () => {
* "Both" column loses its only argument.
*
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
* the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
* the Grocer's Warehouse only receives. `home-deck.md` prints both that way,
* and so does the modifier set — all three Refinery modifiers grant outbound.
*/
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
@@ -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');
});
});

Some files were not shown because too many files have changed in this diff Show More