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
523 KiB
Changelog
Detail behind each commit. Commit messages stay high level; the reasoning, the measurements, and the things that turned out to be wrong live here.
Measured figures are 100 solitaire Standard games with the developer bot unless stated otherwise. The target is 20 Revenue over 5 Days.
Versions
Every commit carries a bump, decided with Jesse rather than assumed:
- third digit — bug fixes to what is already there.
- second digit — a new set of features.
- 1.0 — the first release we think is solid enough to call one.
The number lives in package.json and nowhere else; scripts/build-web.ts stamps it into every
page as v0.1.0 · <sha> · <date>, so what is deployed can always be identified from the page itself.
0.8.1.0 — 2026-09-22
A second-digit bump, and a deliberate one. 0.8.1 had been reserved for the seatless display table; that work is getting more thought, and this batch — a table pass over v0.8.0.17 — earned the number on its own. Six reports, one of which turned out to be a rules question, one a wording complaint with a real bug underneath, and four straightforward.
A car cleared from a red Inbound box came back still loaded
Reported as a wording problem — "the option to send the loaded coach in the red inbound box to the classification yard is technically accurate but doesn't make any sense" — and the wording was only the visible half.
inboundCleared did this:
// `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts).
s.yards.classificationYard.push(pooled(e.stock));
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 still aboard (advance.ts). So the
comment described an intention the call never carried out, and every car the Freight Agent cleared
reached the Classification Yard still carrying a load that had already been delivered and already
been 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 never unload another passenger. Measured over five three-Day solitaire games
before the fix: 18 loaded coaches in the Classification Yard 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 — to the common supply. The option now says that rather than describing the counter that moves: "let the passengers out of the station — the coach in the red Inbound box at (2,-1) goes back to the Classification Yard empty".
Not everything loaded in a yard is this bug, and the fix is scoped to the red box. A train that
completes its run returns its cars as they stand, loaded ones included — that is retireTrain, it
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 into a train 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, and it was measured rather than argued. What is in the yards
changes, so the worry was real: a save whose later moves depend on a cleared car being loaded would
stop replaying. All twelve saves on the test server were pulled and replayed through
tryResumeSession — the server's own boot check — against this build. Six resume and 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: three CARD_NOT_IN_HAND at move 3 in saves from engines 0.5.3, 0.7.0 and 0.7.1,
two NO_PENDING_DECISION from the Gitea#26 clearance fix, and WHISTLE-6945 stopping at move 529 on
v0.8.0.14's Modifier ruling. Nothing new was stranded.
That replay is the cheap check to repeat on any release that touches state, and it is a better one than reading the next boot log: it answers before the install rather than after it.
The history said "Mainline card 7" and left you to remember what that was
"It should specify both. Note there were two plains cards dealt in this hand." — which is exactly why both: the name informs, and the slot is the only thing telling two Plains apart.
The line also had a second fault nobody reported, one word to its left. It built the card's name as
e.key.replace(/([A-Z])/g, ' $1'), so absSignals printed as "abs Signals" — uncapitalised,
and bypassing the authored names cardName started honouring in v0.8.0.17 for precisely this
reason: no key-splitter recovers an acronym. The action list one line above it said "ABS Signals"
while the log said "abs Signals".
Both are resolver-shaped, so narrate takes mainlineAt and enhancementName alongside the
facilityAt it already had, and simpleCardName is exported so the log reads the same table the
buttons do. mainlineModified had both faults in the neighbouring case and is fixed with it.
Leaving a running game was a dead end
"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. enterSeating hides #lb-choice-section, and the only things that put it back
were the lobby's own two leave paths. Leaving a running game is a third route — main.ts calls
runLobby again — so the section stayed hidden from the previous seating and the lobby came back
holding nothing but "Games you are in". The two doors were on the page the whole time, at
display:none, with no control that could reveal them.
Reset in runLobby rather than in the leave handler, because that function is the one thing every
route onto this screen goes through — which is why the two paths that did it themselves missed a
third.
The lobby's buttons say they are buttons
"Create new game should have that same amber border when it's enabled and ready to click, just like the other action buttons in the rest of the game."
#actions button has meant "this is a move you can make" all game. Look up game, Join this game,
Create new game, Copy code, Copy invite link, Start game and Rejoin now carry it. The selector is a
list of those 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 plain chrome, because the amber is a promise the button would not keep.
The Department refill is a rule, and now it is written down
"Why was a department slot refilled? If I take the last card there, then there should just be no cards available in that stack."
The game is right and the documentation was silent. §6.2: "If any of the Department decks is empty,
draw a Home Office card and place it in the empty spot." It fires only when the draw actually
empties the pile — refilling on every draw would grow the Departments without limit and drain the
Home Office deck into them. 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.
Games in the Manage Game dropdown say how old they are
"I've got four games out there, but I have no idea which one's which or how old they are." The
label carried the code, the seat count and the position — everything except the fact that says
whether a game is live or abandoned. lastMoveAt was already on the wire from the server's
summary() and simply unused.
Both forms, because they answer different questions: "5h ago" answers the one that was asked,
and the timestamp beside it is what you quote when deciding to end a game. A lobby reports when it
was created and says so. The date formatter moved to utils.ts, shared with Games in Progress so
the two cannot drift.
0.8.0.17 — 2026-09-21
Four things a table found on Day 1 of v0.8.0.16, all of them the same shape: the game knew something and the screen did not say it.
ABS Signals could only ever be played on one Mainline card
Its tooltip says "any Mainline card". Exactly one was ever on offer — the Trestle, on the board it was reported from.
The engine was never wrong. check accepts any node whose kind is mainline, and
legalActions filters by check, so every Mainline card was enumerated and legal. The whole
failure was in the LABEL: describeIntent named i.placement and never i.node, so all of them
described themselves as plain "play ABS Signals" — and the action list drops duplicate labels, which
discarded every one but the lowest-index node before the menu saw it.
This is the third time that trap has been sprung, and the file documents the other two three lines apart: a turnout's two rotations produced one label each until the rotation was named, and three Department discards collapsed into one button until the pile was named. The fix is the same one both times: name the thing that distinguishes them. Pinned by a test that counts the Mainline cards in the division and requires a distinct, named spot for each.
The card is also called what the card face calls it. prettyKey rendered absSignals as "Abs
Signals" on a button while the tooltip beside it said ABS — an acronym no key-splitter can recover —
so the authored names in the content tables now win. Three of those names were transcribed in
sentence case and were corrected rather than adopted: the repository says "Yard Office" 36 times
against "Yard office" twice, and a lookup that imports its own source's typos is the drift it exists
to prevent.
Nothing on a Mainline card showed what was standing on it
Played, ABS left no mark. You found out it existed by hovering the card — which is the complaint the Heavy Grade wedge answered in v0.8.0.7, and it matters more here, because ABS is what decides whether running a second train onto that card is safe.
A card carrying it now draws a signal mast with a lit lamp at its top-right corner. A signal is the literal object, and unlike a text badge it needs no room for words, which is what lets it sit clear of a name as long as "Uncontrolled Siding" on a 152px cell.
The Mainline modifiers had the same defect and are drawn too, as BRK, AIR and HLP beside
the card's name. Tags rather than names only because the measurements leave no choice — "Brakeman ·
Airbrakes · Helpers" is thirty characters where about eleven fit — and the tooltip has always spelled
them out. Realignment is deliberately not among them: it never sits on a card, because reduce
takes the became branch and changes node.card outright, so a realigned Trestle simply IS an
Uncontrolled Siding afterwards. Asserted in the test, so the absence reads as a finding rather than
an omission.
A Freight Agent turn said a car moved when none had
"Chose FREIGHT AGENT work — one car moved to or from a facility", and then nothing. Three faults behind one line.
The line asserted an outcome. §6.3 requires no action at all, and the bot takes that route deliberately — unjamming a healthy box destroys a load that cost a whole Local Operations action to stock, so an idle Stage is strictly better. It now says what the Freight Agent MAY do.
An idle Agent was silent. A new freightAgentIdled event says so, and gives the reason. It
reduces to nothing, exactly like switchingEnded: it reports a choice the state already holds.
The work named a coordinate, not the industry. A place helper has existed for this since the
switching lines were moved to it, and its own comment makes the argument — "(-1,1)" is the grid's
notation and means nothing at a table where people are looking at cards. These three lines were
missed. A Freight Agent turn now reads "loaded a loaded boxcar INTO the green Outbound box at the
Freight House", with the direction in capitals because to-or-from was the question asked.
The log and the action menu spelled the same square differently
view.ts wrote (col,row) — X,Y, east/west then north/south — with a comment saying why.
narrate.ts wrote (row,col), the internal storage order, with no comment at all. So the menu
offered a move to "(1,-1)" and the log then reported it at "(-1,1)", in two panels read side by
side. The log follows the map now.
Pinned by a test that renders one square through BOTH describers and compares them to each other rather than to a literal — a test written against either file alone would have passed all along.
The documentation is reachable from inside a game, and all of it is published
v0.8.0.16 published the Quickstart and nothing it points at. Its §8 "Where to read more" links five further documents by relative path, and every one of them 404'd on the package — verified against the running container, five of six paths missing. The whole table was dead. The build publishes the full set now, and the test reads the links OUT OF the guide rather than listing them, so it cannot go stale the way the references themselves did.
The guide is linked from the This Game card (Jesse's call), which is where reference already lives — the seed, the seat, the house rules — rather than from the header, which is the line that must not wrap. It needs no mode awareness: solitaire and multiplayer are the same page on the same origin, so one relative link resolves in both, on the public site and on a StartOS box alike. Every link opens in a new tab, because a player reading the rules mid-turn must not lose the game behind them.
The references dropped the version from their names
StationMaster-Rules-v0.4.5.md and three like it described v0.8.0.16 and had done since the
v0.8.0.15 audit. The v0.4.5 was the prototype rules edition they were first written against, kept
in the filename only because thirty-six citations pointed at it — and it read, to anyone opening the
published guide, as documentation five minor versions out of date.
They are quickstart.md, rules.md, home-deck.md, mainline-deck.md and components.md now,
with every citation rewritten. These are kept current with each release rather than published as
editions, so the name is always the latest and the build each describes is stated at the top.
Two errors surfaced while checking them against this release, which is the argument for doing it:
home-deck.md listed ABS Signals among the Enhancements "played into your district" that "change
what a square does" — it does neither, and that miscategorisation is this release's bug written
down. And mainline-deck.md, which lists everything that may be played onto a Mainline card, never
mentioned ABS Signals at all. Both corrected.
0.8.0.16 — 2026-09-20
The Quickstart put where a tester can actually reach it, the last place that still told the old story about the Interchange, and the release that carries v0.8.0.15's corrected card text into a package.
The Quickstart is published beside the game
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 someone who already has the repository. Nobody being
handed the box has it.
build-web.ts now copies docs/quickstart.md into dist/quickstart.md, and the
splash page offers it under the three doors: New to Station Master? Read the Quickstart guide.
Not a fourth door — reading the guide is not a way to play, and giving it equal weight in that
grid would say it is.
Copied, never re-written. The Markdown document stays the one copy. A hand-written HTML twin would drift from it on the first edit, which is exactly the failure #15a was raised about and exactly what the v0.8.0.15 pass spent its time undoing: four references a month and two minor versions out of date.
Served as plain text, which is honest rather than good. Tables render as rows of pipes and the links do not click. Rendering it into a styled page wants a small Markdown converter and is filed as TODO #109; this is the fifteen-minute version that gets the guide in front of testers for this round instead of leaving them without one.
Two things had to be true and neither is checked by tsc, so both are tests:
- The link has to find the file. 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. The test asserts the guide is
in
dist, is the guide, and is the file the splash page names. The build also warns loudly rather than silently skipping a missing document. - A
.mdfile must not arrive as a download. The server's MIME fallback isapplication/octet-stream, which a browser saves instead of displaying, so the link would hand a tester a file rather than a page.'.md': 'text/plain; charset=utf-8'was added to the table inhttp.ts, 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.
sortsCars says what it gates, not what the card prints
Asked directly after v0.8.0.15 — does everything now agree? — and the audit turned up one place that did not. The field's own doc comment read:
/** Interchange: "Sort cars in new order". */
sortsCars: boolean;
which names the 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 the same question, which is 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. This is what a developer reads before using the flag, and it is the most likely source of the sentence v0.8.0.15 had to correct — the one telling players, on the board, that cars could be sorted at an Interchange. The name is kept for its link to the card face, and the comment now says outright that the name is not the meaning.
Everything else already agreed, and was checked rather than assumed: mainlineDescription, the
generated rules/as-built.md, the Mainline deck reference and the Rules FAQ all say the printed
sorting is unimplemented and that a consist is re-ordered at a Small Yard. rules/implications.md
is the deliberate exception and was already correct — it transcribes what the card face prints and
then records that the concept is "still unimplemented".
The reference stamps, and what was filed rather than done
The five documents v0.8.0.15 stamped — the Quickstart, the Rules, Components, the Home deck and the
Mainline deck — plus docs/design.md now read v0.8.0.16, and the Rules book's implementation
note about card.discard says the same. They describe this build because the audit above re-checked
them against it, not because the number was swept forward: the point of the stamp is that it was
earned, and a stamp bumped without a reading is worth less than none.
TODO #109 holds the part deliberately not done — rendering the guide into a styled page instead
of serving it as plain text. build-web.ts's comment names that number rather than gesturing at
"the next step", so the file and the worklist cannot drift apart the way the references just did.
Why this is a release at all
One player-visible change, the link to the guide, and one that only a developer reads. The Interchange correction a player actually sees on the board is v0.8.0.15's; this is the version the wrapper bundles, so that correction reaches the box rather than only the repository — and a tag with an uncommitted comment sitting on top of it is not a thing to package.
0.8.0.15 — 2026-09-20
The reference documentation brought up to the game as it actually runs, ahead of the next round of testing, plus a Quickstart to hand a tester who has never played. One live bug fell out of the pass.
The Interchange advertised an action the game does not have
mainlineDescription told players "Cars may be sorted into any new order here." It is the printed
capability and it has never been implemented — 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.
That sentence was not only in the documentation. view.ts renders this text as a Mainline card's
what, so it is what a player reads on the board, and the generated card 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. The description now says what the card does,
the generated column is headed "Extra may start", and the Rules FAQ answer says the card used to
claim otherwise.
The documents
Four hand-written references had not been touched since v0.6.2 and were a month and two minor
versions stale. Each now carries the build it describes at the top. The v0.4.5 in their filenames
is the prototype rules edition they were first written against, and the names are kept deliberately:
src/, CHANGELOG.md and docs/rules/ all cite them, and several of those citations are historical
records of what a document said at the time.
quickstart.md— NEW. What the game is, how you win, the shape of a Stage, what is on the screen, a first twenty minutes, the things that surprise new players, and what to report. Written for somebody about to play rather than somebody building it.- Rules. §3.4 replaced outright — the
firstToTarget/highestAfterDaysvictory model and thetarget-bearing length presets have not existed since 2026-08; it is a freedayscount and a combined floor of3 × players × days, with extended play. §3.5 and §6 said there was no lobby, no server and no multiplayer; there is. All three optional rules are implemented now, and "Sister Trains" never existed. Crossing time is regions, not mph. Extras are started where the player chooses. Added: the coach ratchet, the Small Yard's nose sorting, the make-up shortfall report, and the screen's names for the last two phases, which differ from the rules' names. - Home deck. Restructured around how the deck is used — piles, hand limit, reshuffle,
placement, upgrades, what a modifier grant means — and defers every per-card table to
rules/as-built.md. The counts table came out under TODO #15a, Jesse's own ruling: counts move with balance, and that table had been wrong for a month. Also corrected: industry track length (four cars like any card, not the box count), modifier grants, andemptiesOnly, which is enforced. - Mainline deck. Said cards were dealt "with replacement" in its opening paragraph and corrected itself four sections later; priced crossings in mph; and listed the Uncontrolled Siding as a passing card, which it is not — it puts an arriving train in the siding a region behind.
- Components. Verified against
ROLLING_STOCK_SUPPLY; the table was right. Added the Crew Tray, Whistle Post and Limits supplies, the coach ratchet, and what the Fedora actually carries. docs/design.md, the index everything starts from, said v0.4.3, "what is not: the server", and 493 tests. It now lists the player-facing references — which it never has — so the Quickstart is findable, and markscard-reference.mdsuperseded rather than presenting it as the card spec.
rules/as-built.md needed no correction beyond the Interchange, which is the point of generating
it: npm run build:cards rewrites it from content.ts and test/card-reference.test.ts fails if
the checked-in file disagrees. Everything hand-maintained around it had drifted; it had not.
0.8.0.14 — 2026-09-17
Six reports from the Day 2-3 playtest of v0.8.0.13. Two were the same shortage seen from opposite ends, one was a rule working as designed that Jesse has now reversed, and three were the screen saying too little or too much.
THIS RELEASE STRANDS GAMES IN PROGRESS, INCLUDING THE ONE ON THE BOX. Modifiers are now bounded
by the Limits, which makes a once-legal move illegal — so a save holding one is refused at that move.
Measured on the playtest save: whistle-6945.day3.stage10 stops at intent 528 of 539, eleven moves
from the end, on card.play c55 → (-2,4). The file is untouched and v0.8.0.13 still finishes it.
Every coach in the game was stuck in the Classification Yard
Two reports, one cause. The Sparrow (trains 5/6) calls for three coaches and nothing else, and ran the length of the Division empty; Tom had a loaded coach standing at his Depot and could find no way to unload the passengers. Both are §2.2 and §9.2 acting exactly as printed, and the combination is a one-way ratchet: boarding discards the emptied coach into the Classification Yard, detraining draws a fresh empty out of the Division Yard, and Classification comes back only when the Division Yard runs bare.
Measured across the save, because the shape of it is the point: sixteen coaches in the Division Yard at setup, zero from Day 2 Stage 8 onward, fifteen piled up in Classification — while the Division Yard sat at 46-47 freight cars and stopped draining, so the refill was never going to fire. Every passenger operation in the game was over, and four of the twelve timetabled trains carry nothing but coaches.
Jesse's ruling: the rule stands, the game says so loudly. The same ruling Gitea#2 got, and the same one 0.8.0.13 gave the blocked Depot panel. Three places say it now:
- A train made up short reports it (
makeUpShort).trainNeedingCarsreturns null both when a consist is full and when the yard holds nothing it will take — the same answer for "done" and "cannot be done" — so the phase moved on in silence, and the only trace was a MADE UP line promising "now taking cars" with nothing after it. It now names what the card wanted, how many are waiting in Classification, and how far the Division Yard is from bare. - The yard panel warns while the condition lasts, rather than only at the moment a train goes short.
- The Depot's blocked panel already said it, and still does — it was correct all along, off to the side, and read by nobody who had gone looking in the wrong place.
A modifier may no longer stand outside the Limits
Reported as a bug and it was working as designed — the design being a call of Jesse's, recorded in
apply.ts: a Modifier is not track, so a Facility standing at the limit kept all nine of its spots,
because bounding the card looked like it would make it unplayable exactly where a district ends.
Reversed. What decided it is what the board shows — Transmission Lines at (-2,4) with the sign at
column 3 reads as building outside your own territory, and §8.1 and §10 both reason about what lies
inside a player's Limits.
The feared case was checked on the move that prompted the change rather than argued away: the Power Plant was at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were both 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).
The history panel was full of coordinates
"Does switching show up in history at all?" — it all did. A six-Move turn wrote a line per move, every one of them a pair of grid coordinates, plus one per mandatory coupling. Switching is still logged in full; what changed is what the panel DRAWS: the line saying somebody switched, the first move of the turn, work at an industry, the Small Yard sort, and a closing summary.
The suppressed lines are still written, marked trace, and that is not a detail. Dropping these
events was the first attempt and the step-queue 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 — the exact thing v0.8.0 was built to let a table watch. The
tone is the seam. The caption keeps its text, the panel filters the tone.
The last move rides in the closing line rather than being kept in place, and it has to: nothing
knows a move was the last until the turn is over, and by then the line has been written and streamed
to every client (server/session.ts § linesSince), so it cannot be revised. switchingEnded says
what the turn cost and where the crew was left — "finished switching — 3 of 6 Moves used, leaving
Train 10 at the Small Yard".
Places are named rather than pointed at: at the Freight House and the Small Yard instead of
(1,1). Two smaller things fell out of reading these lines properly. Every move line ended with
"The crew chip on the grid carries the whole train with it" — six times a turn, when the opener
already says it once, and it pushed the useful half of the line out of the caption row. And the move
count said "of 6" with the 6 hardcoded, which is simply wrong on a Reduced-Visibility night Stage
where a turn gets five; the turn now carries its own allowance.
Making up a train says which train
"I did not see anything in the history about making up train 10." It was all there — a MADE UP line
and one line per car — and every one of those lines read "the train being made up", so a player
looking back for train 10 found nothing under that name. carPlacedOnTrain and carPassed now carry
the number, the way trainArrived carries its owner. Also "a empty tank" is now "an empty tank".
The Small Yard offered permutations, not trains
The menu read re-order consist [1,2,3,0] — the engine's own array indices offered to a person. The
option Jesse wanted was the first of the five and he could not tell which one it was. Each option
now reads as the train it would build: re-order to engine · loaded hopper · empty tank · caboose · loaded boxcar (front to back).
One of the five re-ordered nothing at all and would still have spent a Move — bringing the LAST car
to the end is the identity — and a two-car train's "full reversal" duplicated its only real option.
Both are filtered by the resulting ORDER rather than by the case that produced them, so a new
generator cannot bring either back. No typed-order box: check already accepts any permutation, so
one is buildable, and Jesse declined it as more to go wrong for a labelling problem.
A Small Yard may now put cars ahead of the engine
Jesse's own open question, discussed and then built. Two sources disagreed and the design notes
won. The v0.4.5 card text says a train there may "reorder its entire consist and put the engine
at the nose", which is what consistSorted did unconditionally. implications.md, drawn from the
design source, says a train "may sort itself into any order, including cars ahead of the engine"
— and records that the Small Yard is the answer the switching puzzle was designed to have.
switch.sortConsist carries an optional engineAt, absent meaning the nose, so every save written
before this replays to exactly the train it built. The menu did not multiply. Offering every car
order at every engine position takes a four-car consist from four options to twenty, which is the
unreadability that started this; instead the engine is a separate short list offered against the
consist as it stands — four re-orders and four engine positions, eight readable options. A player
who wants both spends two Moves, the same price the yard charges for any second sort.
§8.2 needed no new code, and the reason is worth stating because the obvious guess is wrong.
badlyMadeUp is deliberately direction-free: a train with its whole consist ahead of the engine is a
PUSHING train and fit to run. What it refuses is a broken-backed train — the engine buried among its
own cars — and a caboose anywhere but the end away from the engine. So the hold Jesse asked for was
already there, and the button now warns before the Move is spent by asking that same predicate rather
than keeping a copy of it.
That warning immediately earned itself: every one of the eight options on train 10 is refused by
§8.2, including the arrangement asked for at the table, because that train carries a caboose and
each sort moves it off the rear. The menu says so now instead of spending a Move to find out. It is
also the correct answer rather than a gap — that train is ALREADY made up, so every sort on offer
would break it — and the labels distinguish the two cases outright: MADE UP, ready to leave or
HELD at the Office: <why>.
A made-up order is always on the menu for a train that needs one, which is worth stating because it looks as though it might not be. A yard sort serves two errands — pulling one car out to an end to be spotted, and putting the train back together to leave — and the curated orders 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 among them. An explicit "make it up to leave" option was written and then deleted, because it produced exactly that order and was dropped by the dedupe every time.
West to east, the way the board draws it
"Front to back" is not a direction a table can read: which end is the front depends on which way the train is pointed, and the board has drawn the crew strip west on the left since v0.8.0 — reversing the consist for an east-facing train so its nose lands at the east end, with the engine as a ◀ or ▶ arrow. The sort labels now read the same way, so the button and the picture describe the same train and "ahead of" and "behind" the engine are read off the strip rather than asserted:
re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶ · HELD at the Office: the caboose must be at the rear of the train
put the whole consist ahead of the engine — west to east: ENGINE ▶ · caboose · empty tank · loaded hopper · loaded boxcar
badlyMadeUp's "1 car(s) ahead of it" is a real plural now; it reaches a player through the held
train's reason as well as through these labels.
A role says what it is for
Tom reached for the Freight Agent to unload passengers, which it has never done — detraining is a Porter's action in the Cargo phase. Both halves were working and neither was visible. The Freight Agent, Porter and Laborer groups now carry a sentence saying what the role does, where the role is chosen, rather than leaving a player who picked the wrong one to discover it by finding nothing there.
0.8.0.13 — 2026-09-16
Nine reports from the Day 1–2 playtest of v0.8.0.12. One was a real bug that cost 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 Division Yard no longer takes a click while your board is behind
The worst of the batch, because it moved a car. 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 the make-up wiring sat OUTSIDE that guard, so the yard chips stayed lit and clickable.
Reported exactly as it happens: a bot was adding the last car, the board lagged, a chip was clicked,
a coach left the Division Yard and the train still ended up with three cars. The click had submitted
a real intent against a board that was several moves stale.
Both halves are fixed. The chips are wired only when the queue is idle, and the yard COUNTS are now drawn from the board on screen rather than the live game — they were the one panel still reporting a future the player had not been shown, which is what "the yards may not be in sync with the turns behind" was describing.
Every seat has a button, in map order
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 — watching the board follow
whoever was acting, with no way back. Your own seat is now in the row, and watchedDistrict returns
your board for it rather than falling through to the actor, which is the same bug seen from the other
side.
The buttons are ordered by SEAT — west to east, exactly as the Division map draws it — instead of by
player index, which is the order people joined. Seat is not player index and must not be assumed to
be: under Employee Rotation the seating moves, and because the row is sorted from the Frame's own
seat on every render, the buttons rotate with the players rather than having to be told.
The Fedora passing is said out loud
§5 moves the Superintendent at the end of Stages 3, 6, 9 and 12, and the log never mentioned it. The
rotation was riding on actorChanged — turn bookkeeping, fired every time the cursor moves, which
record() drops as noise — so the one moment that event carried something a player needed went past
in silence, with only "Supervisor Shift" in the history to hint at it.
It is its own event now (superintendentChanged), narrated in the log and announced on screen the
way a completed run already is. The phase keeps its name: the Supervisor Shift refreshes every
Laborer and Porter EVERY Stage, and the Fedora moves only every third — naming the phase after the
rarer event would mislead about the common one.
A collision says whose Office it was and who paid for it
The line named the wreck and the reason and stopped there: the 5-point penalty rides in a separate
revenueChanged, so a player had to add two log entries together to learn who had just lost five
Revenue, and "COLLISION at the Office" never said whose. The faulting seat is in the event — and for
anything inside a district that seat IS the district's owner — so the line now reads "COLLISION at
Tom's Office … Tom loses 5 Revenue — it happened in their district." A Mainline collision is phrased
differently because §10 makes it the Superintendent's, which is a different kind of fault.
Passengers: the rule stands, the silence goes
Reported as a bug and it is not one, which took a replay of the save to establish. The Depot in
question had a Restaurant and a Hotel beside it and cap{out:3} — capacity was never the problem,
and the modifiers grant exactly what they print. What stopped a second passenger was §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 the Classification Yard, which §2.2 returns only when the Division Yard
runs bare, and it was holding sixty-odd cars.
Jesse's ruling is the same one Gitea#2 got: the shortage stays, because running out is part of the game. So the blocked panel now says it — room for N more passengers, no loaded coach in the Division Yard, and how many are waiting in Classification — instead of the action simply being absent from the menu with no reason given.
Smaller
"Working left" is now "working eastward" in the make-up panel and the New Train tip. It was always the
same rule — playerLeftOf is increasing seat index — but "left" describes a table nobody is looking
at, while the map runs west to east, so at a real three-player game the second car went to the player
sitting EAST and the text read as wrong. The history panel keeps 90 lines instead of 60, in the same
230px box: more to scroll back through, no more screen taken, and the newest line stays where the eye
already is.
Note for the packaging repo
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.
0.8.0.12 — 2026-09-16
A player who has lost their browser storage can be put back in their seat (Gitea#33). No rule
changed: git diff v0.8.0.11..v0.8.0.12 -- src/engine/ is empty, so games in progress resume.
The failure this fixes, and the four things it was not
Reported from the table after the 0.8.0.11 update: of two humans in one game, the host reloaded
straight back into it and the player who had JOINED found an empty lobby — no join secret, no display
name, no game code. Their seat was never lost. sessions.json for that game held both seats, and the
server logged it resuming with 80 intents replayed.
Four explanations were ruled out with evidence before any code was written, and two of them were theories of mine that had to be retracted:
- Not the update.
git diff v0.8.0.10..v0.8.0.11 -- src/web/contains no storage change at all; both tags declare identicalSECRET_KEYandNAME_KEY. - Not a create-vs-join asymmetry in the client.
lb-secretandlb-namesit above both doors inplay.html, so a joiner writes the same three keys a host does. - Not the server forgetting joiners. Create calls
persistSessionand so does join; it writes every session for the game. Two-seat session files plainly work. - Not a second origin. Both players used the identical URL.
What is left is the thing §1 has always said: the token lives in one browser's localStorage, scoped
to the origin. A cleared profile, a private window or a different browser ends the seat while the game
runs on without it. Nothing in the client can detect that — origin isolation is the point — and
nothing in it can repair it either.
A recovery link carries a code, never the token
lobby-and-sessions.md §1: "Keep it out of URLs so it is not shoulder-surfed or pasted into a
chat." A recovery link is precisely what gets pasted into a chat, so the URL carries a single-use
code that expires in 30 minutes and the page trades it for the real token over a POST, then strips
it from the address bar. A spent code is worth nothing; a token in a chat log is the seat for the rest
of the game.
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
POST /api/claim { code } → { token, gameId, player, gameCode }
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, unguessable and one-time, which is the same shape as the token it returns.
server/claims.ts is a pure store — no clock, no sockets, no disk — so its rules are actually tested
rather than asserted: single use, lazy expiry, and one identical answer for unknown, spent and expired
codes so it cannot be probed. The codes are held in memory on purpose. They are minted on demand and
spent within minutes with the administrator present, so a restart dropping them is the right failure;
persisting them would put a credential-equivalent on disk to solve a problem measured in seconds.
The administrator picks a seat, not a string
The admin game listing now reports seatedPlayers — the seats a HUMAN holds a token for, read from
the server's session map rather than guessed by matching "Bot 1" against a display name. That is what
lets the StartOS side offer real players to choose from instead of chairs no token was ever issued
for.
0.8.0.11 — 2026-09-16
Fourteen reports from the second multiplayer playtest of v0.8.0.10, the WHISTLE-6945 table. Eleven are fixed here; the review of the New Train phase turned into four changes of its own; and two were questions the engine had already answered.
A train's arrival says WHOSE Office it reached
The line went to every seat reading "Train 3 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 never said which district the train was standing in, and three of the four readers could not
touch it. trainArrived now carries owner, and the narration resolves the name — "ARRIVED at Tom's
Whistle Post … Tom can work it". The Mainline Phase has no actor, which is why nothing upstream could
name the seat for it. Saves replay history rather than storing events, so the new field strands nothing.
Being five behind now looks five behind everywhere
"When player Jesse is 5 behind, it should always look like he's 5 behind." The board, the district
panel and the caption row have followed the animation 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 elsewhere. The Day,
Stage, clock, phase and Fedora now come from the step on screen (shownTable), as does the district
panel's auto-open. Being behind is fine and the counter says so; being behind on part of the screen is
what made it unreadable.
Pause, beside Skip
Skip was the only control the catching-up row had, and it is one-way and total: the way to look harder at a move that had just gone past was to not be too slow about it. Pause is the opposite lever. The property that matters is not that it stops — any flag gives you that — but that a hold costs the step nothing: a move paused half-way through its dwell resumes with half a dwell left, rather than being thrown away the moment you let go. Skip lifts a hold rather than leaving a Resume button that does nothing.
A one-render look at another player's Office Area
One button per opponent in the district header. Deliberately not a mode: the look survives exactly the
render its own click causes, so the panel is back to following whoever is acting the next time anything
redraws. Jesse's call, and the right one — a sticky pin has to answer what happens when the game moves
on beneath it, and both honest answers are bad (snap home silently, or leave a player studying a stale
board while the game waits on them). Pause already means "hold everything", so that is the lever for a
long look and this stays a glance. Player names reach the page through textContent, never markup.
The Office Area summary counts the district on screen
The panel has drawn somebody else's board since v0.8.0 and the heading says whose — but the summary line read the viewer's own frame every time. So a bot's turn showed "Bot 2's Office Area" over Bot 2's cards, above a line counting your cards, facilities and trains.
LIMITS is printed beneath the card, not through it
"The text is split by the bottom border of the limits card." It was: the bottom row's 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 border ran through the middle of the word. Raising it would have put it on the
card, over the rails, so a 14px band is added below the bottom row and the baseline moved into it.
The Mainline region divider is visible
Drawn at #4a5361, 1.2px, on a #161b21 card — faint enough not to be made out at all. It is the ruler
the train is measured against and should stay quieter than the rail, but a ruler you cannot read is not
restraint.
Only Hilly talks about FAST/SLOW
Every other Mainline card ended its description with "the printed speed is scenery", which sent a player
hunting the card for a number that is not drawn on it. Exactly one card reads the rating — Hilly, the
only profile with speedStarts — and it already explains itself. Everywhere else now says nothing,
which is the honest answer when the rating does not apply.
A passenger Modifier on a Whistle Post says it is dormant
A Restaurant appeared to do nothing. It does nothing: a Whistle Post is not a Passenger Facility, so it
allows neither direction and has zero capacity each way; usableGrant discards the capacity while the
porter is granted regardless, leaving a porter with nothing to carry. The Freight Agent then refuses to
stock the box twice over, so no coach can ever be there to board. Playing it there stays legal on
Jesse's call — the card is not wasted, it starts working on upgrade — and the panel now says so. It had
been claiming the facility "only receives", which is wrong in both directions at a Whistle Post.
An automatic phase says what it is doing
"waiting on nobody — the Division is running itself" reached its answer by negation, on the one line whose job is to say where the game is. The phase's name is already printed directly above it, so these describe the work instead: "the Division is moving trains", "the Division is building this Stage's trains". Only a person is waited on.
The version appears once in the header
The no-git fallback for the cache-bust key led with the version, and the stamp already begins with
v${version} — so the header read "v0.8.0.10 · 0.8.0.10-mfq2p1 · …". It showed up on exactly the builds
that take that path, which is every .s9pk, because the Dockerfile copies the tree in without
.git. The uniqueness that fallback exists to provide comes from the timestamp, not the version, so the
two requirements never competed. Pinned in test/web.test.ts beside the constant-fallback assertion
that the same function already carries.
Save files name the game, the Stage and the day
Every server game downloaded as station-master-day1-stage5.json, so two saves off different tables
collided in the downloads folder and neither said which table it came from. The join code is what a
player already says out loud to identify a game, so it leads: whistle-6945.day1.stage5.2026.09.16.json.
A solitaire game has no code and falls back to its seed.
The New Train phase says what is left, and why your turn ended
Reviewed before changing anything, at Jesse's request — and the phase turned out to be better served than it looked from a grep: the Division Yard chip IS the button, the panel already names the train and what its card calls for, warns about assembly order on the trains where order can lock switching, and carries the "no more cars" button that exists to prevent a real softlock. Four things were missing.
Still needs. The heading says what the card CALLS FOR and goes on saying it unchanged as cars go
on, so the one question a player actually 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. consistNeeds
counts by the same categories acceptsCar does, so it can never ask for a car the engine would then
refuse, and it reads null when the consist is complete.
One car each. §7's round — a single car, then the next player, from the Superintendent working left — 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", and the turn ends with no explanation. It now says so where the clicking happens.
The train is marked on the map. TrainChip gained beingMadeUp, set from the engine's own
isBeingMadeUp, and the chip is drawn in the action amber — so the panel on the right and the train
on the Division strip at the top left are visibly one subject rather than two.
A clickable car looks clickable. The addable highlight was a thin blue outline, reported as "just
a small bold and basically the same color as everything else". It now wears #c8912f, the border
colour the action buttons themselves use. The loaded/empty text colours are deliberately left alone:
green and blue-grey are what say which of the two numbers is which, and that is a different question
from whether you may click.
Asked, and already answered by the engine
Does the bot know how to use Interlocking? It plays it deliberately — the bot ranks it above the
other enhancements precisely because it stops an Office collision — and a Running Track straight is the
card's printed placement, so the play seen at the table was correct. Nobody "uses" it: advance.ts
applies it automatically, turning an arrival at a full Office into a hold at the Limits. There is no
decision to get wrong, so no change was made.
Can passengers load or unload at a Whistle Post? No. Zero porters, zero outbound, zero inbound, and not a Passenger Facility — it takes a Depot to do any passenger work at all.
0.8.0.10 — 2026-09-15
Three reports from the first multiplayer playtest of v0.8.0.9 — one player and three bots — each traced to its cause before it was fixed.
The Superintendent is no longer asked about a train BEHIND the one departing (Gitea#26)
"Two westbound trains: X15 is further west on Mainline cards than X18. And I still get a Superintendent must rule... held X15 and then collision happened. X18 destroyed, but X15 fine."
Reproduced by replaying the exported save. At move 78, X15 was highballing west out of seat 3's
Office while X18, also westbound, was still crossing the card to its EAST — behind it. Every Office was a
Whistle Post, so the Subdivision ran the whole railroad, and evaluateClearance counted every train in it
without asking which side of the departing train it stood on. The ruling was meaningless; holding X15
kept the Whistle Post's only A/D track full, and X18 arrived into it and was destroyed. Unasked, X15 —
the lower number, so the first to move — would have left and freed the track.
§8.1 asks about a train the departing one would FOLLOW and one moving TOWARDS it, and both are ahead of
it. So a train on a card strictly behind the departing train's own card is no longer counted, in either
pass: a following train behind is no concern, and an oncoming one behind is moving away. A train on the
SAME card is still counted, exactly as before — which of two trains sharing a card is in front is
entryConflict's region question. The other two rulings in the save (moves 37 and 530) were correct and
are unchanged.
A test had the fault written into it: its "train ahead" of an eastbound train was the first Mainline card in the Division, which is west of the Office. It now stands on a card the train would follow, and three new cases pin the rule: a same-direction train behind is not put to the Superintendent, an opposite- direction train behind does not bar the departure, and a same-direction train ahead still is. The tally test's seed moved from 42 to 44, because the collision seed 42 was chosen for was this bug.
Games in progress — MANY WILL NOT RESUME
This release changes when the engine asks for a ruling, so a save holding a ruling it would no longer
ask for stops replaying at that move: mainline.clearance is refused with NO_PENDING_DECISION. The
server then refuses to resume that game and logs the move it stopped at, leaving the save untouched —
putting v0.8.0.9 back would resume it. Measured: 40 four-seat co-op games recorded by bots under
v0.8.0.9 and replayed under this release — 28 stop, usually 5–40% of the way in, most at a ruling on a
train behind (about ten rulings a game were being asked). The exported playtest game stops at move 78.
Jesse's call (2026-09-15): no games in progress need keeping, so the fix ships as it is, with no
per-game switch preserving the old rule.
Six more from the same playtest (Gitea#27–#32)
A Realignment says which card it converted (#27). "It stated Mainline card 3 converted to plains. It
should state that the mainline card 3 curves was converted to plains." The event carried only what the
card became, so the line could not name what it had been; mainlineModified now carries from as well —
events are derived by replaying a save and never stored, so widening one strands nothing — and it reads
"Realignment: Mainline card 3, Curves, converted to Plains".
And the map flashes it (#28). "Is it possible to flash the mainline card when it gets changed by
realignment?" The one play that changes the Division itself was invisible on the map of it.
changedDivisionCards compares the two public boards the animation queue already holds — the same way a
pile is lit — so the pulse lands with the step that shows the change rather than when the intent arrived,
and it is shown to the player who made it too, unlike a lit pile. prefers-reduced-motion turns it off.
The history stops running ahead of the board (#29). "Does history display immediately for all bot turns... is it possible to stall history so it stays in sync with the number behind?" A push carries its narration and its steps together, so every line of a bot's turn was in the panel before the board had drawn any of it. The queue now reports how many lines belong to steps not yet shown, and the panel holds back exactly those, revealing each as its step goes up. Skip still shows everything at once.
A ruling reads as the office's (#30). "If a player makes a move as a superintendent instead of as
themselves maybe it could say 'Superintendent Player Tom'." The three moves made by holding the office
rather than in turn — §8.1's clearance, §11's Yard Office offer, §Q's Red Flag prompt — are prefixed that
way. clearanceGiven carries no player at all (the office made it, whoever holds it), so the acting seat
is what names it.
And it names them once (#31). "It gives the player's name and then their player number together."
record() prefixes the acting player's NAME for every event carrying player, and five narration lines
embedded Player <index> themselves — phase ended, actor changed, the Red Flag ruling, the extension vote
and the Yard Office ruling. The name is the log's job; the sentence is the narration's. A test now scans a
played game's whole log for a bare player index.
A multiplayer game can be saved as a file (#32). "In the StartOS actions for the save game, I get a
string I can copy. Most of the time, I want to just save it as a JSON file." Not possible in the action
itself: an action result member is text only — copyable, QR or masked, with nested groups — and the SDK
has no file member. So it is done where it can be: the play page's "Save replay" button was shown only in
solitaire, because a server-backed session has no local save to hand it. GET /api/save?token=… — the
same seat token /api/stream and /api/intent use, not the administrative secret — hands a seated player
their own game, and the button now appears in multiplayer.
"Waiting on" follows the move on screen (Gitea#25)
"Playing against 3 bots — waiting on always says me, even when it is someone else's turn."
The server plays every bot move the moment a human's turn ends, so the LIVE game is almost always waiting
on the human — while the screen is still replaying those bots step by step. The turn chart and the
Division map's move marker read the live actor, and contradicted the playback row naming the bot actually
moving. actorOnScreen (web/step-queue.ts) answers with the player of the step on screen while the
board is catching up, and the live actor once it has; during playback the chart also leaves off a live
"asks … ruling" note that belongs to a position the screen has not reached. Five tests pin it.
The name on the Division map is readable again (Gitea#24)
"When it shows your player, the font is unreadable... the bold font makes it look fuzzy, and the letters blur together."
A name takes the class bs-turn while it is that player's move — and .bs-turn is also the Division
map's turn ARROW, which strokes its shape 2.4px grey with no fill. The name's own rule changed only the
fill, so every letter was outlined in grey. It showed for the viewing player almost constantly because of
Gitea#25: the map believed it was nearly always their move. .bs-name now sets stroke:none.
0.8.0.9 — 2026-09-15
The developer bot, re-measured decision by decision — and an engine 2.8× faster. Across the changes adopted below, each paired against the bot before it, the bot went from about −0.3 revenue a game to about 4.8: the switching planner +2.89, taking a face-up card only if it could be played +1.52, the deliberate New Train fallback +0.32, and laying track by what the district can do afterwards +0.12.
Games in progress
Resume. No rule changed, and no legality check changes its answer. The engine's speed-ups were proven
to leave play identical — every event and intent of 32 seeded games hashed before and after each step —
and test/route-cache.test.ts pins the new check/commit split. Only the developer bot plays differently,
and a bot's past moves are already in the save.
The bot plans its switching turn instead of choosing one Move at a time
The switching branch was a ladder of rules picking ONE Move, and its own comment named the gap: "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." sim/switch-planner.ts attempts it, for one turn.
Why search is fair. A switching turn draws no card and rolls no die, so trying sequences on a copy of the game is what a player does by looking at the board. The score reads only what a player can see — the district, the cars on the trains, the facilities — and never the deck. Jesse's line (2026-09-14): no non-player advantage for the bot.
Why not every sequence. Measured 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 costs no Move and a crew can leave them in a great many places. So the search keeps the best 48 positions at each step and stops at 3,000 tried; small turns are searched completely inside that.
The score is of where the turn ends, starting from Jesse's ruling that players deliver and pick up
cars even when it delays trains. A car an industry can work is +1 (+0.25 past its box count); a car it
cannot is −0.5 on its track; a finished car left on it −0.15; a wanted car aboard +0.35 or staged on
plain track +0.2; a coach kept with its train +0.3, stranded −0.3; anything fouling the Office −3; a
train away from the Office −0.25, −1 more if expedited; a train §8.2 would refuse (badlyMadeUp, now
exported rather than copied) −0.6. A load made in this district scores nothing at a receiver here.
The copy is partial. forkForSwitching copies only the arrays a switching intent writes — cars
standing on cards, industry tracks, the district's A/D and held lists, the trays' consists, this turn
and the tally — and shares everything else. It began as a structuredClone of the district, which a
profile put at 44% of all planning time; the targeted copy made a position about three times
cheaper to try. test/switch-planner.test.ts
proves across seeded games that planning leaves the real game byte-identical, and that every plan
replays through applyIntent on a FULL copy to the exact position it promised.
Measured, 1600 paired seeds: +2.89 ± 0.18 revenue a game (t = 15.79), 733 seeds better, 21 worse.
| rules | planner | |
|---|---|---|
| revenue | −0.05 | 2.83 |
| freight loads + unloads | 0.22 | 1.53 |
| collisions | 0.12 | 0.12 |
| Cargo phases with a car spotted | 5% | 18% |
| … with green box, car and Laborer at one industry | 3% | 11% |
Split by source over 400 seeds, because the biggest wins were rescues: freight +1.21 (t = 13.9), passengers +0.26, expedite faults +1.07 (19 of 400 games faulted under the rules, none under the planner — TODO #53, closed by this), collisions unchanged. Without the fault rescue it is still +1.45 ± 0.12 (t = 12.2) — the switching itself got better, not just the catastrophes rarer. It also sets out half as many cars (3.6 against 7.0): the ladder was setting cars down and picking them up.
The worst seeds, traced. Two of the three lost to "no free A/D track" collisions with both tracks
already held by trains standing at the Office — not trains left away, which is what the score's
trainAway weight would have explained. The third never had a coach train at the Office in a
Load/Unload phase at all: a different switching turn draws different cards, and the game diverges.
Neither is a scoring defect found; the full-Office collisions are worth watching.
How far to search. The search size was measured rather than guessed, paired over 400 seeds against the 3000-position, beam-48 search the gain above was measured with:
| budget / beam | revenue against 3000/48 | median / worst per turn |
|---|---|---|
| 3000 / 48 | — | 150 ms / 445 ms |
| 2000 / 32 (adopted) | −0.02 ± 0.01 (t = −1.68), inside the noise | 69 ms / 222 ms |
| 1000 / 24 | −0.06 ± 0.02 (t = −2.65) | 53 ms / 104 ms |
Times are from 51 switching turns with other simulations sharing the CPU, so read them as relative.
A switching-only legal list. legal.ts now exports legalSwitchingActions, the switching half of
the Local Operations candidates run through the same check, in the same order — so the planner stops
paying for every draw and Freight Agent candidate at each position it tries. A test asserts it equals
legalActions' switching subset across real games. It contains no rule; check still decides.
Nothing the simulation tests measure moved backwards. sim.test.ts passes all 35 tests with the
planner as the default, floors unchanged.
Cost. A turn is planned once and then played a step per decision, replanned if the position is
ever not the one expected. At a live table that is a synchronous pause inside driveBots, well inside
Jesse's bar of half a second before a switch.
The price is test time. A standard solitaire game takes ~400 ms with the planner against ~108 ms
without, and npm test — which plays well over a thousand bot games in sim.test.ts — went from about
150 s to 8 min 6 s (992 of 992 passing, measured with nothing else running). What is left of a
planned turn's time is the engine itself: applying a move is about half of it and check a third.
And the take rule tripled it again. With both defaults in, npm test passes 992 of 992 in
21 min 21 s with nothing else running — the bigger districts the take rule builds (14 → 24 cards,
29 cards played a game) make every game longer to play and every switching turn wider to search.
noPlanSwitching=1 is the ablation.
Track goes where it lets the district do something
Jesse's third area, after switching and industries: track for the run-around. With the draw no longer
wasted, ~13 of ~16 pieces a game were still being laid by the draw turn's fallback at the first legal
square, and both fixes to that fallback failed (holding −0.83, bestTrackLay's own score +0.07). So the
limit was the scoring: bestTrackLay scores the PIECE, and cannot tell one that opens an industry site
or closes a run-around from one that fills a square.
bestValuedLay scores the LAYOUT the piece would leave instead. Each legal lay is placed on a copy of
the district exactly as the reducer places it — protoCard and extendLimitsIfNeeded, now exported
rather than copied — and worth the change it makes to layoutValue: industry sites a crew can reach
(canPlaceAt), a closed run-around (the bot's own descendFrom walk), ways off the main and reachable
siding, a Running Track straight for Interlocking, and a penalty for a turnout on the main whose leg
joins nothing. bestTrackLay's slot takes the best lay that gains something; the fallback still lays as
often as before — holding starved the district — but the best lay rather than the first.
+0.118 ± 0.029 revenue a game (t = 4.14) over 6400 paired seeds, 1,667 better against 1,430 worse.
It needed that many: 400 seeds read +0.17 (t = 1.48) and 1600 read +0.14 (t = 2.58). What it builds is
the larger change — closed run-arounds in 22 of 60 districts against 9, industries 1.78 → 1.87, freight
2.54 → 2.65, collisions unchanged, the district 24.2 → 21.9 cards because squares stop being filled with
pieces that build nothing. No slower: a standard game measured faster, the smaller districts leaving
less to search. noValueLays=1 is the ablation.
Why so many run-arounds buy so little. Traced over 40 games: the planner uses the loop — a move ends on it in 63 of 131 plans where one exists, 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 one-turn planner cannot value it. That is TODO #105.
How 6400 seeds were measured. compare.ts keeps every game's full statistics for both sides, and the
run was stopped for low memory — as was a first lean attempt, by the machine's background-task guard
rather than by the process: a probe showed no leak (heap flat at 9 MB after GC across 300 paired games).
The figure above comes from a paired script with the same seeds and configuration that keeps only each
seed's revenue difference, run in resumable foreground chunks; its first 1600 seeds read +0.143, matching
compare.ts's +0.14.
The engine walks each position's routes once — 2.8× faster, every game identical
Jesse's call (2026-09-15): speed up the engine's move checking and applying first, because the live
server runs the same legalActions and applyIntent for every bot and every player. A CPU profile
with inlining off put the route walk (reachableDestinations) at a third of all time and garbage
collection at another third, and most of the walking was repeated work.
- A route cache scoped to one unchanged position (
withRouteCache,apply.ts).legal.tswalked a tray's routes to list its moves,checkwalked them again for every one of those moves, andexecutewalked the chosen move a third time. Now a legal-action listing, and the check-and-execute of one intent, walk each route once. The cache is keyed to the state object and the walk's inputs and dropped beforereducechanges anything, so a hit is exactly what a fresh walk returns. applyIntent=prepareIntent+commitEvents. The planner decides every candidate against one position inside one cache and commits each to its own copy; deciding on the copy had re-walked every route the listing had just walked.- Less garbage in the walk itself (
exploreMoves): the blocked-square explanations are not built when only destinations are wanted, the queue is read by index instead ofshift(), and "has this route been here" reads the route's own path instead of copying a Set at every step.
A standard solitaire bot game, 561 ms → 203 ms; a short one, 141 ms → 65 ms. Proved identical by
hashing every event and intent of 32 seeded games (20 solo standard, 6 three-player standard, 6
two-player short) before and after each change, and pinned by test/route-cache.test.ts, which checks
at every decision of a seeded game that prepareIntent writes nothing and that committing its events
to a copy equals applyIntent in place.
The test suite is split
Jesse (2026-09-15): npm test runs after every change, so it has to stay fast; reducing the simulations'
game counts is not acceptable. npm test now runs every file except test/sim.test.ts, and
npm run test:sim runs the bot simulations on their own, typechecked first.
Measured with nothing else running, after the engine speed-up: npm test 958 of 958 in 1 min 45 s;
npm run test:sim 35 of 35 in 6 min 58 s — against 21 min 21 s for the two together before it.
Where industries go was not the problem — there was nowhere, and the draw was going in circles
The next thing Jesse named after switching was where industries and enhancements go, then track for the run-around. Measured before touching either, 30 standard games, and it turned both round:
- The bot places an industry every time it legally can. It held an industry card at 1,441 draw decisions and a legal square existed at 24 of them (2%); it played the industry at all 24 and never discarded one that had somewhere to go. 0.70 industries stand on a board at game end.
- Why there was nowhere. An industry is a plain east-west piece that must join existing track and
may not sit on the Running Track. Asked of
checksquare by square, the closest a held card got was NOT_CONNECTED 1,681 times, OUTSIDE_LIMITS 398, legal 28 — never locked out. Printed districts show why: the row beside the main fills with 45° curves and turnouts whose east-west ends face occupied squares. - And track was not the lever either — its supply was. At 1,334 decisions holding an industry with no site, a track lay was legal at only 54; a lay that would open a site existed at 22 and the bot already chose one at 17. The hand had no track in it.
- Because the draw was cycling. The bot took 20.1 timetabled trains and 11.9 industries a game off
the Departments and discarded 20.2 and 11.8:
takingRankranked a face-up train 2 whether or not the A/D cap would let it be played, and a face-up industry 1 whether or not it had a site (one did at 0.17 of those takes). The same card was taken again 28.8 times a game, against 19.6 blind draws.
The bot takes a face-up card only if it could play it
takingRank now ranks a face-up train card 0 when the A/D cap would hold it, and a face-up industry 0
when it is locked out or has no legal site — both asked of the rules the play itself is checked by
(trainWouldOverfillTheOffice, and isLockedOut with canPlaceAt in industrySiteExists). Nothing
here is hidden information: the Departments are face up.
+1.52 ± 0.10 revenue a game (t = 15.59) over 1600 paired seeds, measured as the ablation
noPlayableTakes=1 against the new default: 880 seeds worse without it, 211 better. (The first
400-seed read was +1.63 ± 0.19.) Cards played 15.8 → 29.0 a game and the district grows 14.2 → 24.2
cards, because the draws it stopped wasting now bring in track: trains scheduled 1.74 → 2.30, freight
1.51 → 2.54, passengers 1.89 → 2.94. Collisions rose, 0.12 → 0.24, with the extra trains — see
TODO #106.
Rejected: holding track the run-around scoring declined
Traced first: of ~16.4 track pieces a standard game lays, only ~3.3 came from bestTrackLay. The
draw turn's "play what is in hand" fallback laid the other ~13 at the FIRST legal square — pieces
bestTrackLay had just declined, including 4.0 turnouts and 4.3 straights a game on the main and 1.5
turnouts a row off it. The candidate let the fallback lay track only when nothing else could bring the
hand under the limit. −0.83 ± 0.16 (t = −5.17), 172 worse against 90 better: the district fell
24.1 → 11.7 cards and freight 2.41 → 1.77. bestTrackLay declines most pieces, so holding them
starves the district — #59's finding, again, with a fuller hand to hold them in.
Rejected: laying fallback track where it scores best instead of first
The other half of the same trace: keep laying the ~13 fallback pieces a game, but at bestTrackLay's
best-scoring square (bonus or not) rather than the first legal one. +0.07 ± 0.13 (t = 0.60), inside
the noise; freight 2.41 → 2.56 but the district 24.1 → 21.0 cards. With both halves measured, the
limit is bestTrackLay's SCORING — it cannot tell a piece that opens an industry site or advances a
run-around from one that fills a square — not which branch places the piece. Code removed.
The bot stops running Second Sections by accident
TODO #106 traced under today's defaults: of 20 "no free A/D track" collisions in 60 standard games, no
train had been held before any of them and only 8 of the destroyed were Extras. Six destroyed a train of
the same number as a Second Section run within two Stages, and all 26 Second Sections the bot ran were
an accident: when no car was on offer, the New Train phase fell back to options[0], and legalActions
lists newTrain.secondSection ahead of the Extra starts — so a waiting Extra became a doubled train due
out, into an Office that never had an A/D track to spare. The fallback now takes a car, a pass or the
Extra's start, and never a Second Section or a Red Flag merely because it was listed first.
+0.32 ± 0.09 revenue a game (t = 3.64) over 400 paired seeds, 30 better against 12 worse; collisions
0.24 → 0.19. noDeliberateNewTrain=1 is the ablation.
Found on the way, for Jesse — a rules question, not changed. Q9 defines the Second Section as a card
played on a train due out and content.ts gives SECOND_SECTION one copy, but buildDeck never deals it
and check asks for no card, so any player can run one for free on every train due out.
Rejected: planning two switching turns ahead (TODO #105)
Built as planTwoTurns: keep the six best ends of a switching turn, remove the trains that highball in
the Mainline Phase in between (on the Office square and made up — their cars leave with them), reset the
Moves, plan the next turn from each, and choose by the position after departures plus 0.8 of what the
next turn adds. −0.28 ± 0.11 (t = −2.58); corrected to charge the expedite fault the gap cannot undo
and a Stage for every train left away, −0.14 ± 0.06 (t = −2.36) — 400 paired seeds each. A second turn
has little to find: only 49% of switching turns keep their train for the next, a further turn could have
spotted just 0.7 of the 6.1 wanted cars a game that leave aboard departing trains, and trains left away
cost Stages walking crews home. Dropped at Jesse's call; the measurements are in TODO #105.
Rejected: starting an Extra the Office cannot take where its run never arrives
TODO #106, re-measured under today's defaults: 20 collisions in 60 standard games (−1.67 revenue a
game), every one "no free A/D track", and 26 Extras forced out by a full hand of them. The bot also took
the first legal start for EVERY Extra — the western Division Point, 158 of 158 — so the candidate chose,
when the committed trains exceeded the A/D tracks, a start whose run never reaches the Office: an Extra
runs away from its start (resolveExtraStart), and at the Interchange one way may miss the Office.
+0.10 ± 0.05 (t = 1.82), 395 of 400 seeds identical: such a start was on offer at 1 of 25 over-cap
starts — most Divisions have no Interchange, and a Division Point always runs through the Office. Code
removed.
Rejected: refusing to draw into a forced Extra
With the take rule in, the bot's worst seeds all lost to "no free A/D track". Traced: every one of 23
train plays past the A/D cap in 40 games was an EXTRA, played because the hand held four of them and
the only legal action was card.play — §6.2 makes a player over the limit reduce the hand, and an Extra
may never be discarded (keepReason), so the cap in choose yields rather than leave nothing legal.
The candidate declined to choose the draw option with a full hand of such cards when switching or the
Freight Agent was on offer. −0.03 ± 0.11 (t = −0.30), inside the noise: collisions fell
0.26 → 0.20, and development fell with them (cards played 29.0 → 25.6) — the Stage spent avoiding the
draw was a Stage not spent building. Code removed; the trap itself is real and filed.
Rejected: discarding the card least likely to become playable
When a discard was forced, the candidate picked WHICH card by a keep-value (next Office tier, then
Enhancements, track, trains, and industries with no site or Modifiers with no industry last), weighted
so no pile preference could overturn it. −1.16 ± 0.12 (t = −9.27), 16 better against 119 worse,
cards played 15.7 → 10.4. Not traced; the likeliest reason is that the weight overrode bestDiscard's
pile choice, which exists to avoid burying a face-up card the bot wants. Code removed.
Flying Switch is searched, and cannot be measured
The planner now considers Flying Switch alongside Moves, set-outs and sorts — its partial copy of the
game carries the hand and the Salvage Yard, which spendCard writes, and spending the card costs a
tenth of a point so it is played only when it buys something. Over 400 paired seeds it changed
nothing, because the card is dealt 0 copies (not in sheet 5; Jesse, 2026-08-26): across 30 standard
games no Flying Switch was ever drawn. TODO #58's "never fires" is therefore a deck fact, not a bot
one. On by default, so the planner uses the card the day it is dealt again.
Rejected: letting the planned gain decide whether to switch at all
Whether a Stage goes to switching is decided by usefulSwitching, a yes/no reading of the district.
The candidate replaced it with the planner's own answer — plan the turn on a fork before the option is
chosen, and switch only if it gains at least a threshold. Paired over 400 seeds against the planner:
| threshold | revenue | seeds better / worse / identical |
|---|---|---|
| 0.5 | −0.33 ± 0.06 (t = −5.06) | 15 / 70 / 315 |
| 0.25 | −0.01 ± 0.05 (t = −0.21) | 21 / 22 / 357 |
| 0.1 | +0.06 ± 0.03 (t = 1.79) | 20 / 13 / 367 |
At 0.5 it refuses turns that only COLLECT — a wanted car picked up scores +0.35 — and those pickups feed
the deliveries after them. Below that it agrees with usefulSwitching almost everywhere, because that
rule already says yes exactly when there is a car to deliver or lift, which is when a plan gains. The
decision that would matter is switching against DRAWING or the Freight Agent, and the bot has no value
for either to compare with; that is the next piece of work, not a threshold. It also cost a full
search at nearly every Local Operations decision. Code removed.
Rejected: discounting a car its industry cannot work yet
A spotted car scored half when its shipper had no load staged or its receiver's red box was full.
−0.29 ± 0.05 (t = −5.52) over 400 seeds, 47 worse against 4 better; green boxes stocked fell
15% → 11% of Cargo phases. The reason is the order the bot works in: the Freight Agent stocks a box
only once a car is spotted to receive it (canStockProductively), so an industry is "not ready"
precisely because nothing has been delivered — the discount withheld the delivery that makes it ready.
Code removed.
0.8.0.8 — 2026-09-10
A played train does not come back. Gitea#23, ruled and closed.
Jesse, on the question v0.8.0.7 filed rather than answered: "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."
The test is WHERE the card is, not only what it is — which is the part worth writing down, because it is exactly the rule a later tidy-up would "simplify" into filtering by card kind everywhere. The same train card is spent in the Salvage Yard and still runnable in a Department:
| card | where | on a reshuffle |
|---|---|---|
| timetabled train | Salvage Yard — it was played, its number is on the timetable | stays out |
| timetabled train | a Department — discarded, never played, slot still open | comes back |
| Extra | anywhere | comes back; an Extra is one run, not a standing slot |
| everything else | anywhere | comes back, as before |
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 — measured: four scheduled trains left eight entries in a
pile holding four cards. Nothing read that id. It inflated the pile's depth, displayed as "a card"
because no such card exists, and would have been swept into the draw deck to be drawn as an id with
nothing behind it. Removed, which retires the whole phantom-id class rather than papering over it —
so v0.8.0.7's cardName() resolver for train-<n> is gone too, along with its test. Dead code kept
for an id that can no longer exist is worse than no code.
Games in progress
Resume. No predicate changed its answer — nothing that was legal became illegal, and a draw is a draw whatever is on top of the deck. What differs is the Salvage Yard's depth, which was double-counting, and what a reshuffle would recover. Reshuffles are effectively unreachable in ordinary play: eight games driven to 4000 moves across eight seeds produced zero.
Closes #23.
0.8.0.7 — 2026-09-10
The Salvage Yard was face up and had nothing to say
"Why is salvage deck not face up? I should see the card played onto salvage." It always was — the tile reads the top card. It just said "a card".
apply.ts pushes a synthetic id on trainScheduled:
s.decks.salvageYard.push(`train-${e.trainNumber}`);
Nothing in s.cards matches that, so cardName() fell through to its "a card" default — and since a
train is scheduled several times a Day, that id is on top of the pile most of the time. Measured
before touching anything: a session's Salvage tile read "a card" from the opening frame through 60
pushes, never once changing, while its depth climbed from 2 to 8.
cardName() resolves train-<n> now, so the tile reads Train 3, Train 2 as cards land.
Resolved in sim/view.ts because this is a NAME, which is that file's job.
The engine half is filed, not fixed — Gitea#23. reshuffleIfDepleted() sweeps the Salvage Yard
back into the draw deck, so a synthetic id can be shuffled in and drawn into a hand as an id with no
card behind it. Not reachable in ordinary play: eight games driven to 4000 moves across eight seeds
produced zero reshuffles. There are two defensible fixes and the choice turns on what that
synthetic id is for, which is not a call to make in passing while fixing a label.
Phases scale with the speed control again — at a third of the rate
"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?"
Two complaints from opposite directions, and the answer is between them. v0.8.0.3 pinned phases at their tabled beat because scaling them walled off a player's own turn — "that makes no sense". At 10× that pinned beat is too short to read the sentence on it.
So they scale, damped to a third of the rate: 1× unchanged, 10× lands exactly on the four-times guess, 20× gives 4400ms. The cost stays bounded 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 is ~2.4s typical and ~10s at its very worst, against the minutes a full multiplier would have cost. A player's own move still outlasts a phase beat at every speed, which is the ordering that matters.
0.8.0.6 — 2026-09-10
Playing v0.8.0.5: "saw bot's office area now — much better." Three things still wrong, and one of them was mine hiding inside the fix for another.
Your move is put away while the board is catching up
"Your actions should be hidden while catching up." Two reasons, and the second is the one that changed my mind about a decision taken early in v0.8.0 ("never block input"). The board on screen is behind the game, so a move offered there 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 screen had grown to four things competing at once: the district, the history, the catching-up row, and now a lit pile. 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 at the left of the row, so the wait stays voluntary. The buttons are replaced by the reason they are gone.
…which 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. The action list would have been hidden permanently, with Skip the only way to play.
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. Two fallbacks now: no rAF means draw
everything at once (exactly what pace = 0 does deliberately), and a queue that throws empties
itself rather than stranding the player. test/step-queue.test.ts pins that a never-advanced queue
is still skippable.
The lit pile was never brief — it was too quiet
"Never saw decks lighting up… 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."
Measured before changing anything: at 10× a pile stays lit for 6997ms, just under seven seconds. So the highlight was not brief at all. It was a single 0.45s flash-in over a dark green fill, easy to miss entirely while looking at the district — a state that settles stops asking to be looked at. It pulses now for as long as the move is up, with a ring and a glow. The reduced-motion fallback is loud in a different way rather than simply still, since motion is the whole point here.
The ceiling was not theoretical
"At 10× — still a bit fast, but followable." 10× was the top of the ladder, so the control's
slowest setting was not slow enough for the person using it. PACE_LEVELS now runs to 20 and
MAX_PACE with it. A control whose limit is reached in ordinary use has the wrong limit, not the
right one held firmly.
0.8.0.5 — 2026-09-10
Somewhere to look. Jesse, playing v0.8.0.4 at 10×: "many operations still occurred too fast for me to see." At 10× an action holds the screen for seven seconds, so this was never about duration — it was that a bot drawing a card changes one number in a panel nobody is watching, and the board sits unchanged for those seven seconds. Raising the dwell was the wrong lever, and it had been pulled three times. His diagnosis was the right one: mark WHERE, not longer.
The Home Office deck was never drawn
f.deck has carried the face-down count since the Frame existed and nothing in src/web/ read
it — the exact display gap test/display-gaps.test.ts was written to sweep for, surviving in the
one panel that draws every other pile. It is a tile now, first in the row, because that is the order
a card travels: out of the deck, into a hand, then onto a Department or the Salvage Yard. Face down,
so its card slot says so rather than naming one — not knowing what is on top is the point of the
pile.
What lights, and why that is exactly what is public
The piles a move touched are now lit for as long as that move is on screen. Derived, never sent:
the client already holds the frame before a step and the frame after it, so changedPiles() is a
diff. Nothing is added to the protocol, nothing can drift out of step with the projection, and the
0.8.1 seatless board gets it for free.
Measured across four seeds rather than reasoned about, and pinned by a test that requires each case to have actually occurred rather than passing on whichever the bot happened to play:
| action | lights | why that is public |
|---|---|---|
draw.fromHomeOffice |
the deck | the count, never the card — a blind draw stays the drawer's |
draw.fromDepartment |
that Department, and the deck when it refills | the pile is face up, so the card taken is public |
card.discard |
that Department | face up, and which pile it went on is the point |
card.play |
the Salvage Yard | where a played card that did not stay on the board lands |
| switching, new trains | nothing here | they move the board, which the district panel already follows |
*.end, localOps.choose |
nothing | no card moved |
It is a state, not a flash, and that distinction is the whole reason it works. The timetable's
existing .tt-slot.fresh animates for a fixed 1.5s — right for a die roll nobody is waiting on, and
wrong here, because a step can hold for seven seconds and the animation would be long over before
the pause it belongs to. A brief flash-in marks the moment; the lit border and background stay for
exactly as long as the step is up.
Not for your own moves. You drew that card — the same rule that already gives your own steps no dwell.
0.8.0.4 — 2026-09-09
Housekeeping: the test server's name is out of the ten places this session put it.
Both of this project's repositories allow anonymous clone — checked, not assumed: info/refs for
git-upload-pack answers 200 for station-master and for station-master-startos alike, while
git-receive-pack answers 401. So everything committed here is public, and the standing rule is that
tracked files carry placeholders rather than real hosts.
Ten mentions added while building v0.8.0 are now "the test server" or "the target hardware":
CHANGELOG.md, docs/plans/jitsi-common-board.md (three identical deferral banners), sim/pacing.ts,
test/pacing.test.ts and test/step-queue.test.ts. Prose and comments only — no behaviour, and the
quotes they carry are unchanged, because what a player said about bot pacing is the part worth
keeping.
What is deliberately left, and why it is not an oversight:
- Nineteen older mentions, in entries about v0.7.5, v0.7.6 and v0.7.8 and in
TODO.md. Rewriting a changelog after the fact makes the record less true, and these describe verification that genuinely happened on that machine. scripts/deploy-web.tsis FUNCTIONAL, not prose. It carries the host as the default forFB_URL, so a placeholder there would break the deploy for the person the default exists to serve. Same for the public address it publishes to. If those should move to required environment variables with no default, that is a change to how deploying works and wants deciding on its own rather than being smuggled in beside a comment sweep.
0.8.0.3 — 2026-09-09
Three things from playing v0.8.0.2, all of them about the row rather than the mechanism.
Skip was at the wrong end of the row
Jesse: "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." It was on the far right, and a player's eye is on the countdown. Moved.
The caption said what, but never who
"I saw 2 behind, 1 behind, and then it was caught up, but 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. 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 now introduces itself: "The Division: ▸ Mainline phase". A player's move already carries
its name from record(), so it is left alone rather than stuttering it twice.
And the row was hiding a step early. It was shown only while behind > 0 — which goes false 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. It now stays up while the queue is still showing something, and reads "catching up"
once nothing is queued behind.
The speed control was stretching the clock, not just the other players
"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. Since I've just done my turn, I don't need to wait after it."
He was right, and it was not his move being replayed — own moves have cost nothing since v0.8.0.1. It
was the automatic phases behind it, which were scaling with pace along with everything else. At 5×
that put 105 seconds of clock-ticking into the game, all of it after a player's own move and none
of it anything to watch.
pace now scales a player's move and leaves a phase at its tabled beat. The control is labelled
as how long another player's move is held, and that is now what it does. A phase still gets its beat
(TODO #18) and still vanishes entirely at pace = 0, because off has to mean off.
0.8.0.2 — 2026-09-09
Two things found by playing v0.8.0.1 on the test server, neither of them in the mechanism itself.
?pace= never worked, and a whole game was played at the wrong speed
Jesse: "I'm playing at pace = 7, and the bots are still moving too fast for me to follow." At 7× a switching move holds for seven seconds, so that could not be calibration — and it was not. He was at 1× the entire time.
index.html's two doors are ./play.html?lobby and ./play.html?solitaire. Arriving through the
splash therefore replaces the query string, and location.search on the play page is ?lobby —
so PACE_OVERRIDE was null and it fell back to the stored setting of 1. v0.8.0 shipped ?pace= as
the only way to change speed and the game's own front door destroyed it. Verified rather than
assumed: the queue at pace 7 holds a bot's turn for 32.9s with the bot's district up for 24.5s, so
the mechanism was right and the value never arrived.
Fixed twice over, because one of them is the durable answer:
- A speed control on the play screen, beside zoom —
− 1× +, persisted per viewer, reading through to the queue on the very next move.PACE_LEVELSis0, 0.5, 1, 2, 3, 5, 7, 10: off is the first rung (TODO #18's "a player who has seen it a hundred times will want it off") and the ladder reaches the speeds people actually reach for. At the top, a six-move switching turn takes a full minute to watch. - The doors now carry
pacethrough, so the URL lever is honest for handing two playtesters different speeds — the only thing it was ever for. When one is present the control says7× (URL)and disables itself rather than showing buttons that do nothing.
PACE_LEVELS lives in sim/pacing.ts with DWELL and MAX_PACE, not in main.ts — the whole
tuning surface in one file, and testable, which a constant inside the page entry point is not.
The committed default is unchanged at 1×. What it should be is a question for a game played at a speed that actually took effect.
"0 today, 2 in all" — the Day-end dialog contradicted itself Jesse, at the end of a Day 1 with
two collisions in it: "It shows a total of two collisions, but zero today. Since we just finished day one, that does seem to be a contradiction." Unrelated to v0.8.0 — this has been wrong since the dialog was built for Gitea#10, and nobody had played a Day with a collision in it and then read the summary.
One line of ordering
advance.ts, at the rollover:
s.clock.day += 1;
s.collisionsToday = 0;
And noteDayEnd() fires when f.day goes UP — so the dialog reporting the Day that just finished is
drawn from the very frame in which that Day's count was zeroed. It printed the new Day's zero beside
a running total that could not possibly agree with it. Reproduced on four of five seeds before
touching anything: Day 1 ended with today=3 total=3, and the dialog read today=0 total=3.
Not derivable on the client, which is why the fix is in the engine. A Day turns over inside the
phases that run themselves, so in multiplayer the push announcing the new Day is the same push that
carries the reset — a client may never see the ended Day's final count to remember it. So
collisionsPrevDay is captured in state at the rollover, immediately before the reset, and rides on
the frame like the other two counts.
And "today" was the wrong word anyway
Even with the right number, a dialog headed "Day 1 has ended" should not say "today" — by then "today" is Day 2. It now names the Day: "Collisions: 2 on Day 1, 2 in all." The end-of-game results screen passes no Day and keeps "today", where the Day has not turned over and the word is accurate.
test/redaction.test.ts's allow-list did its job on the way through: adding a public property failed
the suite until it was declared out loud.
0.8.0.1 — 2026-09-09
Bot play was way too fast. v0.8.0 was installed on the test server and played within the hour; Jesse: "I briefly saw that it was the bot's office area then their turn was done and it pointed back to my office area", and the countdown row appeared "very briefly". Everything else looked right — the bots were visibly doing things — so this is calibration and one real bug, not a redesign.
The bug: the last step of a burst never got its moment
busy() was pending.length > 0. So the instant the FINAL step of a burst was shown, the queue
reported itself idle — the animation loop stopped and, because the district panel follows busy(),
it snapped back to the viewer's own board without that step ever being looked at. The countdown row
went with it. busy() is now pending.length > 0 || dueAt !== null: there is more to come, or
what is on screen has not had its moment yet.
The calibration: 250ms was invented, and it was wrong
Jesse's instruction had been "start at 1s and tune down". That was applied to switching and then a
250ms action tier was made up beside it, which held for the case the design was measured against —
a switching burst — and failed the common one. Switching is not legal until there is track down,
so an early-game bot turn contains none of it. Measured from a real 3-seat game, one bot turn was:
localOps.choose 0ms · draw.fromHomeOffice 250ms · card.play 250ms
draw.end 0ms · localOps.choose 0ms · freightAgent.stockOutbound 250ms
750ms for a whole turn. action is now 700ms, which puts that same turn at 4.7s.
And localOps.choose was the worst of it. It was classed as bookkeeping, at zero — but it is the
line reading "Player Bot 1 chose to SWITCH — six Moves to shunt cars around the yard": the heading
for everything that follows. A bot's turn began with no indication of what it was about to do. It is
an announcement, and it is now in action.
The viewer's own moves cost nothing
Raising action exposed a waste: your own click was being held for 700ms before the bots' turn
started animating. A seated player's own board is drawn from their authoritative Frame, never from
the queue, so replaying their own move shows them nothing and delays the thing they wanted to watch.
Own steps are still applied — the delta chain runs through them — but at zero dwell. Automatic phases
have no player and are unaffected, which is what keeps #18 working in solitaire, where every intent
is the viewer's own.
Faster and slower, without a rebuild
pace multipliers above 1 are supported and expected — Jesse asked for 2 and 3 — bounded by a new
MAX_PACE of 10 so that ?pace=300 from somebody meaning 3.00 cannot look like a frozen board.
Every tier scales by the same factor, so a switching move outlasts an ordinary action at 0.5× and
at 3× alike: the relative weighting is the design, and the multiplier is only how fast it runs.
Whole-game animation is now ~5.7 minutes across a 6-day game.
0.8.0 — 2026-09-09
Watching the table. TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated player's own screen. Jesse, 2026-08-29: "It's not fun to do my turn and have magic happen in the background and then have to figure out what others did." And 2026-09-09, on what he most wants to see: "I definitely want to watch other players struggle with the switching exercises … I don't think reading the switching in the log will be anywhere nearly as interesting as watching the trains actually move on the board."
The release was scoped in conversation: 0.8.0 is this, 0.8.1 is the seatless board page, 0.9.0 is
the Jitsi publisher — "13 is the key. Watching on a TV is the bonus." The design is
docs/plans/jitsi-common-board.md § v0.8.0.
The correction that set the scope
Frame.cells is ONE district — the viewer's own, built from areaOf(s, viewer). So a step stream
alone does not answer #13: the data would arrive with nowhere to be drawn. Rendering a district
you do not own is the feature, not part of the seatless page it had been filed under. Nothing
needed to change in officeSvg to do it — it takes board data and has never wanted a private
viewer, which is why PublicDistrict renders as-is.
One hook, not two, and replay inert for free
The design anticipated wiring a collector into GameSession.intent() and driveBots() separately,
with solitaire doing its own thing. It needs neither: src/server/session.ts imports submit from
src/web/game.ts, so solitaire, live multiplayer and every bot turn already funnel through one
function. That is also what makes this a special case of multiplayer rather than a second
implementation.
And fromSave/fromMultiplayerSave rebuild a game with applyIntent + record + drain rather
than submit, so a resumed server does not re-emit a whole game as steps. The plan expected that to
need a guard. It needs none — but the property is load-bearing rather than lucky, so it is pinned by
test.
Pacing, decided by measurement
The obvious scheme is a time budget divided by the queue length. Measured against real games it does
exactly the wrong thing: 44 of a 307-intent game are draw.end and 60 are loadUnload.end, while
the thing worth watching is rare and clustered — two of the three published replays contain no
switch.move at all, and the third has bursts of 14, 6, 6 and 6. Six is the engine's own cap per
crew, which trayMoved says out loud ("N of 6 Moves left"). A uniform budget spends the player's
attention on bookkeeping and rushes the switching.
So dwell is assigned by kind: switching 1000ms (Jesse: "start at 1s and tune down"), an
ordinary action 250ms, a phase 600ms, bookkeeping zero. Three tuning levels, because the committed
table needs a web rebuild and in the .s9pk that is a release: the table, a per-viewer pace
multiplier in Settings where 0 turns it off, and a ?pace= URL parameter for handing two
playtesters different speeds. Deliberately not in game-creation settings — dwell is presentation,
not a rule, and a GameConfig rides along in saves and replays.
Cost, measured: about 4.4 minutes of animation across a whole 6-day game, of which phases are now the largest slice and therefore the first dial to turn.
TODO #18 needed a stepped pump, not a delay
pump() runs every automatic phase between one click and the next and drain() records the whole
batch, so New Train, the Mainline and the shift change were never drawn at all. Folding them into the
triggering intent's step reproduced exactly that. submit() now steps advance() one call at a time
and collects per phase; drain() is untouched, because replay, undo and fromSave all use it and
the inertness above depends on their staying off that path.
Two obvious rules for what earns a beat were both wrong, and both are now pinned by test. "No
narration, no dwell" looked right and silently killed #18 — a phase can move trains without saying
anything. "Anything that changed the board" beat on every turn hand-off, and submit() steps
advance() about 4.6 times per intent, which came to a quarter of an hour a game. The rule is that
the clock turning over earns the beat.
The counter, which is Jesse's design
"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, I could just
click a button and have it skip all the rest." One row rather than three additions — the countdown,
#15's caption naming the action being shown, and Skip. It counts only the steps that will actually
dwell: with bookkeeping at zero, a backlog of 17 where 12 are *.end would read "17", plummet to
5 instantly and then crawl, which is not a countdown anyone can act on. Skip costs the animation and
never the information — every line is already in the History panel.
This also settled the question the design had left open: an "it's your turn" that arrives while the board is still catching up is confusing, and showing the lag beats both alternatives (holding the turn indicator back, or saying nothing).
Switching logged unattributed, and now names its train
record() attributes a line only when the event carries player. trayMoved, carsCoupled,
carsDropped and consistSorted were the only events in their class that did not — so a switching
turn read as an attributed bracket around anonymous contents: "Player Alice chose to switch / CREW
moved (1,2) → (1,3) / Player Alice finished Local Operations". Fixed with the feature that reads
those lines rather than filed. Two texts were reworded to compose with the prefix, because
uncapitalise deliberately protects acronyms and "Player Alice CREW moved" is what it would
otherwise have produced. On Jesse's ask the move now names its train — "moved Train 3 (1,2) → (1,3)"
— using the existing trainName, since a second way of naming a train is the drift this codebase
avoids.
Saves are unaffected: a save is a seed and a list of intents, and events are derived.
The delta had to become a true partial
Steps carry a PublicFrame delta. The first version spread ...next and nulled only the board
fields, so every step shipped all 35 top-level properties even when the only change was whose turn it
was. Once #18 gave phases their own steps most steps became exactly that, and a full game cost
19.4 MB, of which 16.7 MB was silent steps at ~11 KB each. As a true partial — only changed
fields, only changed districts — the same game is 8.7 MB. Districts are keyed by seat rather than
compared as one array, which alone saves 22%: one intent changes one district, and a whole-array
compare resends every other player's board every step.
The redaction net grew to cover the steps, and grew two exemptions
The steps are folded into everythingSeatSees, so every existing case covers them — the blind draw,
the pending decision, Employee Rotation before and after the seating moves, the reconnect, the
played-out game. Doing that surfaced two false positives in the name-based heuristic, neither
caused by this feature, and the distinction they forced is worth keeping: a card NAME is
circumstantial, a card ID is proof. Ids are searched everywhere. Names are not searched in two
places entitled to carry them — lines naming a face-up pile (§2.6: a Department is public, so
"Ann discarded Train 6 face-up on Department 3" is the record working, and it stays in the log after
she takes it back), and the accumulated step frames, which record what was public over time
rather than the position now.
The harness was also passing g.log into snapshot() for the Frame's own lines, which production
has not done since #97; now [], matching frameFor().
Verified by injecting the v0.7.9.2 blind-draw leak and confirming the net still fails — both the dedicated test and, independently, the new step coverage. A relaxed safety test that has not been shown to still bite is not a safety test.
What is not verified
The mechanism is proven end to end server-side: a real server, a real 3-seat game with two bots,
and 28 steps read off a live SSE stream with dense sequence numbers and a 13-step bot burst intact.
The page is proven not to throw — drainIntoQueue, renderWatching and watchedDistrict all run
under the existing DOM-stub tests. Nobody has watched it in a browser. The district switching to a
bot's board, the row appearing, and Skip are unexercised, because the stub has no
requestAnimationFrame and the page degrades to un-animated without one.
0.7.9.8 — 2026-09-07
Housekeeping before v0.8.0 — the answer to "anything else that should be looked at first", which turned up one real hole and one stale document.
npm test did not typecheck, and passed green on a type error (#102)
pretest ran scripts/build-web.ts, which invokes tsc --ignoreConfig against three web entry
points. So it saw only what those three transitively import, under a weaker configuration than
tsconfig.json — no noUncheckedIndexedAccess, no exactOptionalPropertyTypes, --types '' — and
never saw src/server/ or a single file under test/.
Demonstrated rather than argued. Planting const DELIBERATE_TYPE_ERROR: number = 'not a number'; in
src/server/session.ts:
npm run typecheck → src/server/session.ts(441,7): error TS2322
npm test → # fail 0
pretest is tsc --noEmit && node scripts/build-web.ts now, and the same planted error exits 1 with
the tests never running.
Why this week rather than generally. v0.8.0 is steps 2-7 of the common board — a display stream,
credentials, persistence, a Chromium supervisor — which is almost entirely src/server/, exactly the
half the test command could not see.
The common-board plan had drifted from the code it is the source for (#103)
docs/plans/jitsi-common-board.md was written on 2026-08-27, still said "No implementation has been
performed", and is what steps 2-7 will be built from. Step 1 has since shipped across four releases,
so every "current code finding" under it described a fault that is now fixed. A document that reads
as present tense and is nine days stale sends the next reader to fix things twice.
Measured: the plan's PublicFrame sketch lists four properties never built (protocolVersion,
config, scoring, deckCounts) and omits 28 that exist. The shape is the real difference —
the implementation is flat where the plan grouped things into clock, config, scoring and
deckCounts objects, so a renderer written from the sketch would not compile against the projection.
The plan now says all of this at the top and at step 1, names src/sim/view.ts and
test/redaction.test.ts's allow-list as the authority, keeps the original sketch for its reasoning,
and lists what has been gained since. protocolVersion is called out as unbuilt rather than quietly
dropped — step 2 is the reconnecting display stream and is the first thing that would want one.
And one step-1 item is struck off rather than built. The plan asks for the Red Flag holder on the
public player projection, "public game state but currently absent from Frame". The premise does not
hold here: decks.redFlags is written once, in setup.ts, from optionalRules.emergencyToolbox,
and never again — redFlag.play emits phaseEnded and does not spend it — so every player holds one
or none does, decided before the deal. A per-player redFlagHeld would be one already-public option
copied N times, while telling every reader of the common board that it varies by player and might
change mid-game. That is worse than the absence. The invariant is pinned by test so the item is not
re-raised from the plan text.
Four dead imports, and a measurement for #46
Removed: HAND_LIMIT, left unused in apply.ts, view.ts and web/game.ts when 0.7.9.6
consolidated the three copies of the §6.2 test into one, and actingPlayer in web/game.ts, dead
since 0.7.9.5 made currentActor delegate. All four were mine.
Finding them re-measured #46: tsc --noUnusedLocals now finds 40, up from 29 on 2026-08-30. That
entry's prediction — "without the flag this list simply regrows — it is regrowing now" — is a
measurement rather than a forecast. The remaining 36 and the flag itself are still open; ten of them
still wait on #48 settling what sim/replay.ts is for.
Proof
946 tests pass, up from 943. Nothing in this release changes behaviour: the three new tests pin an invariant and the rest is the build command, dead imports and a document.
0.7.9.7 — 2026-09-07
The last item off 0.7.9.6's sweep — the one parked as a maybe, which turned out to have a second half worth more than the first.
A dispatch device now says whether it is still available (#101)
Telegraph (+4), Telephone (+8) and Radio (+12) are "once a day, when dispatching facing trains, add
+N to the other train's number". enhancementText(key) takes only the key, so the tooltip could not
vary with anything — a spent Radio read "Once a day, add +12…" for the rest of the Day, advertising a
bonus that was not there. trainRules before #100, in another corner of the same view.
The half that actually surprises. spendDispatchBonus reads areaOf(s, s.clock.superintendent)
— the Superintendent's own devices, not the train owner's — and the Fedora moves every
STAGES_PER_SHIFT (3) Stages, four times a Day. So a player's Radio does nothing at all for
three-quarters of the Day, and is spent automatically, without its owner being asked, during the
quarter it is theirs to use. Neither half was anywhere on the board.
The card now reads as one of three states — available and dispatching, unspent but idle while somebody else holds the Fedora, or spent until the next Day — and names the shift length, because "not now" without "for how long" is half an answer. A spent device is struck through on the board.
On every district, not only your own (Jesse's call): it is public, and a rival's spent Radio is
exactly what you want to know before forcing a meet. projectDistrict serves the player's own cells
and the common board's districts alike, so one change covers both.
What counts as a device is enhancementRule(key)?.dispatchBonus, not three keys written out in the
view — the ladder lives in ENHANCEMENT_RULES, and a fourth rung would otherwise be silently exempt.
A stale comment corrected, and turned into a test
advance.ts warned that indexing a seat-keyed Office Area with the player holding the Fedora "is
right only while seating is the identity map". It reads as a live Employee Rotation bug and is not
one: areaOf(s, p) is areaAtSeat(s, seatOf(s, p)). A comment that sends the next reader
chasing a bug that does not exist costs about what the bug would. It is rewritten, and the claim is
now pinned by a test — seating set to a real permutation, and the Superintendent's own district, not
the seat with the same index, is the one that reads as dispatching.
Two things caught by mutation rather than by passing
A test that passed for the wrong reason. "Leaves a non-dispatch enhancement alone" asserted the
absence of /spent|Fedora/ with the Fedora held — and a mutant with the dispatchBonus guard deleted
passed it, because the leaked text in that case reads "Available today, and this district is
dispatching", which contains neither word. A test that something was left alone has to compare it
against what it should be; it asserts equality with enhancementText now, in both Fedora states.
The replay wire format needed the field. Cells are packed positionally and the round-trip test
caught the loss immediately. The flag is index 9 and reads ?? [], the same tolerance standingWest
uses, so recordings made before it existed report no device spent — exactly what they drew at the
time, leaving every published replay unchanged.
Proof
943 tests pass, up from 934. The 9 new ones were written red. Mutation: using the raw player index for the Fedora check fails 1, ignoring the spent record fails 3, and treating every enhancement as a dispatch device fails 1 — that last one only after the test above was tightened, which is how the hole was found.
Not verified at a table, like 0.7.9.6. #39 and #35 still stand, and the pre-0.8.0 session is where they get closed.
0.7.9.6 — 2026-09-07
Three things the engine knew and the screen did not, found by looking for them rather than by waiting to be told — plus the dead-field audit from 0.7.9 finished off.
The method, first, because it is the part that generalises
Gitea#21, Gitea#22, #94 and #96 were four instances of one fault in a row: the engine gains something
that changes what a train may do, and nothing draws it. Every one was found by a player hitting it.
So rather than wait for the fifth, every field of GameState and its nested types was enumerated and
checked for a reader in sim/view.ts, src/web/ and sim/narrate.ts — and the survivors were then
verified by running the engine, not by trusting the grep. Four fields had no reader anywhere.
movedThisPhase is set and cleared inside a single advance call and is nobody's business. The
other three are below.
Saying what was ruled out matters as much: freightWorked, drawnThisTurn, freightAgentUsed,
switchedSince and movesUsed are all invisible on purpose — their effect already shows as
legality, or as a complement already on the Frame. A field is not a display gap merely because
nothing renders it. dispatchUsedToday is the one genuine maybe left, and is not done.
The Crew Tray pool is a mechanic you can now see (#98)
state.ts calls §7's tray scarcity "an explicit mechanic" and it was explicit only in the engine.
The blocked panel — the one that answers "why is nothing moving?" — had exactly one tray rule, keyed
off the train due out this Stage. So a player who had spent a card on an Extra, or ordered a second
section, got a completely empty panel while their train sat behind an exhausted pool. Both had
been announced once in the log, in a line that promised a future event — "it runs once as soon as a
Crew Tray frees up", "an identical train will run right behind it" — which nothing ever confirmed.
projectSharedTable carries crewTrays and queued now, so the common board gets it too, and the
panel reports all three cases with the count beside them: "no free Crew Tray" on its own reads like a
permanent fact about the game rather than a state that will pass.
The first draft of that count derived the pool size as trays.size + freeTrays.length. That is
invariant in play — retireTrain puts the tray back — and reads "0 of 0" the moment it meets a
state where a tray is neither free nor carrying a train. crewTrayCount already owned the number.
A train held at the Limits had been vanishing off the board (#99)
The most serious of the three, and it had been shipped. The Interlocking is the designed answer to a full Office: rather than Gap 2d's automatic collision, the train is stopped on the Limit Track and takes the first A/D track that frees, ahead of any newcomer.
arriveAtOffice removes the tray from the Mainline node's transits, and the Interlocking branch
pushes it onto area.heldAtLimits without assigning tray.position. The map draws mainline
nodes from transits and district squares from position.at === 'grid' — so between the two, the
train was drawn in neither. It disappeared from the board on arrival and reappeared in the Office
some Stages later, with a single log line as the whole account of it.
Measured rather than reasoned: with the tray in transits the Interchange node carries its chip;
moved to heldAtLimits exactly as the engine moves it, that node's trains is [] and no grid
square has gained it.
Fixed in the view, not the engine. The engine is right — a held train is inside the Limits and
not on an A/D track — and position is deliberately left alone so nothing may treat the train as
standing on a square it could be switched from. The map draws it on the Limits square it came in by
(eastbound at limitsWest, westbound at limitsEast), flagged so it does not read as an ordinary
arrival, with the reason on the chip and in the blocked panel.
The Campaign Train now says whether its speeches are made (#100)
X17 is "one turn at station (speeches) then expedite" — two states, not one sentence. Its first Office arrival is an ordinary stop; every arrival after runs expedited, and an expedited train left off the Office square when the next Mainline Phase begins is a fault costing 1 Revenue.
trainRules() took { trainNumber, trainIsExtra }, so it could not see speechMade even though
both of its tray-side callers hand it a whole CrewTray that has it. The chip read identically
before and after. Worse, the "EXPEDITED … costs 1 Revenue" warning is printed only under
rules.expedite — so X17 became subject to a fault whose warning the game shows to every other
expedited train and never to it. It now says which half it is in, and borrows isExpedited from
advance.ts rather than restating the test.
The 0.7.9 dead-field audit, finished (#45)
Deferred by Jesse on 2026-08-30 with the decision framed as delete-or-document. The answer turned out to be different for each, and neither was a patch.
overHandLimit is wired, because its consumer existed all along and was guessing. main.ts
already draws a disabled "End Local Operations" button explaining the hand limit — but decided to
draw it from the absence of draw.end in the menu. That is sound only because check('draw.end')
refuses for exactly three reasons and the two guards beside it rule out the other two; a fourth
reason would have made the panel explain a refusal by describing something else entirely, which is
#90 verbatim. It reads f.overHandLimit now — which is what the field was built for in the first
place. Behaviour is unchanged; the screen states its reason instead of inferring it.
viewerSeat is documented, with a condition. Gitea#20's common board keys districts by seat and
resolves the player through playerAtSeat, so a client picking its own district out of a seat-keyed
board needs this and cannot get it from viewer. The declaration says so — and says to delete it if
step 2 ships without using it.
And the audit had missed a third limb. game.mustPlayCard was assigned from overHandLimit on
every submit and read by nothing at all: deleted. Chasing it turned up the thing actually worth
fixing — the §6.2 hand-limit test existed in three places (check('draw.end'), an inline
recomputation inside snapshot(), and web/game.ts's own). All three agreed, which is precisely the
state #96's disagreement started from. There is one overHandLimit(state, player) in state.ts now
and the other two ask it. Session.overHandLimit() — declared on the interface and implemented
twice — is deleted rather than kept, the Frame already carrying the fact.
Proof
934 tests pass, up from 917. The 17 new ones were written red and each fix was then checked by
mutation: reverting speechMade fails 2, dropping the held-train projection fails 4, and forgetting
the two tray queues fails 2. The blocked panel returning nothing for the queues is reported alongside
its positive control — the same state with a timetabled train due, which correctly says "no free Crew
Tray" — because an empty result from a function that is simply broken proves nothing.
Not verified at a table. All of this is engine and view work checked by tests and by running the engine; no part of it has been met by a person at a board. #39 and #35 still stand.
0.7.9.5 — 2026-09-07
Two faults in what 0.7.9.4 had just built, both of the same shape: a second copy of an answer that agreed with the first until it didn't.
The §3.3 vote had no actor, and the screen named one anyway (#96)
Extended play's vote is parallel. Every un-voted seat may vote at any moment, in any order, and
one refusal ends it — apply.ts says in as many words where it accepts a vote that there is no actor
to be. So the honest answer to "whose turn is it" is nobody, and the honest answer to "who are we
waiting on" is every un-voted seat, which is exactly what the tally beside the turn chart already
drew.
The chart disagreed with the tally directly above it. It named the last seat to move before the timetable ran out — a seat with no more claim on the vote than anybody else — while the tally correctly showed three outstanding.
The cause is worth more than the symptom. currentActor(game) in web/game.ts guarded on
status !== 'active' and returned null. currentActorOfState(state) in sim/view.ts — added the
same day in #95, and the function the frame actually calls — had no such guard, so it handed back
whatever clock.currentActor was left holding after the game stopped being active. Two functions
answering one question, correct in every state anybody had looked at.
currentActorOfState carries the status guard now and currentActor delegates to it, so there is
one answer. That matters more than the tidiness: currentActor is what refuses an intent, so a
screen answering differently is telling the table to wait on a player the server would turn away.
This is the fourth of this class in a row after Gitea#21, #22 and #94. It is the first found by
asking a view helper its question in a state the game is not active in — which is the
generalisation, and cheaper than finding the fifth the same way.
Narration reaches a seat once, by one path (#97)
Frame.lines carried the whole narration log on every push, to every seat, and nothing read it.
RemoteSession (web/session.ts) accumulates lines from push.lines alone, and its lines()
returns that accumulator — so the log was serialised into every frame, grew all game, and was
discarded on arrival, while linesSince sent the same text correctly beside it.
The duplicate was masking a bug rather than merely wasting bandwidth. connect() cleared
lastFrame but not sentLines, so a reconnecting seat was told "nothing new since your last push"
— while the browser it was answering had just reloaded and started from an empty accumulator. The
history panel came back blank, mid-game, with the server holding the whole log and shipping it in the
one field nobody reads.
So the two halves are one change, and the plan's instruction taken alone — "stop passing the full
game log into frameFor()" — would have deleted a real behaviour instead of a duplicate. A
(re)connect now resets the seat's watermark, and Push.lines on a connect is the history, which
is what lets the Frame stop carrying a second copy.
Every remaining reader of Frame.lines was checked before the field was emptied: all of them
(sim/replay.ts, web/replays.ts, and the sim and replay tests) are the solitaire and replay path,
which builds its Frames through snapshot() directly and never goes near a session.
One test was wrong before the code was. The first draft of the reconnect test connected inside
its own fixture, so both sides of the comparison were the empty array and it passed against the
broken server — two empty arrays are deepEqual. Each of these tests now asserts its premise is
non-empty before comparing.
Also
docs/plans/jitsi-common-board.md is committed. It was never added — not ignored, just missed —
while TODO.md cites it twice as the plan for all of v0.8.0 and the last two releases were built
from it, so a clone got a TODO pointing at a file that did not exist.
917 tests pass, up from 909.
0.7.9.4 — 2026-09-07
Gitea#20 step 1, done as its own release rather than as the first hour of 0.8.0 — and a Red Flag you can now see.
A Red Flag standing at the Limits is on the map (#94)
maneuver.redFlags sets a flag on an Office's Division node, and from then on the next train
arriving from that side is held short until the flag is spent. It is a token standing on the board —
the same kind of object as a train — and it was announced once in the log and drawn nowhere. Three
Stages later a train stops and the only explanation has scrolled out of the panel.
DivisionView's office node carries redFlag now, and the map draws a staff and pennant at the
end it guards — west on the left, east on the right, since east is right on this map. Which
approach it covers is the whole of the information: a flag in the middle of the cell would say a
flag is out and leave the reader to hover for the half that decides whether to run a train. The
tooltip leads with it, ahead of everything that merely describes the cell.
The third of these in a row, after Gitea#21 and #22. When the engine gains something that changes what a train may do, the question to ask is where it is drawn, not whether it works.
The public projection helpers (#95)
projectDistrict(state, seat), projectDivision(state), projectSharedTable(state),
publicSnapshot(state) and currentActorOfState(state) — and snapshot() rebuilt to compose
from the same helpers rather than keeping a second copy of the shared table. A player's frame and
a spectator's now cannot come to disagree about the clock, the phase, whose turn it is or the score.
Behaviour-neutral: the existing 897 tests passing unchanged is the proof.
The public view is composed upward, never by calling snapshot() once per seat. That shortcut
is the trap the plan warns about: snapshot exists to assemble one player's view, so a public view
made of player views starts by building every private field and then has to remember to strip it —
and it defaults its viewer to player zero, so a careless spectator call today would have served seat
0's hand. Composing upward means a private field cannot arrive by accident; it would have to be
added to a projection that has no business holding one.
Districts are keyed by seat, with the player resolved through playerAtSeat. Employee Rotation
moves players between districts, so seat and player index are not interchangeable — a board that
assumed they were would relabel every district the first time anybody rotated.
One finding from the plan is struck off rather than fixed: it warns that a display reading
clock.currentActor could highlight the wrong district during a decision, since that field is null
while an interruption stands. Measured across six seeds and 3,600 decision points, it and
actingPlayer never disagreed — both are only consulted when somebody is genuinely acting.
currentActorOfState exists anyway, as one place for the next reader to ask.
The redaction net, systematically (#91)
v0.7.9.2 closed two leaks. Both were found by reading a plan rather than by a test, which is the whole argument for this: a suite made of the leaks somebody happened to notice proves nothing about the next one.
Serialise a seat's Frame, the PublicFrame a spectator gets, and the narration they receive, then
search all three for every opponent card id, every card name unique to one opponent's hand, the
seed, and any private decision or menu data — across a fresh game, a blind draw, mid-game, a pending
decision, Employee Rotation before and after the seating moves, a reconnect push (a full Frame, not
a delta, and its own opportunity to leak) and a played-out game.
And the allow-list, which is the plan's stated acceptance bar rather than the tests. Every
property of publicSnapshot is written down with the reason it is public and compared on every run,
so adding a field fails the suite until somebody has said out loud that a spectator may see it. Both
v0.7.9.2 leaks were fields nobody had ever asked that question about.
Two false failures were worth the lesson. A card NAME is a type, not an identity: "right-hand curve" names a dozen cards and one is legitimately drawn as a cell label the moment anybody lays track, so searching for it fails on correct code — which is worse than not searching. A name counts as evidence only when every card bearing it is in the one hand. And a one-digit seed makes the seed check meaningless: seed 7 matched "Train 7" and reported a leak that was not one. The seeds here are nine digits deliberately.
Proved by mutation rather than by passing: restoring the seed line fails 6 tests, restoring the
blind-draw card name fails 1, adding a private field to the public projection fails 7, and making
players[] carry hand contents instead of a count fails 5.
One item on the plan's list has no test because it has no referent: there is no secret objective in
this game. objectiveOf derives from config.minCombinedRevenue and the player's own Revenue,
both public. Recorded so the next reader does not go looking for the gap.
909 tests pass, up from 897.
0.7.9.3 — 2026-09-07
Documentation and the build script behind it. No engine change; 897 tests pass, unchanged.
The generated reference covers what only the implementation knows
TODO #15a asked for this in 2026-08-22 and specified more than a table of card faces: every
Enhancement carries a live / dormantSolo / unbuilt status in content.ts saying whether its
printed effect actually resolves yet, and the opponent-directed Action and Space-use cards are held
out of every deck until the attacks are implemented. A transcription cannot carry either fact. Both
are in the generated page, which is the argument for generating it.
No card counts appear, as ruled — the counts move with play balance, so a document that prints
them is stale on the next retune. Where a count matters it is rendered as a yes/no "is this dealt at
all", which is a fact about the design rather than about the current tuning. #88 closes with it: it
asked whether card-reference.md's industry rows were stale, deliberately without rewriting them
since Laborer counts are a balance decision. They are stale, nothing in the engine changed, and that
file is simply no longer where anyone looks. The balance question it was guarding is #70.
Save compatibility is a rule now, not a per-version note
README.md § Design notes carries it: a save is a list of moves and reopens by being re-played
through the current rules, so any change that makes a once-legal move illegal stops an older one
there — a deck change being the likeliest breaker, since a history naming a card the deck no
longer deals has no legal answer at all. It fails safe every time. #40 is generalised to match and
#32 closed; per-version compatibility facts are no longer tracked (Jesse, 2026-09-07). Versioned,
migratable replays are a post-1.0 question, deliberately deferred — every migration would be written
against rules that change again next release.
0.7.9.2 — 2026-09-07
Two multiplayer information leaks, and the documentation problem that let a wrong table sit in
docs/rules/ for several releases with the code pointing at it.
The shared narration log was telling every seat things only one seat should know
Both found while planning the public common board (Gitea#20 step 1), and both are live multiplayer bugs with or without that display — which is why they are fixed here rather than waiting for 0.8.0.
game.log is ONE list. linesSince(seat) (server/session.ts:181) slices it with no per-seat
filter at all, so every line written there reaches every player. Two things were being written into
it that should never have left the seat that caused them:
The seed, in the opening line of every multiplayer game. competitive · 3 players · seed 4242
handed each player the entire future of the deal — every card order, every die roll.
The name of a card drawn blind from the Home Office deck. Player Cy drew Red Flags from the Home Office deck, to the whole table, from a face-down deck.
Solitaire deliberately keeps both, and that is the rule rather than an exception. A one-seat table has nobody to leak to; the seed in the log is what a bug report quotes — both of the issues fixed in 0.7.9.1 opened by naming it — and a solo player's own history naming their own draw is the record, not a leak. The rule is do not tell the OTHER seats, not write less down. A Department slot stays named for the same reason: those piles are face up, a discard goes onto one precisely so a rival can take it, so the card was public before it was drawn.
The drawing seat still learns what it got. justDrawn is the owner-only channel and session.ts
already sends it to that seat alone, so hiding the name from the shared log costs the drawer nothing.
The seed remains in game.seed, in every save and in the lobby record, so nothing administrative or
replayable loses it.
These were not found by a test. They were found by reading a plan. Every test in
redaction.test.ts passes [] for the narration log, so the whole of it has sat outside the
redaction net since the net was built. Tests for both now live there, but two strings are not a net —
TODO #91 carries what is still owed, and supersedes #78, which described a gap that had already been
closed and never mentioned this one.
docs/rules/ had no current description of the game, and content.ts pointed at a superseded one
content.ts named docs/rules/card-reference.md as "the place that now carries what the cards say".
That file opens with its own banner — "⚠ SUPERSEDED… Do not use its numbers" — and describes the
v0.4.5 deck: twelve numbered trains, 3 / 4 | Mail-Express | 3 coaches, no caboose, against a
content.ts whose train 3 is the Express, two freight cars, oneFreightPerLocation. The code was
sending readers to a table it had itself replaced.
Every file in docs/rules/ turns out to be 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. So the fix is not to rewrite one of them —
the record is worth more intact than patched, and there was simply no current reference at all.
docs/rules/as-built.md is new and is generated — trains, Mainline cards, Offices, freight
facilities, modifiers and track, emitted from the same exported catalogues the engine instantiates
from, by scripts/build-card-reference.ts (npm run build:cards).
test/card-reference.test.ts re-runs the generator and asserts the checked-in file matches, so
changing a card face without regenerating turns the suite red.
A hand-written replacement would have drifted exactly the same way, and for the same reason: nothing fails when a table falls behind a constant. That is the whole lesson of the file it replaces.
897 tests pass, up from 891.
0.7.9.1 — 2026-09-07
Two playtest bugs from one session (seed 550943578). Both were reported as the game getting a rule wrong; in both the engine was right and what failed was what the screen said about it.
The map drew westbound trains in the wrong half of the card (Gitea#22)
Reported as a collision that hit the wrong train: "the display graphically showed train 17 further west than train 5… I allowed train 3 and it collided w/train 17, not train 5 as expected."
regionOfTransit answers how far along its crossing a train is, counted from the end it
entered — everything still to run is region 0. That is the question the collision rules ask, both
directions share the one index space, and it was correct throughout. The Division map was asking a
different question with the same number: which printed box, left to right. East is right on
this map (Gitea#18), so for an eastbound train the two coincide by luck — it enters at the west end,
so "just entered" and "leftmost box" are the same box. A westbound train enters at the east end,
so its region 0 is the right-hand box, and using the travel index directly drew the whole card
mirrored.
Replayed from the attached save at intent 186, which is the position the ruling was made in: T5 was a Stage from the far end (travel index 1) and TX17 had just entered behind it (index 0). Both westbound, so TX17 was physically east of T5 — behind it, in the direction they had both come from. The map drew TX17 at the left, which reads as further west, which reads as further ahead. Asked whether Train 3 could follow Train 5 onto the card, the Superintendent said yes, and Train 3 entered behind — into TX17, exactly where the rules had always had it.
So the fix is one mirror in view.ts, at the boundary the map is drawn from, and the collision
rules are untouched. regionOfTransit keeps its meaning. This is the same class of bug as the
consist row at the Whistle Post (seed 270861860), which came out mirrored for the same reason: a
number that means "distance run" used where the screen means "place".
The tooltip carried the same number and now says which way it is counted — "region 2 of 2, counted west to east". Beside "2 Stages still to run" the bare number reads as a contradiction, and the reporter quoted the tooltip as part of what misled them.
Proved by mutation rather than by assertion: reverting the mirror fails two tests in
mainline-cards.test.ts, and making the renderer ignore the region fails the SVG placement test in
web.test.ts. The existing region tests all ran eastbound, where the mirror is the identity, which
is why the bug survived them.
A refusal the Blocked panel explained wrongly (Gitea#21)
Reported as "could not drop second tank car at refinery… I dropped the first tank car, but that was all I was allowed to do."
Replayed from the attached save: the crew was Train 3, and the engine's answer was
FREIGHT_WORKED_HERE. Train 3 is the Express, which prints "May drop or pick up one freight car at
every location" — the refusal was correct, and the budget is per location rather than per turn,
so the same train may work another car at the next square it reaches. No rule changed.
What failed is that nothing said so. The player checked "Blocked — why nothing is moving" and got
refinery 1,0 — green box empty — nothing to load (needs a Freight Agent action). That is a true
statement about the facility and has nothing to do with why the drop was refused — so it sent them
to spend a Freight Agent action that could not have helped. A panel that answers the wrong
question is worse than one that stays silent, because it looks like an answer. The rule was on the
train card's own tooltip, which is not where anyone looks when a button they expected is absent.
The panel now names it, and only when the crew still has a freight car it could otherwise set out —
a spent budget on a train with nothing to drop is blocking nothing, and this panel earns its keep by
staying short enough to read. It asks freightBudgetLeft, the same predicate the reducer refuses
on, through a new exported freightRuleSpentHere, so the words cannot drift from the rule.
Against the reporter's own save the panel now reads:
[waiting] refinery 1,0 — green box empty — nothing to load (needs a Freight Agent action)
[waiting] Train 3 at (0,1) — ONE FREIGHT CAR PER LOCATION — this train has already worked a
freight car on this square, so no more come off or on here until next turn.
Both were verified against the saves attached to the issues
Not against a reconstruction. Each save was replayed through fromSave to the exact intent the
report names, and the fix checked in that state — which is what the process item added after v0.7.5
through v0.7.8 asks for, three releases that each reported the same bug fixed and each fixed
something that was not the reported fault.
891 tests pass, up from 884.
0.7.9 — 2026-08-30
Four pieces of feedback on the solitaire setup screen, plus the rules bug the second one exposed.
The collision limits were dead settings in solitaire — now they are not
Asked to reword the collision entries to "the game ends immediately and results in a loss", which
turned out to be unwriteable: advance.ts gated the whole §3.4 check on
mode === 'competitive' || mode === 'coop', and a solitaire game's mode is 'solitaire'. So both
limits were offered on the New Game dialog as live settings, rode into the config, and never fired.
A solitaire player could set a limit of 1 and crash all game. The existing text beside them ("the
game ends in a loss") was already false; the new wording would only have made it more so.
The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing recorded a reason for it. Jesse's ruling: the settings do what they say, so the mode gate is gone rather than the controls.
A solitaire game can therefore now end early, and that is measured rather than asserted. Over
200 standard games with the developer bot: loss/collisionFloor 1 game in 200, with Days played
falling 5.00 → 4.98 mean and a minimum of 1 — a bad opening Day can now end a game outright.
Collisions per game are unchanged (0.14 mean, max 3), which is the point: the ending is rare because
crashes are, not because the check is lenient. 0 still switches either limit off, at one seat
exactly as at four, and that path has its own test now.
Every figure in TODO.md quoted from a full-length solitaire run predates this.
Where an Extra may start defaults to your own Control Point
Was "any player's". At one seat the two are the same rule — apply.ts only rejects ownOffice
when start.seat !== seatOf(s, player), which cannot happen — so this is a labelling fix with no
gameplay effect and nothing to re-measure. The permissive label described a permission a lone player
was never being granted, and named an "any player" they have no contrast with.
The extension question was hidden behind the results screen
Jesse, 2026-08-30: "Solitaire game ended. I did not have an option to extend the game by a day."
The engine and the Frame were right the whole time — checked before changing anything: a
solitaire game at the end of its timetable reaches awaitingExtension with extensionVotes: [null],
and isExtendable covers both revenueFloor and daysElapsed. renderActions reaches
renderEnding for any non-active status, and renderEnding writes "play one more Day" and "end the
game here" into #actions.
Then it opens #resultsdlg, which is modal. So the two buttons were rendered directly underneath
a dialog whose only control was Close, and the player read a results screen that offered nothing but
Close and concluded the game was over — which is precisely what it looked like.
The results dialog now carries the question itself, hidden unless a vote is actually pending:
Play One More Day and End the Game Here, casting the same game.extend intent. The
#actions buttons stay, because they are what remains once the dialog is closed and what a player
who reopens it with "see the full results" comes back to.
Why this survived being "verified". TODO.md #35 recorded extended play as checked on
phoenix.local — over the HTTP API, which renders no dialog. The browser path had never been run to
the end of a timetable. Same shape as the three attempts before it: the thing that was verified was
not the thing the player uses.
configWith let the day count and the Revenue floor disagree
minCombinedRevenue fell back to SOLO_CONFIG's constant — the floor for a five-Day game —
whatever days said. So configWith({ days: 1 }) asked a one-Day game to clear 15, a figure a
full five-Day game averages barely half of, and configWith({ days: 10 }) asked for the same 15 a
five-Day game does. It derives from the days it was actually given now.
Not a live fault: createLocalSession is the only caller, and the page always writes the floor
itself, so no dealt game was ever wrong. It was found by a throwaway probe written to reproduce the
extension bug above, which passed only days — which is exactly how the next caller would reach for
it. At the default day count the answer is unchanged, since SOLO_CONFIG's own floor is this same
formula at DEFAULT_DAYS; the three new tests pin that as well as the derivation.
A Heavy Grade shows which way it climbs
Jesse, 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card doesn't show it."
The Frame has carried gradeUp since the Division map was rebuilt and the tip has read "climbs
east" all along — but the one Mainline card whose orientation is set per game, and the one where
Helpers and Brakeman mean opposite things at opposite ends, drew nothing to tell the two apart.
A brown wedge in the card's lower right, rising toward the climb, with a bone arrow lying along its slope. East is right on this map (Gitea#18), which is what lets a wedge be read without a compass — and is the layout decision paying for itself again.
Four passes, and the first three are worth recording because each failed differently. An arrow up the hypotenuse had to start at the wedge's thin corner, where there is no height to draw in, so its head sat over the edge and read as clipped. Level, it was contained but did not read as climbing. At 30° — steeper than the wedge's own 22.5° — it had to be tucked into the fat half to survive. Lying along the slope is the shape that fits: the perpendicular gap to the hypotenuse is then constant down the whole arrow instead of closing at one end, so it can sit on the triangle's centroid. The wedge grew 44×19 → 58×24 to pay for that, because a centred arrow has less room than an off-centre one — the centroid is about 7px from the hypotenuse.
The final sizes are a search result rather than an eyeballed nudge: the roomiest arrow keeping all seven vertices clear of both edges, at 2.88px.
A test that passed a visibly broken glyph is the reason this is pinned properly. The first containment check bounded the arrow against the CARD, which it never left — while the wedge clipped it. The check is against the triangle now, with a 2px floor, plus the arrow being parallel to the wedge (derived from the wedge, so resizing it cannot leave a stale angle) and centred on the centroid.
Orientation is always set — measured, not assumed, since a wedge that invented a direction would
be worse than no wedge. Across 400 seeds × 4 player counts: 535 Heavy Grades placed, 0 without an
orientation, 276 east / 259 west. setup.ts is the only place a Mainline node is created and it
rolls the direction from the seed for every grade, so the ?? 'east' fallbacks in view.ts and
advance.ts are unreachable.
Two setup screens, not three — the in-game dialog is deleted
Jesse, 2026-08-30: "If you are in the Solitaire game and you click the new game dialog, it should not go to a separate screen. We should reuse the Solitaire New Game Screen… in general we should reuse what we already have."
#newgamedlg was a third copy of the same questions and the one that drifted. It was shown ONLY to
a solitaire player, and it asked "Everyone loses if combined Revenue at the end is under" — a
table's question, put to one person — and explained Employee Rotation in full, in multiplayer terms,
beside a control it had itself disabled. Both were on the list to re-word. Deleting the screen
removes the drift instead of restating it, which is the cheaper fix and the one that cannot drift
again.
New game now opens the setup screen in place. Not a navigation: the live session stays in memory,
so the fields open on the rules actually being played — which is what the dialog was good for, and
losing "change one dial, redeal, compare" would have been a real loss — and Continue Existing Saved
Game puts the board straight back with no reload and no replay. Nothing is at risk either way:
render() calls save() every frame, so the game in progress is always on disk.
And the two remaining screens now match below their headers. Each keeps its own opening — the lobby's join/secret section and "Create a new game" are multiplayer's alone — but from the parameters down they are one form: the same three fields in the same order (seed, players at the table, days), the same note about a seed and settings dealing the same railroad, and the same note about every chair being taken. Solitaire shows Players at the table too, locked at one, rather than omitting it — a control that is present and fixed says "this is the same form, at a table of one", where a missing one just made it a different form that happened to share a rules block.
The drift guard that had been checking three prefixes now checks two, and a new assertion fails if
any ng- id ever reappears.
The two screens are worded the same, section by section
Jesse, 2026-08-30: "let's get the text between the Multiplayer and solitaire as close as possible to the same." Walked section by section; where the two said the same thing differently, the lobby's wording won, because it was written for the harder case.
- The chair note is one paragraph on both, replacing two that each described only their own case: "Every chair must be filled before the game can start. For solitaire, there's only one player. For multiplayer, that must be filled by a person or a bot. The number of players cannot be changed once the game is created."
- The Game type heading names which game the screen deals — "Game type (multi-player)" and "Game type (solitaire)". That heading is now the whole explanation for the dimmed rows.
- No reason is printed beside a dimmed type any more. The lobby appended "— dealt with the New
game button, not here" to Solitaire, and the solitaire screen appended "— use the Multiplayer
button; a table needs a server" to each of three. Jesse: "grayed out with no additional
explanation. The explanation above… is sufficient." One heading says it once; three rows were
saying it three times.
markUnavailableis down to dimming, and.lb-whyis gone with them. - The sentence under the radios is deleted on both. It restated the type just chosen to the
person who had just chosen it. Its one non-obvious case — how many settings a Custom game differs
by — is not lost:
form.markalready puts "Co-op default: …" on each row that differs, which is where a reader can act on it rather than a count they would have to go and find. The tests that read that sentence now read the row hints instead. - The Game settings, Starting hand and Victory conditions notes are the lobby's on both, with one edit Jesse asked for: "clicking a type again resets" → "changing the game type resets", which names the control that does it rather than a gesture. Victory conditions was not on his list — applied under the same rule, and the lobby's wording fits now that solitaire also shows the table size.
What is deliberately still different: each screen's own opening paragraph, and the heading above.
The status line said nobody was holding the game up
Jesse, 2026-08-30: "waiting on shows 'nobody — the Division is running itself' BUT the system is actually waiting on the Superintendent."
Frame.actor carried clock.currentActor, which is null for the whole Mainline Phase — so the three
interruptions that stop the game and put a question to a named person (§8.1's clearance ruling,
Gitea#5's Yard Office offer, Gitea#19's Red Flag prompt) all reported that the Division was running
itself. actingPlayer has had the answer since the Gitea#5 refactor; the Frame threw it away.
It carries actingPlayer now, and what the question is: "waiting on Bob · a clearance ruling
· Train 4". Naming the person is not enough on its own — three different things can be pending, and
"waiting on Bob" with nothing after it is a game that looks stuck to everyone except Bob.
The Fedora moved to the end of the phase row
TODO.md #29. It sat on a line of its own between the phase chips and everything above them, which
put a thing that moves every third Stage in among the things that move every Stage. It rides at the
right-hand end of the phase row now — the row whose last chip is Supervisor Shift, the phase that
passes it — and wraps underneath rather than squeezing the chips on a narrow screen.
The developer replay stopped printing a raw enum
TODO.md #34. Its heading read loss — revenueFloor: the exact defect Gitea#16 was filed about on
the playable page, still alive here a release after that was fixed, because nothing player-facing
pointed at it. It reads "lost — The Division closed short: 3 Revenue between everyone, against a
floor of 15…" now.
panels.ts's reasonSentence is exported and shared rather than reimplemented, so the replay and
the results screen cannot explain one ending two different ways. It is fed the last recorded frame —
the state the outcome was decided in — and stripped of markup, since it lands in an <h1> and a
console line. The drift test that compared this string against the engine now maps win/loss to
won/lost, so it still checks the two agree rather than that they are spelled alike.
The save warning, the buttons, and a claim that was simply false
Three more from Jesse reading the two screens side by side.
- The save warning is a warning. "That's a warning, not an 'oh by the way'." It had already moved
off
.ng-noteearlier in this release; it is larger and heavier again now — 15px, weight 500, bright amber on a deeper ground with a 5px rule down the side. Worth noting what he was actually looking at:phoenix.localruns v0.7.8, where this line is still the small grey.ng-note. None of this release has been deployed. - The buttons read the same on both screens: Continue saved game and Create new game. Solitaire said "Continue Existing Saved Game" and "Deal New Game"; the lobby said "Create game". Three phrasings for two actions.
- "Off in every game type" is deleted from the Optional rules note, because it was not true.
Checked against the presets rather than taken on trust:
discardTimetabled— §6.2's "a Timetabled train may be discarded" — ships on in all four types, not just Co-op. The note now says only what is true of all of them: "Each one changes how the game plays."
A game you come back to has not begun
Jesse, 2026-08-30: "when you are continuing the saved game out of that screen, do not post a message that says 'The game has begun.' … it needs to say 'The game has resumed.'"
A restored game draws exactly like a dealt one — mid-Day, mid-phase, with a log already several turns deep — and solitaire said nothing at all to tell them apart. It flashes "The game has resumed — Day 3, Stage 7" now, on both ways back: the setup screen's Continue saved game, and a bare reload that restores the save.
The same line was wrong on the multiplayer side, in the other direction. noteFirstFrame guards
on firstFrameSeen, which is per page-load — so re-entering a game this browser already held a seat
in, by reloading mid-game or picking it out of the lobby's list, announced that the game had begun
to somebody who had been playing it for an hour. beginRemote carries whether this is a rejoin now,
and the line reads "resumed" when it is.
Both halves are pinned, including that a brand-new game does not claim to be a resume — an announcement that fires either way says nothing.
The screen does what you tell it — three items off TODO.md
Reviewed with Jesse 2026-08-30 out of the Display section. A fourth, #17 (hiding the Division map),
was declined in the same pass: its premise died with Gitea#18 and nobody had connected the two.
The map used to grow a row at a time and was worth folding away at three or four seats; a single row
is boardH = PAD * 2 + CH + 30 — 150px, fixed, at every seat count — and that is not worth a
control, three states and a persisted preference.
The Office Area's auto-hide could not reach every state (#16)
One button cycling auto -> pinned -> auto, where the pin it reached was
open ? 'closed' : 'open' — and open is what auto is doing at that moment,
FOCUS_PHASES.has(f.phaseKey). So which pin a press offered depended on the phase: "always hidden"
during Local Operations and Cargo, "always showing" everywhere else. Getting from one pin to the
other meant clicking back to auto, waiting for the phase to turn over, and clicking again. That is
why it never read as a setting — it was not one.
Three controls now, one per mode, and every mode is one press from every other. The labels still say
what pressing does rather than what the panel is doing, which was an earlier deliberate fix; what
the cycle could not do was report the state it was in, and aria-pressed on the lit button carries
that instead of the label.
Addressed by id (#dm-auto/#dm-open/#dm-closed) rather than by querying the container's
children, and that is a testability decision rather than a style one. The page never writes this
markup, so a child query finds nothing in the stubbed DOM the web suite runs against — the control
would have shipped green and completely unexercised. The test that now pins it presses
always-show → always-hide directly, which is precisely the transition the cycle could not make.
The history reads newest first (#23)
Jesse: "it should be reversed so the top line is the most recent and the further down you go, the older the entry." The panel ran oldest-first and scrolled itself to the bottom, so the thing that had just happened was the one line you had to go and find.
The phase headings now trail their lines, and that is accepted rather than overlooked. A
t-phase line reads forwards — it introduces what follows it — so reversing puts each one below the
events it announced. Jesse ruled on it directly: "stage changes will be beneath (prior to / older
than) the following events. That is OK." Reading down the panel is reading backwards in time, and a
heading under its own lines is what backwards looks like. Grouping by phase and reversing the groups
was the alternative and was declined as more machinery than the complaint needs. The
"— the game began —" marker moves by the same logic: it is the oldest thing on screen, so it goes
last.
replays.ts keeps its own oldest-first log deliberately. It is paired with a frame stepper, where
"what just happened" is the step you have this moment clicked, so newest-first would fight the
stepping rather than help it.
The settings moved into a card, and the collision counts came out of hiding (#28)
Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card like Facilities, timetable or blocked… we can give complete information about all the game options and not take up valuable real estate at the top of the screen." And on when it is read: "To go, 'Oh wait, what did we set that to?'"
The top line carried six things and now carries four: Revenue, the objective, the collision counts, and the game code. The seed or seat, the game type and the abbreviated house rules moved into a This Game card at the foot of the right-hand column, folded by default and persisted with the other display preferences.
Nothing new travels for it. configFromFrame already turns the Frame's copy of the config back
into a GameConfig, and rulesListHtml is the renderer the lobby's join preview and seating screen
already draw — so what a player agreed to before the deal and what they read mid-game come from one
implementation and cannot drift. The card adds only the half rulesListHtml has no notion of: which
seed or seat this is, and what the game is called.
The collision counts are new on the board, not merely moved. The Frame has carried
collisionsToday and collisionsTotal since v0.7.0 and nothing drew them, so the one victory
condition that ends a game EARLY ran invisibly — which v0.7.9 made reachable in solitaire too, and
that is what made them worth having. They stay on the top line while the limits go in the card,
because the two are different kinds of thing: a limit is a setting agreed to once, "2 of 3 today" is
a number that changes how you play the next Stage. A limit of 0 is off, and an off half is left out
rather than shown as "1 of 0"; with both off the chip is empty and collapses.
In solitaire the game-code span is empty, so its tooltip — which still carries the type, the blurb and the victory conditions — is unreachable. Not a hole: the card's summary line is on screen whether the card is open or shut and opens with the type, "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1".
One gap in the test stubs was closed to get here. None of the five element factories in the web
suite had setAttribute, so the first render threw b.setAttribute is not a function — meaning any
control that reports its state through ARIA could not be tested at all. They carry an attribute bag
now, and the segmented control's test reads aria-pressed through it.
What is verified and what is not. The logic is covered by three new tests and the two that used to
read #houserules/#gametype were rewritten to open the card rather than deleted. The layout is
not verified — there is no browser on this box, so nothing has confirmed the segmented control, the
card, or the reversed panel actually look right on screen. That wants a play session.
Four things the game counted and never said
Prompted by Jesse asking the general question after two separate v0.7.9 fixes turned out to have the
same shape — actingPlayer existed and the Frame threw it away, and collisionsToday/collisionsTotal
rode the Frame for three releases with nothing drawing them. So: what else is being computed,
serialised and sent to nobody?
Audited rather than guessed. Every one of Frame's 59 top-level fields grepped for a read across the
seven renderers, then the same for Tally's 26 members. 55 of 59 are read. The four that are not:
tally.unloadsBegun— and this one was visible rather than merely unused. §9.1 makes loading and unloading the same shape, begun then carried through, and the results screen printed "Loads still in the pipeline" for one side and nothing for the other. It reported half of a symmetric mechanism. There is an "Unloads still in the pipeline" line now.tally.cardsDiscarded— counted by the engine, listed beside "Cards drawn" and "Cards played" without it. Gitea#9 made throwing a Timetabled train away a legal, deliberate move, so a discard is a player CHOICE the game counted and never reported. It reports it now.viewerSeatandoverHandLimit— deferred by Jesse. The second is the fullest version of the shape: the engine computes it,view.tsputs it on the Frame,session.tsdeclares it on theSessioninterface and implements it twice, and the only caller in the repo is its own test. Four layers of plumbing with no consumer. Left alone for now; the decision when it comes is delete-or-document, not a patch.
Fixing the two turned up a third thing worth knowing. resultsHtml draws
tallyHtml(report?.tally ?? f.tally), and report is f.official, which the engine writes the
moment any game ends — so a finished game reports the tally frozen at the official ending, not the
live one. The first attempt at a test overrode f.tally alone, changed nothing on screen, and failed
for a reason that had nothing to do with the fix.
A shouted keyword is not a sentence
EXTRA X18 started…, attributed to a player, rendered as Player Solitaire eXTRA X18 started…. The
same happened to TRAIN 1 MADE UP and COLLISION, which are shouted deliberately.
record folds a narration's opening word into the middle of a sentence — "Chose to draw" has to read
"Player Bob chose to draw" — and did it with a flat charAt(0).toLowerCase(). It now folds only a
sentence-cased word: ^[A-Z][a-z], a capital followed by a lower-case letter, which is an ordinary
word capitalised because it began a sentence and nothing else is. That also leaves X22 Pee-Dee
alone, which a naive "is the first letter uppercase?" test gets wrong, because '2'.toUpperCase()
is '2'.
It had been filed under Play Balance, where it has no business being — which is how it survived a session that had explicitly ruled balance work out of scope. Found by reading the section it did not belong to.
A replayed save now narrates what the live game narrated
fromSave's loop called record(game, result.events) with no actor, so every restored save, every
undo (which rebuilds through fromSave) and the replay viewer stripped the "Player X" prefix off
every attributed line. Live play attributes — submit passes actor — and so does multiplayer's
replay; this was the one path of the three that did not, which meant the same moves were described in
different words from the game that produced them. The fix is one argument, with actor already
computed on the line above.
Why it survived two years of green tests, which is the part worth keeping. Nothing ever compared a
fromSave-built log against a LIVE-played one. The single log-comparing test compares undo's
rebuilt log against another fromSave-built log — and since undo itself rebuilds through
fromSave, the missing attribution cancelled out on both sides. The whole suite stayed green with the
bug in place, and stayed green after the fix too.
The new test plays a game, saves it, restores it, and asserts the two logs are identical. That is the missing direction, not a new requirement. All three of this batch's fixes were confirmed to fail with the fix reverted before being called done — the discipline the v0.7.5→v0.7.8 sequence bought.
Three wording and layout fixes
- The collision entries on all three screens now read "The game ends immediately and results in a loss…". The Revenue floor keeps its own wording, since it is settled at the end rather than on the spot.
- Employee Rotation on the solitaire screen says "not applicable for solitaire" instead of "meaningless at a table of one, shown here so this screen and the lobby read as one list" — which explained the page's own construction to somebody who had not asked.
- The save warning is legible. It sat in
.ng-note, the same dim 11px grey as the twenty explanatory notes above it, while being the only thing on the screen describing something irreversible. It is 14px on an amber panel now — amber and not red because losing a save is a real cost, not a danger, and red would outrank the actual rules above it. The buttons say Continue Existing Saved Game and Deal New Game rather than "Continue saved game" and "Deal".
884 tests pass, seventeen of them new; one existing test asserted the opposite of the collision ruling above and says so where it was reversed.
0.7.8 — 2026-08-30
The setup screen was unreachable for anyone who had ever played
Third report of the same symptom, and this time the build was confirmed current on screen
(0.7.7-mtf7hyxc), which ruled out the caching fault v0.7.7 had just fixed and left the actual
cause with nowhere to hide.
v0.7.5 skipped the setup screen whenever load() found a save, reasoned in its own comment as
"a saved game is a game to resume". The consequence went unnoticed: a browser that has ever played
solitaire always has a save, so the door could never reach the screen again. Only a browser that
had never played would see it — which is exactly why a fresh private window appeared to prove
v0.7.7's cache fix. The private window had no save. Two genuine faults were stacked, the caching one
was real and is fixed, and it masked this one.
The door now outranks a saved game. ?solitaire is an explicit request to set a game up;
clicking "Play solitaire" is not a request to resume. A BARE reload still resumes, which is the
zero-friction case D11 is about and is pinned by its own test.
Dealing from the door would have destroyed a game in progress, since commitNewGame calls
clearSave() — so the screen now carries #ss-resume ("Continue saved game") and states plainly
that dealing replaces the save. Resuming navigates to the bare URL rather than building a session
on the spot, so start() stays the only place that turns a URL into a game.
Recorded because the failure was diagnostic, not technical. The first two attempts each fixed something real that was not this, and both were reported as verified. The routing fix in v0.7.6 was verified by reading what the server served; v0.7.7's by the same. Neither ever exercised the actual path with the actual state a returning player has. The reproduction here is a failing test asserting the door with a save present — written before the fix, and it failed with "a saved game swallowed the door".
The splash footer names both ways to play
Was "solitaire runs entirely in your browser — no server code required", written when solitaire was the only door. Now: "Multiplayer runs on StartOS server. Solitaire runs entirely in your browser." (Jesse, 2026-08-30, asked to ride along with the next change rather than take a release of its own.)
868 tests pass, four of them new.
0.7.7 — 2026-08-30
Two releases shipped to a browser that never received them
Jesse installed v0.7.5, clicked Play solitaire, and landed in a dealt game instead of the new setup screen. v0.7.6 diagnosed that as a routing bug, fixed it, installed, verified — and it happened again, identically. The second report is what made the real cause findable: the fix was correct both times and neither one ever reached the browser.
buildStamp()'s no-git fallback was the literal nogit, and the .s9pk build has no git. The
Dockerfile copies the working tree in without .git, so git rev-parse fails there on every
packaged build — and that string is not only the visible stamp, it is the cache-bust key every module
URL carries. So v0.7.4, v0.7.5 and v0.7.6 all published ./web/main.js?v=nogit, byte-identical, and
a returning player's browser correctly concluded it had the file already. The fallback is now the
package version plus the build's own timestamp, which is always distinct and needs nothing from the
environment. Proven rather than assumed: two builds of an identical git-less tree now stamp
0.7.6-mtf6l8rm and 0.7.6-mtf6lant.
And the server sent no Cache-Control at all, which is the other half — the pages are the one
thing that cannot be versioned in their own URL, since a player types the address or follows a
bookmark, so a cached play.html pins that player to the whole build it names including every ?v=
inside it. Fixed the exact way round that matters: a request carrying ?v= may be stored for a year
and marked immutable, and anything else is no-cache. ?v= rather than "not HTML" because
build-web.ts tags the modules and nothing else — a year of immutable on an untagged image or on
the replay manifest would outlive several releases of it.
Neither half is sufficient alone: without the varying tag there is nothing for a fresh page to point at, and without the header the fresh page is itself served from cache.
What this says about the two releases before it. v0.7.5's setup screen and v0.7.6's door fix were
both real, both correct, and both verified on phoenix.local by reading what the server served —
which was true, and was never the thing in doubt. What went unverified was the browser, and a
hard-reload would have told us on the first report. Worth remembering the next time a fix "has had no
effect": check that it arrived before re-diagnosing it.
864 tests pass, two of them new — one pinning the no-git fallback as something that varies per build,
one pinning the header rule and that the ?v= flag actually reaches serveStatic.
0.7.6 — 2026-08-29
The solitaire door could not reach solitaire
Found by Jesse verifying v0.7.5 on phoenix.local: from a browser that had ever held a multiplayer
seat, clicking Play solitaire on the splash landed straight in a Co-op, four-seat lobby left over
from unrelated earlier testing — not the new setup screen v0.7.5 just shipped.
start() checks a browser-remembered multiplayer session (station-master.remote.v1) before it ever
looks at solitaire's own state, and there was nothing distinguishing "clicked Play solitaire" from
"reloaded mid multiplayer game" — a bare ./play.html load means both. ?lobby already solved the
identical problem for the door on the other side (D11); the solitaire door had no equivalent marker.
The door now links to ./play.html?solitaire, and start() treats that — along with an explicit
?seed= or a ?hand= the setup screen's own Deal button just wrote — as unambiguous proof this
navigation means solitaire, checked ahead of the remembered-session lookup rather than only below it.
The hand check matters on its own: without it, pressing Deal would work once and then bounce the
very next load into the remembered game, since commitNewGame's URL carries hand= but not
solitaire=.
862 tests pass, three of them new: the door reaching solitaire past a remembered game, a bare reload still correctly resuming one (unchanged behaviour, pinned so the fix does not overreach), and Deal's own URL surviving the same bounce.
0.7.5 — 2026-08-29
Solitaire asks first, the same way multiplayer already does
Jesse: "let the user choose their options like the start of a multiplayer game"; "asking first is
the only path." A bare visit to play.html used to deal a game on the spot, at whatever defaults
gameOptionsFromUrl fell back to, and the only way to see or change a setting was to open the
in-game "New game" dialog after the fact — compare a hand you already have, not one you are about
to be dealt. The lobby has asked this question for every multiplayer game since v0.6.0; solitaire
never did.
A genuinely fresh visit now lands on a new #solitairesetup screen first: game type, starting hand,
where an Extra may start, the three revenue rates, victory conditions, and the three optional rules
— then a Deal button. A saved game, an explicit ?seed=, or a URL a Deal already wrote (hand is
the field every write always sets, so its presence is what tells the difference) all skip straight
past it, the same way ?lobby already skips the front doors on an invite link — those are not "no
plan yet", they are a choice already made, elsewhere.
One shared block instead of two copies drifting apart. The in-game dialog, the lobby, and now
this screen all drive the identical settings-form.ts block through one new function,
wireGameTypeBlock() — factored out of what used to be dialog-only code. Only Solitaire can be
dealt outside the lobby, so the setup screen shows the other four types exactly as the dialog always
has: present, disabled, with a note pointing at the Multiplayer door. Committing an answer — from
either the dialog or the setup screen — goes through one commitNewGame(), which builds the URL and
navigates; start() is still the only place that turns a URL into a game.
Prefilling is deliberately left to the caller rather than folded into wireGameTypeBlock itself:
the dialog opens on the game CURRENTLY IN PLAY, so redealing to compare keeps comparing against it,
while the setup screen opens on the plain Solitaire defaults, since there is no live game yet to
read.
index.html's door copy changed to match: "Start a game" reads "Set up a game" now, and the blurb
states the floor (15, not "20 Revenue") since that is what a player is agreeing to before they deal.
859 tests pass. Not yet played in a browser — verified by tsc --noEmit, the full suite, and
reading the diff, not by loading play.html fresh and clicking through it.
0.7.4 — 2026-08-29
Three rules issues off the tracker, in the order Jesse asked for them: #13, #5, #19. All three are rules Jesse has designed or redefined, and two of them turned out to be rules the code claimed to have and did not.
Some Extras must run loaded (Gitea#13)
X17 Campaign, X18 Circus and X19 Military now prefer loaded cars at make-up — "if not loaded, then empty, and if none available, run without". It is a preference ORDER, so the rule is asked of the Division Yard: an empty is refused only while the yard can still supply a loaded car this train would take, per category, and once it cannot the empty is legal and the train may depart short.
The per-stop point is once per Office Area rather than once per game (Jesse: "in a multiplayer
game, each player could score if the circus stops in their area"), and only when the train is fully
loaded — every non-caboose car, with a coach counting as loaded when occupied. That last detail is
what makes the rule work for the Campaign Train, which carries one coach and no freight, so "fully
loaded" is exactly "the candidate is aboard". X17 also GAINS the point; it had stopThenExpedite
and no scoring rule at all.
Two bugs found doing it. ConsistSpec.emptiesOnly was declared on X13 Appleseed, rendered to
the player as "(empties only)", and enforced nowhere — the same rule as this issue pointing the
other way, so it would have been perverse to add one and leave the other. And a set-up out on the
Mainline paid its point to PLAYER 0 whoever was playing, because playerAtSeat needs a seat and off
the grid the fallback was 0; scoping the rule to Office Areas removes that rather than patching it.
The Yard Office is offered, reachable, and can be run into (Gitea#5)
It was implemented, in a form missing all three of the rule's conditions: a qualifying train was TELEPORTED onto the card. Nobody was asked, no route was computed — so the card's own printed "that can reach the yard office in one move" was unenforced — and because nothing was walked, nothing was ever met on the way in.
Now it is offered to the district's owner; reachability is the engine's own move walk, which already
means what the card means; and cars on the lead collide. The walk COUPLES standing cars rather than
treating them as obstacles, because that is what a switching move does — so what an arriving train
would have coupled is what it is about to hit, and destination.couples turned out to be the
fouling signal with no new machinery. Per Jesse's ruling the two failures are kept apart: no route
means no offer and the history says why; a route that exists but is fouled is offered, and taking it
crashes.
This needed a refactor that #19 then reused. pendingDecision was one question asked of one
player, and currentActor hardcoded that. It is a discriminated union now, with decisionActor as
the single place mapping a question to whoever answers it; six copies of pendingDecision !== null ? superintendent : currentActor across engine, sim, web and tests collapse into actingPlayer.
Red Flags, redesigned (Gitea#19)
The old card protected a stopped train out on the Mainline: offered 4,212 times and played 4 across 600 games. It is replaced outright by a flag planted on one side of your own Limits, holding the next train from that direction — "Flag East holds westbound trains" — spent on the train it stops. One card, one train, so there is no lifting action to build and a flag cannot strangle the Division.
It can also be played out of phase: when an arrival would certainly collide and the district's owner holds the card, the phase breaks in with the question. Offered only to somebody holding one, because a prompt with a single button is not a choice and would leak that a collision is coming.
The bot still never plays it, and the reason changed — measured, not assumed. It now takes the
out-of-phase prompt unconditionally, since the engine has already established the danger. Over 200
solitaire games it plays ZERO, because the prompt needs an arrival that would collide (about one
game in seven) to coincide with holding the card from a three-card hand out of 121. The anomaly
exemption in sim.test.ts stays, but its comment no longer claims the bot is unwilling; what is
left to fix is the half of the card a human uses — planting a flag on purpose to buy switching time.
A bug the redesign walked into, recorded because the next interruption will meet it too: the
flag was originally taken down in a reduce case, which never fires for an event advance.ts
emits — the phase driver mutates and then describes. The flag stayed up and held every train that
came. test/events.test.ts's unreduced-event registry is what makes that class of mistake visible,
and redFlagSpent is now on it deliberately.
858 tests pass.
0.7.3 — 2026-08-29
Two issues off the tracker, and they are halves of one thing: the end of a game. Gitea#11 stops the game being over when the timetable runs out, and Gitea#16 replaces the four lines that were shown there with a results screen worth reading. Neither ships on the 0.4.9 line — Jesse's call (2026-08-29): that line may be complete, and these are not fixes people mid-playtest need.
The game asks before it ends (Gitea#11)
"When game ends allow players to continue playing if they wish… don't force end."
The rule, decided with Jesse: the official result is settled at the original game length and never changes. In a five-Day game extended to eight, the winner is whoever led at the end of Day 5. Extending grants exactly one Day and the question is put again at the end of it — solitaire the player decides alone, multiplayer it is unanimous and one refusal ends it there and then. Only days-based endings offer it: a §3.4 collision breach is final, during an extended Day exactly as during the scheduled game, because a railroad declared unsafe does not carry on regardless.
It could not be a client-side change, for three reasons that each rule out the others' fixes.
check refused every intent once status left active; server/index.ts never loads a finished
game back into memory; and a save is { seed, config, history } replayed through the engine, so a
"continue" the history does not record did not happen — the extended game would evaporate on the next
reload, Undo or restart. So there is a fourth status, awaitingExtension, and the vote is an intent.
config.days never moves. extraDays counts the borrowed Days, and official — the outcome, the
standings and the statistics, frozen at the first ending — is what the results screen reports. That
freeze is done by a wrapper around advance rather than inside checkVictory, so it happens after
the last Day's events have been counted rather than before them.
Three bugs found while testing it, all of which would have shipped:
- A saved game with a vote in it could not be resumed —
NO_ACTOR. A history is a flatIntent[]with no seat written down; the replay derives who acted from the turn order. That works for every other intent,mainline.clearanceincluded, because there is exactly one seat it could have been. Not here: every seat may vote in any order.game.extendtherefore carries its voter, uniquely, and the server checks it against the seat it authenticated. - An all-bot game hung on the question for ever.
driveBotsloops oncurrentActor, which is null the moment the game stops, so it cannot cast a vote; the bot-vote driver returned early when there were no humans to follow, and nothing ever asked. With nobody to follow, the bots' own answer stands — no — and a bot-only game ends on the timetable it was dealt. - The balance harness became unbounded, which is how the third one announced itself:
test/sim.test.tswent from under a second to never finishing.randomBotpicks uniformly among its legal options, so once the two votes were among them it took another Day about half the time — and because a table may go on granting Days indefinitely, every seeded game ran toplayGame's 50,000-turn cap instead of a couple of hundred. GivingdeveloperBota policy was not enough: the guarantee has to hold for every policy, soplayGameitself declines. A simulated game plays the timetable it was dealt, whatever the bot would have voted.
All three have regression tests.
Bots never lead. They agree only once every human has agreed, which is the rule Jesse set: "bots will not disagree with the human. Humans get to vote first."
The results screen (Gitea#16)
GAME OVER — revenueFloor was not a message. It was outcome.reason, an internal enum, interpolated
straight into the page at the one moment the game has the player's whole attention. Every reason now
has a sentence with the game's own numbers in it, and that fix alone answers the issue's "why did the
game end?".
Around it: the result and the winner, the standings, the rules the game was actually dealt under, a per-player breakdown, and the railroad — trains through the Division and how many of them did any switching en route, loads made up and broken, passengers worked, cars switched, trains destroyed and what they took with them. It shares the Day-end dialog's standings, target and collision blocks rather than reimplementing them, because two screens reporting the same game must not be able to disagree. It opens itself once per ending and leaves a button to reopen it, which is what stops Gitea#11's extended play costing you the results.
"I don't know if we keep statistics on…" — nothing was being kept, and now state.tally is,
folded from the event stream. Hooked at applyIntent and at advance, because reduce never sees
the phase driver's events and those are exactly the interesting ones: trainCompleted,
trainsDestroyed, trainStoodStill. The test that matters asserts exactly once — it plays real
games, counts the event stream independently, and checks the tally against that count, so a fold
hooked twice or nowhere fails whatever the seed.
Nothing in the rules reads the tally, so adding a counter is always safe. It rides the Frame, so
multiplayer gets the same numbers as solitaire from one implementation — and test/redaction.test.ts
gained a case proving a tally never carries a card id, since that is a property of what tally.ts
chooses to count and not something the types enforce.
"Longest an engine sat on a siding" is not in this pass, and the issue comment was wrong about
why it could be. That comment said trainStoodStill "is emitted per Stage, so a run of them is
exactly the 'sat on a siding' streak you describe". It is not. Reading advance.ts rather than
trusting it: the event fires only for a train whose profile sets stopEarnsPoint — the X18 Circus,
the one card in the deck that pays for standing still — and stopPointClaimed makes sure it can
never fire twice for the same train. The streak was built, shipped nothing but "1 Stage" against a
raw grid coordinate, and has been taken out again. The engine has no per-Stage "this train did not
move" signal at all, so this needs one before it can be answered; TODO.md #36 records that. What
is reported instead is the Circus set-up itself, which is a real thing that happened.
Badges are not here. The issue asks for them in a second pass after "a whole conversation brainstorming session", and that is where they belong. The tally keeps the raw material — the switching join for "switching master", the standing runs for "longest engine sat on a siding" — and because the statistics are derived rather than recorded, a second pass can add any of them retroactively to games already played and saved.
Also
- A recorded replay plays to its end.
save-replay.tsstopped wherecurrentActorwent null, which since Gitea#11 is the extension question — so a newly recorded file would have replayed to a question nobody answered rather than to a finished game. It declines, like every other bot driver. The three files already published inpublic/replayspredate the vote and stop on the question when replayed;test/harness.test.tsaccepts that as the end of their history, since it is. - The status line stops lying past the last Day. It read
N of TARGET · D Days leftwith both halves false; in an extended game it now reads the Day and how far beyond the timetable play has got. objectiveOfpaces against the timetable actually being played rather thanconfig.days, which otherwise reported "the last Day is over" through every extended Day.
0.7.2 — 2026-08-26
Five issues off the tracker. Two are engine bugs a player hit at the board, two are the design catching up with rulings from RAR that the code had got wrong or never had, and one is the Division map being redrawn. The first four ship as 0.4.9h on the 0.4.9 line; the map does not — it is a multiplayer redesign, and pushing one of those into a build people are mid-playtest on invalidates the feedback.
A 45° leg is part of the row (Gitea#17)
REPORTED: "Cars were West to East Caboose, Loaded boxcar, Loaded boxcar, Loaded boxcar. After backing into that square cars were attached to the train Loaded boxcar, Loaded boxcar, Loaded boxcar, Caboose, Engine." The caboose came back next to the engine instead of at the far end, which also leaves the train badly made up under §8.2.
The square was a sw CURVE and the train backed in through its SOUTH leg. standing runs west to
east, and both places that walk that row asked the PORT which end they were at: exploreMoves
reversed the row for an 'e' entry and nothing else, and cutTowards answered "you meet nothing"
for a north or south exit. Neither is a property of the port. A 45° leg leaves through the MIDDLE of
its edge, so its end of the run is whichever end the arc does not reach — a sw curve's south leg is
the row's EAST end and an se curve's south leg is its WEST end.
rowEndAt answers it from the card, and cutTowards is narrowed from Port to 'e' | 'w' so the
type checker forces every caller to resolve rather than leaving a fourth to be found later.
THE SECOND HALF WAS LIVE TOO, and is a report we had failed to reproduce. With cutTowards
returning nothing for a leg, a crew standing on a curve pulled out through it and DROVE AWAY LEAVING
ITS OWN CUT STANDING — against §A.4's mandatory coupling. That is "cars left behind when backing up
over them", carried as NOT REPRODUCED since 2026-08-22; the earlier sweep had tried that case only
with an east or west exit. The report's second sentence — "I can later drive right through them" —
is still unexplained and still open.
The deck is the sheet (Gitea#14)
docs/Deck cards5.xlsx is in the repo, and every count in the catalogue is now its count. Track is
halved: 16 straights, 8+8 curves, 8+8 turnouts, sharp curves at zero — which is where they already
were, and where sheet 5 independently puts them.
BOTH LONG-STANDING MULTIPLIERS COME OUT WITH IT. The Q12 office doubling (14 → 7) and the Gap 12 industry tripling (27 → 9) were measured against a deck holding 96 track cards, and halving the track turns the tripling backwards — the deck keeps dealing industries while the district stays too small to reach them. Measured over 300 bot games on identical seeds, 96 track with the multipliers / 48 with them / 48 without: reefer cars set out by a crew 49 / 0 / 39, mean revenue −0.20 / +0.22 / +0.27. The middle column wipes out the reefer chain completely. The sheet's own density is the best of the three on both counts.
Q12's own failure was re-measured rather than assumed: 43 of 100 games now never upgrade off a Whistle Post, up from 25 — but they average −0.2 revenue against +1.4 for games that do, where the gap used to be −6.0 against −0.4, and collisions fell from 26 per 100 games to 6. Staying at a Whistle Post is now common and survivable rather than rare and fatal.
Everything sheet 5 does not list is dealt ZERO copies rather than deleted, so the design stays visible and the rules stay implemented: the Telegraph/Telephone/Radio dispatching ladder, Facing Point Locks, Flying Switch, Section House and Vandalism — all confirmed by Jesse as deliberate removals — plus Poling and the sharp curves, which were already there. Card for card, 84 rows agree with the sheet; the only ones that do not are the ten Safety, Event, Inspection and Space-use cards it adds that are not built, held out until they are.
A rail may stop dead against its neighbour (Gitea#15)
Filed as "track placements must connect", against a right-hand curve laid with its north leg against an Ice House and the turnout below pointing at its portless south edge. RAR reversed it on review: the placement is fine, and a stub like that has a use — a siding to park cars on. What he asked to confirm instead is that no train can traverse the gap.
It could not, and cannot: exploreMoves gates every hop on joins, which tests both ports AND that
two 45° legs lie on the same diagonal. That was already true and simply unpinned; track.test.ts
now holds it against the reported geometry, including a check that the curve IS reachable from the
side that joins, so the negative test cannot pass on a card that is merely unreachable.
A per-edge placement check was written and then taken out, along with a matching guard on
checkTurnoutUpgrade. Both are documented in place as deliberately absent, because this is exactly
the rule someone will "fix" again. A Modifier is scenery (Jesse): a rail pointing at a building is
fine.
Crossing a Mainline card is regions, not miles per hour (Gitea#3)
REPORTED: a train taking two Stages to clear Double Track, which prints 60. RAR, on review: "Ignore speed signs, they are just graphics. Regions shown on cards indicate how many stages it takes to cross."
Two recorded rulings are superseded together — Q1, that the printed 60/30 are crossing time, and Q2, that a Slow train adds a Stage to every card. Q2 is what produced the report. Cards carry a region count now and where a train STARTS is what varies: Plains 1, Double Track 1, Trestle 1, Curves 2, Tunnel 2, Heavy Grade 3. Fast/Slow is read on Hilly and nowhere else — and Hilly no longer reads the consist, which had a fast freight crossing slower than a slow passenger train. The grade modifiers move the start rather than cutting the clock: Helpers start an uphill train a region on, Brakeman a downhill one, Airbrakes another again. The Uncontrolled Siding and the Interchange print a back region that is not road: a train through starts past it, one arriving to find the siding occupied takes it and runs a region behind, and an Extra beginning its run at an Interchange starts there.
THREE THINGS WERE MISSING RATHER THAN WRONG. With Plains now one region, a following train is in
the same place as the train ahead the moment it enters — and nothing tested that, because the
catch-up check sits inside stagesRemaining > 1, which a one-Stage crossing never reaches. That is
the case ABS describes ("the train moving onto the card is instead held back") and it needed
building. ABS was also holding SILENTLY on the Office and Division Point paths, so the one card whose
purpose is preventing a wreck did its job invisibly. And collide never released the A/D track,
which did not matter while every collision happened out on the road — a train destroyed as it LEAVES
is still standing at the Office.
The Uncontrolled Siding was marked "trains may pass", which skipped the collision test altogether and
made the siding do nothing at all. It is false now, with the siding entry doing the work.
Measured: on the Mainline cards of a 3-player Division, a fast train pays ~5.6 Stages against ~5.4
before and a slow one ~6.0 against ~9.4. Fast traffic is unchanged; slow traffic is about a third
quicker, and the gap across a Division collapses from roughly four Stages to under one — so Q2's
recorded consequence, that every Slow train is still on the road when the next Day begins holding its
Crew Tray, no longer holds and players + 3 is due a re-examination.
Two effects are recorded in TODO.md rather than acted on: freight share fell 8% → 5% and completed
freight loads got scarcer, which runs against the obvious expectation and nobody knows why yet; and
the bot stopped playing Red Flags — offered 4,212 times in 600 games, played 4.
The Division is one row (Gitea#18)
REPORTED: "track design should not be horseshoe / square, but a single row… Division map should not show any office area detail."
It was laid out around a table — one row for a single seat, two facing rows for two, a horseshoe for
three, a square for four — on the reasoning that players sit around one. Three reports came out of
that, and the one that decided it is that east stopped being to the right: a player's east could
be drawn south, west or north depending which lane their district landed in, on a map whose whole job
is saying which way a train is going. WDP · ML · Office · … · ML · EDP, left to right, and the
buffer stops simply face outward at the two ends.
An Office no longer expands into its Running Track either, so the map stops carrying every straight, turnout and Limits sign of every district — that is the Office map's job, and it draws them properly, with the rails. It also stops the Division map growing sideways every time somebody lays a card.
The trains stay, in two registers: those holding an A/D track ON the rail, and crews switching in the district UNDER it — the distinction drawn as position rather than colour, because that is where those trains are. Each chip carries its number, a direction arrow and a car count, with the consist and the train's printed card on the tooltip. Every district cell is four chips two-by-two regardless of tier, because a Whistle Post with one A/D track can still hold four trains when three of them are crews, and sizing by occupancy is what "The Roster Pass" fixed. 1,580px wide at four players against 842 before, drawn at 1:1 so it scrolls rather than shrinking — Jesse's call, "zooming and scrolling worked fine".
This supersedes TODO.md item 24 outright and makes 19, 20, 21, 22, 25 and 26 irrelevant; item 27 is
fixed.
Six test fixtures that pinned a seed and meant "a game like this"
Every one of them broke on a rules change and none was about the rule that changed: the deck's SIZE
moves the RNG stream, so any count change re-deals every fixture that names a seed.
mainline-cards now searches for a Division holding a single-track card, multiplayer for a game
that reaches Day 3, web clicks every play verb rather than assuming the first goes on the board and
searches for a seed that builds a board, and the sim commodity samples were re-measured — a tank is
first set out at game 216 now, unload Revenue at game 46. A seventh, mainline-cards' hand()
helper, threw when a card was not in the deck, so dealing Flying Switch zero copies took five passing
tests of an unchanged rule with it; it mints one now, which is the point of keeping a row at zero.
And one real bug, in a test. the game conserves Rolling Stock went red claiming the engine had
conjured three cars. The engine was right: Gap 2c sends a wreck's cabooses to the Division Yard and
everything else to Classification, destroying nothing. The test subtracted the wreck's consist from
the expected census — a rule the engine does not have — and the branch had never executed, because
none of its six seeds had ever collided. The census is held flat unconditionally now, which is both
the true invariant and stricter than what it replaced.
0.7.1 — 2026-08-25
Four issues off the Gitea tracker, all of them things a player saw at the board. Two are engine or page bugs, one is a rules ruling that supersedes a ruling from three days earlier, and the fourth turned out not to be a bug at all — the fix there is that the game now says so. The same four ship as 0.4.9g on the 0.4.9 line.
A caboose is not a load (Gitea#8)
REPORTED: X22 Pee-Dee could not couple a caboose — including the one it was made up with. Drop it at the end of a sweep and it was stranded there, which makes a train whose whole card is a restriction ("may only pick up MTs") unplayable rather than merely restricted.
ROLLING_STOCK_SUPPLY mints all six cabooses as { loaded: 6, empty: 0 }, because §2.2's "a
coloured car is loaded, a white car is empty" is doing double duty there as a PIECE COUNT and there
is no white caboose to make a train up from. So .loaded carries two meanings, and the second one
escaped in exactly one place: pickUpEmptiesOnly's fresh.some((c) => c.loaded). Every other read
of the flag in apply.ts is already scoped to a coach or to a named car type.
carriesLoad now answers the question the rule is actually asking — a caboose carries the crew, not
freight, so it is never a load — and the restriction itself is untouched: a loaded car alongside the
caboose still refuses. trainRules says so on the card ("A caboose is not a load"), because a player
reading "EMPTIES ONLY" has no way to know which reading the game took.
The Day rolls over and says so (Gitea#10)
REPORTED: "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of time."
Nothing on screen was wrong. The clock, the turn chart and the timetable all said which Day it was. What is wrong is WHEN it changes: a Day turns over inside the phases that run themselves, so it happens between one click and the next, while the player is watching the board and waiting for their turn. The two transient signals the page already had are both gone before that — the phase banner at 2.6s, the announcement flash at 4.2s. A modal is the whole request: it stops, and it waits.
dayEndHtml writes it from the frame AFTER the rollover, so the Day that ended is f.day - 1.
It carries the standings in Revenue order rather than seat order (the question at the end of a
Day is who is ahead), the Days left to run, and the combined target — reported against the whole
table's Revenue, because minCombinedRevenue is a combined floor and one player's score against a
four-player target reads as hopeless when the table is comfortably ahead. Collisions appear only
where §3.4 actually scores them: competitive and co-op, and only when a dial is non-zero. A solitaire
game carries the default dials and enforces neither, so printing a collision budget there would put a
rule on screen that this game does not have.
Three suppressions, each of them a way it would otherwise lie:
- the first frame — arriving in a game already on Day 3 is not Day 2 ending, and a page reloaded mid-game would announce a rollover that happened before it was watching;
- the Day going DOWN — that is Undo stepping back across the rollover, not a Day passing. Undo
also clears
lastDay, so replaying forward through the same rollover does not announce it twice; - the Day the game ends on — the outcome panel is the thing to read then. Verified by playing a full solitaire game through: the dialog fires four times in a 5-Day game, not five.
A Timetabled train may be thrown away (Gitea#9)
REPORTED: "Timetabled trains are at the choice of the player — they can either play or discard. If someone else wants to pick it up, they are more than able to. The reason: I don't want, if you decide to play a game longer than five days, to decide that maybe there are too many trains, the stations are jammed, and the railroad doesn't need any more. You can toss it. Someone else might disagree and pick it up."
This supersedes Gitea#6, shipped three days earlier in 0.6.2, which made every train card unconditionally undiscardable. Two things narrow it:
- an Extra is still never discardable. It never joins the timetable, so it can never be what jams it, and the only rule it would dodge by being thrown away is the hand limit;
- the Timetabled half is a New Game setting —
discardTimetabled, on by default — because Jesse's reasoning is explicitly about games run LONGER than five days, and a five-Day game may well want Gitea#6's pressure back. Jesse asked for it as a setting on this line and as the plain rule on the 0.4.9 line, which has no scaffolding for one; both play the same game at main's default.
The second half of the ruling needed no code at all. "Someone else might pick it up" — a discard already goes face-up onto a Department pile, and a Department pile is exactly what a rival draws from. Only the first half was a change.
One place decides, and the card says which rule refused. keepReason returns the sentence a
player should read, or null if the card may go; check, the hand panel and the blocked "End Local
Operations" button all ask it. It returns a SENTENCE rather than a boolean because there are now two
distinct reasons — "an Extra is never discarded" and "not in this game" — and the panel that used to
hard-code one of them would now tell half the players the wrong thing. It reaches the page as the
Frame's handKeepWhy, replacing text panels.ts and main.ts each wrote for themselves.
Two places would have dropped the setting silently, both found by looking rather than by tsc —
HouseRuleOverrides' fields are all optional, so omitting one compiles and falls back to the
default. The New Game dialog's close handler builds its own houseRules object (the comment
directly above it warns of exactly this: "a setting missing from here is a setting the dialog
silently discards"), and presets.ts's Frame-to-config path, which is how a JOINER is shown someone
else's game — a setting dropped there shows them a rule the table is not playing.
Gitea#6's corner survives, narrowed. A hand of four undiscardable trains still has exactly one legal way on — play one — with nothing in the engine computing "you must play a train". With the setting on, the only hand that reaches it is four Extras; with it off, any four trains, as before. Both are pinned by tests.
Why nothing is moving on the platform (Gitea#2)
REPORTED: "The Sparrow pulled into the station with two loaded coaches. There are two passengers on the platform. Four porters. My thought was to unload two and load two. I never get the chance to load the last two."
The engine is not deviating from the rules, and this was checked step by step against the reported save. §9.2 discards the white coach into the Classification Yard on boarding and draws one from the Division Yard on de-training; §2.2 returns the Classification Yard only when the Division Yard is empty. All three are implemented exactly. What bites is the interaction: both halves of every passenger cycle consume coaches one-way, and a single global refill condition over a pile of six commodities means they do not come back. Traced over the reported game the coach pool goes 8+/8− on Day 1 to 0+/1− by Day 5, with eight coaches stranded in Classification behind ~60 other cars.
Jesse's ruling is that the shortage stays — "it is possible to run out, that's part of the
strategy" — so the three balance options written up in TODO.md are declined rather than deferred.
What was unambiguously a bug is that the game said nothing. A Porter action that cannot be taken
is simply absent from the menu, and impediments() — the panel whose entire job is "why is nothing
moving?" — opened with if (!f || f.kind !== 'freight') continue, so a platform had never had
anything to say for itself. The player was not merely blocked; he was given no reason.
A Passenger Facility now reports both directions: passengers standing with no train to take them, a
train whose card bars Porters from working it, a Terminals-only train at a lesser Office, every coach
already full, the red slots full, the same-district rule, and the coach shortage itself — that last
one naming how many coaches are sitting in Classification and the condition that brings them back,
because a yard visibly full of cars that will not yield one coach is the state that looks like a
broken game. The reason text comes from passengerRefusal, the engine's own predicate (exported for
this), so what is on screen is the rule that actually refused rather than a second guess at it.
A second defect fell out of fixing it. The row's name is read off card.geometry.facility, which
a Passenger Facility does not have — it rides on the office card — so every passenger impediment
would have read facility 0,0 beside a freight row saying mineTipple 1,-3. It is named by the
Office Area's tier now (terminal 0,0), and there is exactly one Office per Area, so that tier is
the card's own.
Verified by replaying the reported save (playtests/station-master-seed947338225-day5(1).json)
through fromSave and printing the panel. Worth noting for anyone who tries it: that save is a
v0.4.9-line recording and stops at intent 250 of 323 on the 0.7 engine, because Gitea#4, #6 and #7
changed the rules its later intents were recorded against. That is expected divergence, not a
save-format bug — a replay reproduces a game from decisions, and the decisions no longer mean the
same thing.
0.7.0 — 2026-08-23
The multiplayer set-up, the lobby, the start of a game, and four things a remote client had never been shown. Jesse's cleanup pass, worked area by area with the design settled before any code was written.
Four game types, and Custom
The lobby used to offer two modes and a folded block of numbers; the New Game dialog offered three modes that only previewed defaults. Both now offer the same five: Solitaire, Co-op, Competitive, Cutthroat and Custom.
A type is a set of defaults, not a ruleset. Picking one fills the whole form; every rule stays editable; editing any of them selects Custom, which keeps the scoring of the type it was edited away from and says so on screen ("Custom — scored as Co-op · 3 settings differ from Co-op"). Clicking a named type again resets every rule below the radios.
| Solitaire | Co-op | Competitive | Cutthroat | |
|---|---|---|---|---|
| Scored as | solitaire | coop | competitive | competitive |
| Opening hand | six | six | six | six |
| An Extra may start at | any Control Point | your own | your own | any player's |
| Passenger / freight / transit | 1 / 1 / 0 | 1 / 1 / 1 | 1 / 1 / 0 | 1 / 1 / 0 |
| Combined Revenue floor | 3 × players × Days | 3 × players × Days | 2 × players × Days | off |
| Collisions in one Day | 3 | 3 | 3 | 3 |
| Collisions in the game | 5 | 5 | 5 | off |
The floor is a formula, not a number, which is why the seed, the table size and the Day count sit ABOVE the type radios as parameters: changing one re-derives the floor rather than making the game Custom, so "Co-op, 3 players, 8 days" is still Co-op. Once a host types a floor themselves it is theirs and nothing overwrites it.
The type is derived, never stored (presetOf, src/web/presets.ts). A saved game is its numbers;
a name written beside them is one more thing that can disagree with them. A join preview measures a
Custom game against the nearest type it is scored as, so "Custom" alone is never all a player is told.
Every field says what its type would have set, and goes amber when it differs — a non-standard game should be possible and never accidental.
The two screens stopped drifting apart
They each carried a hand-written copy of the same form, and had already diverged in both directions:
- The lobby had no "where an Extra may start" at all, so every multiplayer game ever played used the most permissive setting — an Extra could be planted in another player's district — and no host was ever asked. It is a Cutthroat-only default now.
- The solitaire dialog had none of the three optional rules, so Employee Rotation and the
Emergency Toolbox could not be played solo at all. They ride in the URL with everything else now
(
vis,rot,tool), and Employee Rotation is disabled at a table of one, where it has no meaning.
Both blocks are generated from one template and driven by one module (src/web/settings-form.ts);
test/web.test.ts asserts the built page carries every field on both screens, which is the guard the
shared markup was for.
The dead PvP checkbox is gone
buildDeck ANDs pvpCardsAllowed with a hard-coded cardsImplemented = false, so the checkbox
could not change anything whatever it was set to — on either screen. The 22 opponent-directed cards
(and the 7 defensive cards held out with them) are a property of the game type now, and where the
control was there is a sentence saying they are not implemented yet.
Victory conditions lost the magic zero
0 means "off" to the engine, which is exact and unreadable: a Cutthroat game showed two zeroes and
left the player to know the convention. Each condition is a checkbox with its number now, and the
wording says what actually happens — "The game ends and everyone loses if collisions in one Day
reach 3". Unchecked still writes 0, so nothing underneath changed.
The lobby
- Joining is its own door, not a heading below fifteen fields a joiner has no use for.
- A player reads the whole rule set before taking a seat —
GET /api/lobby/preview, gated by the same join secret, taking no seat, and never carrying the seed, which decides every shuffle. - A seat survives a reload. The token used to live in a closure and reach
localStorageonly atLobby.Start, so refreshing while seated orphaned the chair: the player could not return and nobody could free it, on a table that cannot start until every chair is taken. The record is written at create/join with astage, and a reload walks/api/sessionthen the lobby stream to land wherever the seat actually is. - There is a way out.
POST /api/lobby/leavefrees a seat; naming somebody else's is host-only, which is the host's remove; the last human leaving deletes the lobby, its code and its index row rather than leaving a table of bots waiting for a host who no longer exists. - The lobby stream can fail out loud.
onmessagewas the only handler, so a dropped connection left the seating screen frozen and silent. It now says so, and its probe tells a blip from a dead lobby — and finds a game that started while the connection was down. - A stored join secret collapses to one line and re-opens itself on a 403, beside the field it is about.
- Two players may not share a display name (
NAME_TAKEN, trimmed and case-insensitive). The name labels the district on the map, is what the turn chart means by "waiting on Jesse", and prefixes every line that player causes; the names lock atLobby.Start, so the refusal has to be at the door. Refused rather than suffixed: a player should play under the name they chose. - Bots are numbered in the seating list the way
startLobbywill number them. - The code copies as a code and as an invite link (
?lobby&code=…) — the link never carries the join secret, which travels out of band by design. - Server codes became sentences in a red block instead of
.dimgrey:LOBBY_FULLwas being printed at players verbatim. - Everyone at the table can see what they are about to play, not just the host who typed it.
The start of a game, which nobody had ever drawn
beginRemote wrote "… connecting to the game" into #presence — the DISCONNECT banner — and it
worked only because the first render overwrote it. The handoff is its own state now: a curtain with a
deliberate beat, a stall message if the board never arrives, then an announcement naming the game and
its type, and a marker at the top of the log so the bots' opening turns are visibly after the start
rather than merged into it. The game code and the game type are in the header for the rest of the
game — the code used to end at the lobby door, and the type was never on the Frame at all, so a
Cutthroat game looked exactly like a Co-op one from the board. Start cannot be pressed twice.
A client is told who else is at the table
/api/stream's connect push carried the board, the menu and the log — and nothing about anybody
else. broadcastPresence only ever reports a CHANGE, so a player learnt of a seat only if it dropped
after they connected: at a table where two people had not opened the game yet, the screen said
nothing at all. Every other seat now rides on the connect push, with seen separating was here and
dropped from has never opened the game — the first will probably be back, the second needs
somebody sent a link.
The four transient signals reach multiplayer
createRemoteSession answered all four with empty values, so a game on a server had no sound at
all, no flash on the timetable slot a D12 had just filled, no announcement when a completed run
paid the table, and no badge on the card you had just drawn. All four ride on the push now.
The shared three (cues, the flash, the announcement) are drained once per broadcast and are identical
in every seat's push — a collision anywhere on the Division, the Stage bell and a train leaving the
Division are the table's, not one player's. justDrawn is not shared: game.justDrawn is one
field for the whole game and does not say whose card it is, so the server remembers who drew and
sends it to that seat alone. A reconnect gets the badge back but none of the shared three: a fresh
connection is drawing a state, and replaying the sounds of everything it missed is a burst of noise
about the past.
Draining them also fixed a slow leak nothing had noticed: game.cues grew without bound on a server,
because the only thing that ever emptied it was a local session's render.
Two smaller fixes found on the way
- Undo re-dealt a game under the wrong victory conditions.
undo(game)defaulted its config toSOLO_CONFIG, andSavecarries only the house rules — so undoing a move in an eight-Day game replayed it as a five-Day one, with the Revenue floor and both collision caps reverting too. It defaults to the game's own config now. - The page deals the Solitaire game type, not the engine's fallback. Every type opens with six
cards (Jesse's call), but
SOLO_CONFIGdeliberately did not move: every engine test and every sim measurement is taken against it. What a player is dealt when they open the page is the type.
Verified by running it, not by reading it
A real server, a real lobby and a real game — which is where two of these came from:
- A bot seat was being reported as a disconnected player. Every screen would have carried "waiting on Bot 1 — not here yet" for the whole game. Bots hold no connection and never will, so they are not reported at all.
- The six-card opening survives a table with bots in it. A three-seat game (two humans, one bot) played through Day 1 without stalling on the discard round the six-card deal creates — the risk worth checking before making six the default everywhere.
Also exercised end to end: preview before joining (and its 403/404s), the duplicate-name refusal, leave freeing a seat, a non-host being refused somebody else's chair, the last human closing the lobby down to its index row, and every one of the four signals arriving at two seats at once.
test/presets.test.ts pins the numbers in the table above, test/server/session.test.ts pins the
justDrawn boundary, test/server/lobby.test.ts covers leaving and the name rule, and
test/web.test.ts gained a suite for the lobby screen, which had none at all.
Found by playing it on StartOS, and fixed in the same release
Everything above was written before the build went on a box. These came out of Jesse playing it:
- An Extra belongs to the player who played it. §7 gives an Extra to whoever played the card —
they place the Crew Tray and load the consist as they choose — while a TIMETABLED train's consist
is built by the table, starting at the Superintendent and working left.
pendingExtraswas a barenumber[], so nothing recorded whose Extra it was and the phase asked whoever the acting order happened to be on: right in solitaire, wrong at every table. It carries the player now, the tray remembers who is building it (builtBy), and another seat is refused withNOT_YOUR_EXTRAand is not even offered a start point. No migration: a save is a seed plus intents, so aGameStateshape change costs nothing. - The board never named the Superintendent. Reported as "seat 1 played a train card BUT seat 2
was prompted to build the train — what's the rule?" The engine was right; the screen simply never
said who held the Fedora, so the question had no answer on it.
Framehas carriedsuperintendentsince v0.4.0 and only the standalone replay ever drew it. It is a chip on the turn chart now, shared by all three screens and hidden in solitaire, and the New Train pill's tooltip says who builds a consist rather than only what the phase does. - A player can leave a running game, keeping their seat and their token, and the lobby lists
every game the browser is in with Rejoin and Forget. This fixes something worse than what was
asked for:
localStorageheld exactly ONE session, so joining a second game overwrote the first token and locked that seat out permanently —TODO.mdhad it as the nearer half of the lost-token problem. Leaving closes the SSE stream too, so the table sees the seat go quiet instead of being told somebody is present who is not. ?lobbynow beats resuming. The splash's multiplayer door and an invite link both mean "I want to pick a game", but a remembered session was checked first — so anyone already in a game was dropped straight back into it and could never reach the lobby from the door. A bare load still resumes.- The create form is two columns, the lobby is 1040px rather than 640px wide, and Game settings spans both columns when it opens with its five groups flowing into as many columns as fit — it used to open inside one column and leave the other standing empty down a very long scroll.
- "Every chair has to be taken" moved next to the table size it explains, from below the rules block.
- A disabled game type now looks disabled — the row dims and says why. A bare
disabledon a radio leaves the label at full strength and reads as a broken control. - Rule section numbers are out of every string a player reads — 22 of them, across the page copy, the turn chart, the narration, the panels and the bot's own explanation line. "§6.2 — you may not end a turn holding more than three cards" is now "You may not end a turn holding more than three cards". The sentences still state the rule; they no longer cite a number that a rewrite will invalidate. Code comments keep their references, which is where they are useful.
Two more findings went into TODO.md rather than into code, at Jesse's direction: the Division map
draws no track geometry at all (a turnout is pixel-identical to the straight it replaced — the whole
visible change is a caption), and the map's buffer stops and direction go wrong once the route wraps
through the lanes. All three are one drawing pass with the items already queued there.
Also in this commit: Heavy Grade orientation, asked again and unchanged
Uncommitted documentation work from the previous session, carried in here rather than left in the tree. "Did we ever fix Heavy Grade to allow user placement of direction?" was raised a second time, with the option of giving the choice to the Superintendent considered and rejected: the advantage is permanent and the office rotates every three Stages, so a rotating chooser moves the fairness problem rather than solving it. v0.5.0 stands — the orientation is rolled from the seed.
The docs were the real defect. README.md listed it among three open rules questions, all three
of which v0.5.0 had closed, and the Mainline deck reference said the implementation "needs a player
-selection step". Both corrected, the reasoning recorded in docs/rules/implications.md §10 Q11 so
it is not asked a third time, and content.ts/setup.ts gained comments saying which half of the
printed card is deliberately overridden and why. No code change, and none wanted.
Housekeeping
playtests/ is a gitignored home for save files that arrive with a bug report, with a committed
README saying what goes there and how to open one (the replay viewer takes a file straight off
disk). package-lock.json's version had been stuck at 0.5.0 since that release — only
package.json was ever bumped — and now matches.
undo() defaulted its config to SOLO_CONFIG (noted above), and while confirming that, one more
came out of the same corner: nothing was draining game.cues on a server, so it grew without bound
for the life of a game. Draining it per broadcast is what makes multiplayer sound work and fixes that
at the same time.
Not done here
The visual rendering of the new screens was not looked at in a browser while it was being written —
headless Firefox could not start on this box, so everything was verified by test and by driving the
real server. It has since been played on StartOS, which is where the fixes in the section above came
from; the Division map items it also turned up are in TODO.md rather than in this release.
0.6.3 — 2026-08-23
The deploy script only. No rules change, no game change, and the built site is byte-for-byte what 0.6.2 produced — this repairs the path that publishes it.
The host moved to FileBrowser Quantum
Found trying to publish the 0.4.9f playtest build: every deploy died with
login failed: 404 404 page not found. The File Browser instance has been upgraded to
FileBrowser Quantum, a fork whose API differs from the v2.63 one deploy-web.ts was written
against. Three things moved at once, each fatal on its own:
- Auth is a session COOKIE, not a JWT returned in the response body and sent back as
X-Auth:. A deploy that ignored the cookie would authenticate and then be rejected by every upload. - The password is a header —
X-Password, URL-encoded — not a JSON body field. - The path is a query parameter (
?path=), and every resource call must also name asource: Quantum can serve several named stores and refuses any call that does not say which ("no source provided"). The v2.63 API had no such concept at all.
Rewritten against the running instance's own bundle rather than guessed — the same discipline the
v2.63 version was written with, and worth repeating: the bundle at /public/static/assets/index-*.js
is gzip-compressed, so it has to go through gunzip before it can be grepped. Each path was then
confirmed against the live host by response code, which is the cheap way to tell a moved endpoint
from a bad password without holding a password: an endpoint that exists answers 401, one that does
not answers 404.
The source is discovered from GET /api/settings/sources — one configured source is used silently,
and several makes the script stop and list them rather than deploy the site into the wrong store.
FB_SOURCE overrides it; FB_OTP carries a two-factor code.
Verified by deploying with it, not by reading: 0.4.9f went up this way. This commit is the same file, byte-identical, brought across to the main line — both branches had carried the same broken script, so deploying 0.6.x would have failed in exactly the same way.
Housekeeping
dist-test/ removed — an untracked hand-made copy of a v0.6.2 dist/ build, referenced by no
script and no test. build-web.ts hardcodes dist and wipes it on every run, so nothing in the repo
could have produced that directory or would ever read it. .gitignore is deliberately unchanged:
the answer for a directory that should not exist is to delete it, not to hide it.
0.6.2 — 2026-08-22
Three more from the v0.4.9e gameplay-testing round, now filed as Gitea issues: #4 extras did not
start where the player said, #6 train cards could be discarded, #7 four train cards had the
wrong coach counts. Two further bugs were found underneath #4 and are fixed with it. Gitea#2 is
diagnosed but NOT fixed — it needs a ruling, and the reasoning is in TODO.md under Play Balance.
The same change ships as 0.4.9f on the 0.4.9 line.
Gitea#4 — an Extra starts where the player puts it
REPORTED: "When extras are played the player doing so may choose where the extra starts. They may choose either division point. And if the interchange mainline card has been played, they may start the extra on that card and choose the direction from there. If there is potential for conflict with other trains in that area the superintendent may hold the extra."
Only one Division Point was ever offered, chosen by the train's number parity, plus any Control Point. Confirmed against a live game before touching anything: a pending X17 offered exactly one placement.
The number no longer decides an Extra's direction — the START does. This supersedes a ruling
recorded in content.ts ("the number decides, like everything else on the timetable") and the two
cannot both hold: an odd, westbound Extra placed at the WEST end would leave the Division on its
first move having crossed nothing, and be paid the completion Revenue for the run. A Division Point
now runs the train away from itself; at an Interchange or a Control Point, where both ways are real
runs, the player says which. Timetabled trains are unchanged. The Extra cards were always printed
direction: 'playerChoice' and the engine had been overriding it; they now mean it.
The Interchange start is a YARD, not a spot on the running line. This is what makes §7's last
clause work. An Extra started there stands in the card's yard, off the road, and highballs onto the
card itself at a later Mainline Phase. Three things follow, all of them Jesse's rule: placing it can
never force a collision however busy the card is; a guaranteed collision holds it in the yard for
another Stage and it tries again; a potential collision is the Superintendent's to rule on. Those last
two are exactly evaluateClearance's blocked and ask, so the Extra leaves the yard through the
same §8.1 check a train leaves a Division Point through — nothing new decides collisions.
Where an Extra may start is now a setting (extraStart), because the Division Points and the
Interchange belong to nobody and a player's own district does not: divisionPointsOnly /
ownOffice / anyOffice, in the New Game dialog, defaulting to anyOffice — what the engine did
before the setting existed, so the 0.4.9 playtest line does not change under its testers mid-release.
A Whistle Post never qualifies at any setting.
atSeat on the intent is kept as a legacy field: absent start, it replays exactly as it always
meant, so a save written before the choice existed is unaffected.
Found underneath #4: an Extra started away from a Division Point ran empty
isBeingMadeUp tested the position alone — "standing at a Division Point" — which was the whole
truth while that was the only place to build a train. The Control Point start has therefore been
shipping since it was added with a train that could never be given a consist, and the Interchange
start would have shipped the same way, against a report that says an Extra started there "would be
Loaded with cars". A beingMadeUp flag now marks exactly those trays and is cleared the moment the
train starts running, rather than a second positional rule — a train that ARRIVED at an Office must
never be fillable from the Division Yard, which is the bug isBeingMadeUp was tightened to kill.
Found by playing it, not by the tests, which had only ever asserted where the tray landed.
Found underneath #4: a collision left the wreck on the card
collide deleted the trains from s.trays and left their Transit entries sitting on the Mainline
card they died on. evaluateClearance counts every transit as an occupant, so one rear-end
collision permanently poisoned that card: every later train was either held against a ghost or put
to the Superintendent about one. The only other place a transit is removed is a train rolling off the
far end, which a destroyed train never does. Found because the Interchange highball clears through
that same occupant list.
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/mainline-deck.md had flagged the mismatch
as needing correction; "an Extra may start at the Interchange if one is on the board" is what forced
it, since that only reads as a rule if the board holds at most one. Now dealt from MAINLINE_DECK
without replacement, verified over 1600 deals across 1–4 players.
This re-deals every seed, which retired the published replays (re-recorded) — and also, noticed
only after the fact, the saved games in docs/. Jesse's Gitea#2 repro now replays 250 of 323 intents
instead of reaching the reported position. Two saves there (seed493290760-day2, seed58228926-day6)
were already dead at 2 intents before any of this, retired by some earlier change and never noticed,
because the replay-fidelity test guards public/replays and nothing else.
Gitea#6 — a train card is never discarded
REPORTED: "Players are not allowed to discard Train cards. They may keep the card in their hand for multiple stages and even multiple days, but they may not discard it. If a player has three train cards in their hand, and they draw a fourth, then they must play one of those cards." Extras count.
The interesting property is that the forced play needed no mechanism. A train cannot be discarded,
and draw.end already refuses while the hand is over the limit, so a player holding four trains has
exactly one legal way to conclude the turn without anything ever computing "you must play a train".
The corner cannot trap anyone: playing a train card is unconditionally legal — card.play's train
case refuses only a board placement, and a card played into a full timetable still leaves the hand.
Confirmed in a running game: a hand of four trains offers zero discards, no draw.end, four plays.
The bot needed no rule either. legal.ts filters candidates through check, so the option stopped
being offered, and the developer bot already reaches for card.play before a discard. Over 400 games:
400/400 finished, revenue unmoved, trains scheduled 1.2 → 1.3 — the rule's intended effect.
The player is told, on the card and on the button. handDiscardable on the Frame marks what may
be shed; the hand panel says so in the train's own tooltip, and when every card held is a train the
blocked end-turn button changes its text. That is the Gitea#2 lesson applied early: a rule the player
cannot see is a board with nothing to click and no reason given.
Gitea#7 — coach counts on four train cards
1/2 Crack Limited 3 coaches → 2. 5/6 The Sparrow 2 → 3. A change to the cards, not a
transcription fix, so Trains3.pdf and the transcription in implications.md §5 keep the original
numbers with a footnote; content.ts and home-deck.md carry what the game
plays. Both consists remain inside the four-car Crew Tray limit.
A test had to follow: multiplayer.test.ts used Train 1 because it had three cars, to exercise a
placement round that wraps at two players. It now uses Train 5, which has the three coaches.
The content.ts comment pass
Asked for after #7 raised "where do we actually keep track of train cards?" — the answer being five documents of three vintages. No data changed; only comments. Four were factually wrong:
// Offices — Depot 4, Station 2, Terminal 1— the real copies are 8/4/2, doubled by Q12 long ago.- A doc pointed at
DEALT_DECK_SIZE, which has never existed; the symbol isSOLITAIRE_DECK_SIZE, defined eight lines below the comment that could not name it. - "the ten Mainline card types" — there are nine kinds; ten only holds if both Division Points count as one.
- "the developer bot averages 7.0 Revenue against a target of 20", in the present tense. 7.0 was
measured while
trainPerTransitstill defaulted to 1, and the next paragraph explains that setting was defaulted to 0 for being worth ~5.4 of it. Re-measured over 400 games at current defaults: about zero. Replaced with an instruction to run the harness rather than trust a number in a comment.
And every Enhancement row cited its implementation as file.ts:NNN, and every citation had
rotted — apply.ts:405 for Small Yard was pointing ~440 lines short, stale long before this
release. All eight now name functions, which do not move, with a note never to cite a line number
there. Swept src/: no others remain.
Card counts came out of the comments, Jesse's call — they move with play balance, so a comment that prints one is stale at the next retune. Kept: figures attributed to the recovered source sheet (a fixed document, and the audit trail for the transcription) and dated experimental findings.
Documentation queued, not built
TODO.md gains an item for a card reference generated from content.ts so it cannot disagree
with the game, in six sections — Mainline, then Home Deck Trains / Track / Industry / Modifiers /
PVP. Each card wants its name, effect, placement, and the part only the implementation knows: whether
its printed effect resolves yet. Deliberately no card counts. enhancementText() and
mainlineDescription() are the model.
Not in this release
Gitea#2 — the Sparrow arrives with two loaded coaches, two passengers wait, four porters stand
idle, and only one action can be taken. Reproduced and diagnosed: the engine is faithful to §9.2 and
§2.2 at every step, but both directions of porter work move coaches one-way into a Classification
Yard that returns only when the Division Yard is bare of all ~60 cars. Three ways out are written up
in TODO.md; the choice is Jesse's. The unambiguous half — impediments() skips passenger
facilities entirely, so a blocked platform gives the player no reason at all — is also still open.
0.6.1 — 2026-08-22
Six bugs came back from a gameplay-testing session on 0.4.9d. Five are fixed here; the sixth could not
be reproduced and is written up in TODO.md with the two questions that would pin it down. The same
change ships as 0.4.9e on the 0.4.9 line, cut from the v0.4.9d commit — the engine files the fixes
touch are identical across the two lines, so the patch applied cleanly both ways.
Two trains at one platform answered to one button
REPORTED: "Operating two trains in a station: the select button does not work. Regardless of which you pick, it is always one train, not the other."
It did not work because there was nothing for it to do. porter.board and porter.detrain carried
no tray at all — { type, at } and nothing else — so there was one "board passengers at (0,0)"
button however many trains were standing at the platform, and the reducer walked adOccupancy and
filled the first empty coach it met. The roster chip the player clicked chose which crew the board
DREW and nothing else. Two independent things were wrong at once:
checkasked whether SOME train at the Office had an empty coach, skipping any whose card refuses passenger work (refusesPassengers,refusesThisOffice). The reducer did not skip those. So with a Military train and an ordinary one at the same platform,checksaid yes on behalf of the ordinary one and the reducer boarded the Military.- The action list collapses identical labels, and "board passengers at (0,0)" describes both trains — the same trap that once ate a turnout's second rotation and a Department discard.
Both intents now carry an optional trayId, one function (passengerWork) resolves which train and
which coach for check, execute and the reducer alike, legal.ts enumerates one candidate per
train standing at the Office, and the label names it: "board passengers at (0,0) onto Train 9".
trayId is OPTIONAL for the reason switch.move's via is — intents are the canonical record every
save and undo replay against, and absent still means "the first eligible train".
The events carry trayId and coachIndex rather than leaving the reducer to find them again, which
is the lesson unloadBegan's carIndex already taught: a reducer that re-derives the target is a
second implementation of the rule, and it disagreed with the first.
A load could be made and broken without going anywhere
REPORTED, twice over: "Freight House: boxcars loaded cannot be immediately unloaded. In the game we'll put the chip upside down in the tray to indicate." And: "Passenger stations: passengers just boarded cannot be immediately unloaded."
They could. A Freight House permits both directions, so the boxcar its own Laborers had just loaded
was standing on its own industry track, loaded, with an empty of that type in the Division Yard and a
free red box — every gate said yes. Passengers were worse: porter.board filled a coach and
porter.detrain looked for "a loaded coach on a train at the Office", which is the coach that had
just been filled. Full Revenue at both ends of a movement that never happened, for one Porter action.
Jesse's rule, and it is wider than the report: freight or passengers loaded anywhere in an Office
Area may not be unloaded anywhere in that same Office Area — not at another facility, not in a later
Stage. A train has to carry them to a different district. So a load carries a stamp naming the SEAT
that made it (RollingStock.origin), and the stamp never expires; laborer.beginUnload and
porter.detrain refuse a car stamped with the district they are standing in, with a rejection code of
its own (LOADED_IN_THIS_DISTRICT) because "the car is loaded, the Laborer is free, the boxes are
clear, and the only thing wrong is where it came from" deserves better than "wrong car".
A seat, not a player, because Employee Rotation moves players between chairs and the district
stays with the chair. Undefined, not −1, for "no origin": the Division Yard opens with loaded cars
and loaded coaches out of the common supply, and those are exactly the inbound traffic a solitaire
district lives on — a sentinel inside SeatIndex's own range is not a sentinel. And pooled strips
the stamp at every yard push, because the stamp belongs to the LOAD: a train can retire at a Division
Point with freight still aboard, and that car must not carry a district it left three Days ago into
whatever train is made up from it next.
Measured: −0.60 ± 0.10 Revenue a game (t = −6.1) over 400 paired deals — 78 deals worse, 3 better, 319 unchanged. That shape is the point. This is not a nerf spread across the game; it is a narrow piece of free Revenue coming off the board, and on four deals in five the bot never took it.
The screen's version of the upside-down chip: a car or coach loaded by this district reads "loaded boxcar (loaded here)" on the card, in the tray and in the facility panel.
The Grocer's Warehouse shipped, and the Refinery received
REPORTED: "Grocer's warehouse should be receive only, does not ship anything out." And: "Refinery: only ships out tanks, does not receive anything."
Both were flow: 'both' in content.ts, put there deliberately and for a reason that has since
collapsed. card-reference.md read: "'Freight House' is not a card. It is the collective term for a
freight facility that loads and unloads — the Grocer's Warehouse and the Oil Refinery." If that were
true, §9.3's "Passenger Facilities and Freight Houses permit cars to move each direction" named
exactly those two, and they had to be two-way. But the engine has dealt a freightHouse card since
before v0.4.9 — 6 copies, one slot each direction — so §9.3 names it, and the argument evaporates.
The card set says the same thing without needing the rules text. All three Refinery modifiers —
Pipelines, Oil Depot, Viscosity Breakers — grant +1 outbound; a two-way Refinery would be the only
industry in the game with no card able to raise one of its two directions.
home-deck.md prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
Warehouse · Inbound · 0 out / 1 in".
So the Refinery ships and the Grocer's receives, and the Freight House is the one two-way industry — which also means the only same-district load-and-unload the district rule above has to refuse is a Freight House unloading its own work. The two fixes meet exactly where the report said they would.
A consequence worth naming rather than discovering: an Ice House beside a Grocer's Warehouse is now
a dead card, its +1 outbound dropped on a direction the host does not have. That is the design, not
an oversight — the Home Deck sheet says so outright, and names the Truck Dock's inbound grant beside
the outbound-only Packing Sheds as the other example. suppressedGrants already reports it on the
page. It does mean the v0.4.7 note in TODO.md that opened these two facilities up was half wrong,
and it is annotated there rather than deleted: the machinery it built (an industry's printed flow is
absolute; drop the grant, never open the direction) is exactly what makes this correction land.
Not reproduced: cars left behind when backing up over them
REPORTED: "When I back up to collect standing cars and, further down the tracks, the caboose, I get the caboose but the cars remain. I can later drive right through them."
Not found, and not for want of looking. Cars on plain track on the way; cars spotted at an INDUSTRY on
the way (Jesse's own guess at the shape); the train's own cut on the square it is pulling out of; a
stale standingWest; an industry locked by MEN AT WORK. Every one couples the lot, and the last
correctly blocks the whole route rather than letting the crew past. Three of them are now pinned in
apply.test.ts so the case, when it is found, is somewhere none of them cover.
The reason it is hard to make happen is structural: coupling is mandatory (§A.4) and exploreMoves
accumulates what it meets card by card, so a route that reaches the caboose has already met everything
between. carsOn is the single answer to "what is standing here", and the movement walk, the sweep in
carsCoupled and every renderer all ask it — so cars a train can drive through would have to be cars
that are on screen and not in carsOn, and there is no such place.
There was one way to MAKE such a place, and it is closed: flyingSwitch's reducer wrote the cut
straight into industryTrack, which is not where carsOn looks on a Passenger Facility. check
refuses a non-freight target so it never fired, but a trap that needs another rule to stay unsprung is
still a trap; it goes through carsOn now.
TODO.md carries the two questions that would settle it: was there a second route to the caboose, and
what did the move button say it would couple. The label names every car, so "couples caboose" and
"couples 2 boxcars, caboose" are different bugs — the first is route selection, the second the sweep.
Also
- An unload never checks the facility's commodity — found reading
laborer.beginUnloadfor the district rule, not from play. It gates onallows.inbound, a loaded car, a matching empty in the yard and room in the red box, but never onfacilityCarTypes, whichfreightAgent.stockOutbounddoes check. So a Freight House will unload a hopper. Left alone and logged: the district rule now refuses the one same-Office pairing that made it easy to reach, and the fix is a rule question about what an industry will accept, not a one-line guard. - Both published replays that had gone dead were re-recorded (
save-replay.ts 400 --top 3). A save is a save from a particular ruleset, so a rules change retires the files that no longer replay —harness.test.tscatches it, which is what that test is for.
0.6.0 — 2026-08-21
Three queued items, and the last of them is the one that matters most.
A release no longer destroys every game in progress
Four consecutive releases killed every game on the box — one of them a release that changed only
how the board is drawn. The reasoning behind the refusal was always right: a move that was legal
under the old rules may not be under the new ones, and half-replaying a save is worse than refusing
it. The test was wrong. It compared engineVersion for exact equality, and that stamp is the
package version, which moves for a CSS fix.
Whether a save still replays has an exact answer, so it is now asked directly. loadGame reads the
file and judges nothing; tryResumeSession replays the intents and reports the first one the engine
refuses, if any. A save stamped with a version this server has never run resumes fine, provided its
moves replay — verified against a file hand-stamped 0.4.9-ancient. One that genuinely does not
replay is still refused, but the log now names the move rather than two version strings: "move 3 of
8 (localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN".
fromMultiplayerSave had to stop lying first. It has always stopped at the first unacceptable
intent and done so in silence, which was survivable only because the version gate meant a doomed
replay was never attempted. Now that the replay is the check, it returns where it stopped and why.
Deliberately not done: resuming a partly-replayable game at its last good move. That silently rewinds a game to a position nobody played to, while every browser holding a later Frame carries on unaware. Refusing leaves the file intact, so putting the previous version back still recovers it.
Employee Rotation is real, and Sister Trains is gone
Two of the four optional-rule flags were read by nothing at all.
Employee Rotation is implemented — "at the end of the day, all players move one chair to the
left and take over the next station up the line. Take your points (and the Fedora) with you." It is
four lines in advance.ts, because the seat/player split (D9) exists for precisely this rule:
seating is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is
travel with the player for free, and the Office, district, grid and any trains standing in it stay
with the chair. Inheriting the state of the district you move into is the point of the rule, not a
side effect. "Left" is seat + 1, matching playerLeftOf.
Sister Trains is deleted rather than implemented. Q9 records that the Second Section card supersedes it — and that card is built — so the flag was a toggle for a rule the game no longer has.
The lobby asks what game you want to play
Creating a game asked for a display name, a mode and a table size; every other dial came from
defaultMultiplayerConfig, hardcoded. A Game settings block now carries the same set the
solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue
floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules.
Mode and table size set the defaults and everything stays editable, exactly as the solitaire dialog
already behaved.
The seed is honoured: name one and the table deals that railroad, so a game can be reproduced or compared.
0.5.6 — 2026-08-21
Three things off the first proper look at a live table.
Seats are counted from 1
The lobby listed chairs as Seat 0 to Seat 3. Zero-based is right inside — it indexes seating,
the seats array and every route, and none of that changes — but nobody sitting down at a table
calls their chair "seat 0". The displayed number is now the one a player would say out loud.
There were four of these, not one: the lobby list, the topline's Seat N for a remote session, the
presence banner's fallback name, and the admin summary's. All go through a single seatLabel, and
a test fails the build if any Seat ${…} interpolates a raw seat again — the conversion has to
happen at exactly one place or the two conventions drift. (The StartOS package's Games in
Progress action had the same leak and is fixed alongside.)
seatLabel lives in view.ts rather than web/game.ts, because the page may not import values
from that module — they are the local engine by another name, and test/session.test.ts fails the
build for it. Putting it there was the first attempt; the test was right and the placement was
wrong.
The current player's name was unreadable
.bs-name.bs-turn carried font-weight:700 over a base of 600. At 11px a monospace face has to be
synthesised the rest of the way, and the extra ink lands as blur rather than as weight — so the one
name you most need to read was the one you could not. The weight bump is gone; amber against
#e6e9ee was always doing the work, and blue "(you)" and amber "their move" stay clearly distinct
without it.
The seating chain says its piece once
The west-to-east line under the Division map explains who is where and why, which is a question you have once — at the start, when the chain has just been rolled and the names are new. It now shows only during Day 1 Stage 1. By Stage 2 the map itself has been answering it for a while, and a permanent line restating it is a permanent line to read past.
0.5.5 — 2026-08-21
One bug, found by updating to v0.5.4 and clicking Multiplayer: the page went straight into a game with no lobby and no controls, and the board was blank.
A remembered session for a game the server no longer has
Three things lined up. start() enters a remembered multiplayer session without checking it
still exists — that is what makes reconnection seamless, and it is why the lobby was skipped.
The v0.5.4 update had refused to resume that game, because the save was recorded under v0.5.3
and the engine-version check is exact (D7). And createRemoteSession had no onerror at all,
so EventSource retried the resulting 404 forever, in silence, while frame stayed null and the
page rendered nothing.
The only escape was clearing site data, and nothing on screen said so.
The same dead end had just been widened by v0.5.3's Manage Game → End, which closes every watcher's stream: a player whose game an administrator ended would sit frozen on a stale board indefinitely, for the same reason.
The fix. GET /api/session?token=… is new — a cheap yes/no on whether a token still names a
live game. EventSource fires error identically for a transient blip (the expected shape of a
game idle for minutes, §9) and for a 404 it will retry forever, and exposes no status code either
way, so the client asks. Only a definite 404 closes the stream and reports the game gone; a flaky
network still self-heals as before.
The page then forgets the stored session, says why — ended by an administrator, or the service was updated, which does not carry games across — and drops into the lobby. Forgetting the token is what stops the next load repeating it.
It also stops rendering nothing while it waits: "… connecting to the game" sits in the presence banner until the first push arrives, because a page showing nothing is indistinguishable from a page that is broken, which is precisely what this looked like.
Recorded, not fixed
TODO.md now carries the underlying problem: three releases in a row destroyed every game in
progress, and v0.5.4's changes were rendering only. The refusal is right — a move legal under old
rules may not be legal under new ones — but the test is exact equality against the package
version, which moves for reasons that have nothing to do with the rules. Three options are costed
there; the recommendation is to replay the save and refuse only if an intent actually rejects,
since that answers the real question rather than a proxy for it, and a full replay measures ~100 ms.
0.5.4 — 2026-08-21
Six things found by playing the StartOS build, all of them about the game telling you what it already knows.
A disabled button that did not look disabled
Reported as "the Start button is enabled when it says it is waiting for a player". It was not — the
note and the disabled assignment are two lines apart in the same block, so a lobby waiting on a
chair had a genuinely disabled button. The page had only two :disabled rules, header button and
#actions button, and #lb-start is in neither, so it kept its normal face and still lit up
under the cursor from the generic button:hover. It was advertising a click it would refuse. The
rule is generic now.
The game code is the invitation
It was rendered as — code TRESTLE-5109 beside the "Seating" heading, in dim text, reading like a
reference number rather than the thing you have to send someone. It is now a labelled block —
"Send this code to your players" — at 22px, with a Copy button beside it. Clipboard access is
unavailable on an insecure origin and can be refused outright, so a failure says the code can be
selected instead of silently doing nothing.
The blurb under it was also wrong: it said the chairs were "West to East, in the order everyone joined", which has not been true since v0.4.1. §4.4's D12 decides, at start, and the lobby now says so rather than claiming the opposite.
The Division map names its districts
Every Office was labelled with its tier, which every other player's Office also has, so four districts read identically and "where does Bob sit?" had no answer on the only map that shows where trains are. The owner's name takes the headline and the tier moves down beside the A/D count, because the name is what is being looked for and the tier is what it is called once found.
Two marks on top of that: amber for whose move it is, the same "it is happening here" the action panel uses, and "(you)" spelled out on the reader's own district. Colour alone cannot say which of four railroads is yours, and that is the first thing you want at a table you have just sat down at. Where both apply, the turn colour wins — whose turn it is changes every few seconds and which railroad is yours never does.
Underneath the map, the chain in words with the roll that decided it: West to East: Alice (1) →
Bot 2 (5) → Bot 1 (11). That is what state.openingRolls has been kept for since v0.4.1 and
nothing had yet displayed — and it answers "is the host always at the eastern end" outright. No:
Alice there is the host, rolled lowest, and sits at the western end.
Supporting changes
Frame gained viewer and viewerSeat. Every private field on it was already scoped to one
player — hand, Office Area, revenue, option, movesLeft — but nothing said which player, so a
page rendering a Frame could draw a railroad without being able to say whose it was. Harmless in
solitaire; the first question at four seats. It also gained openingRolls.
Bots are named Bot 1, Bot 2 rather than all being Bot: two of them at one table are two
different railroads, and a map labelling both the same cannot say which is which.
The standalone replay gets all of this too — players, actor and viewer are not among the
delta'd keys in compress, so they ride whole on every frame and replay.ts passes the same
roster the live page does.
0.5.3 — 2026-08-21
Everything a StartOS administrator needs to see and manage a server full of games, plus the seat control that came out of the first real multiplayer session.
The host picks the table size, and a gap stops being expressible
The seats array used to GROW as people joined, which made the four rows on screen partly fiction:
a 2-player game just started with a 2-long array, while a host who dropped a bot into a later chair
padded the array with a null and silently disabled Start behind a one-line note. The host now
chooses 2, 3 or 4 when creating the game and the array is built at that length once. A gap cannot
be written down rather than merely being refused.
That also removed a trap nobody had sprung yet. Compacting seats at Lobby.Start — the obvious way
to support a "closed" chair — would have shifted the player index that every PlayerSession
stamps at join time and that /api/stream and /api/intent both route by, handing a player
somebody else's railroad without an error anywhere.
And it fixed a live balance bug. minCombinedRevenue is derived from the player count, but the
config was fixed at CREATE while the count was not known until START, so the lobby guessed 4. Every
2-player game was playing against a floor of 60 instead of 30 — and missing the floor means
everyone loses, so a 2-player competitive game was set up to fail for a reason that was a UI
artifact rather than a rule. The real count now reaches defaultMultiplayerConfig.
Administration: what is running, and how to end it
/api/health gained games: { active, lobby }, which is what the StartOS package's health check
reports as "3 games in progress, 1 waiting to start". It reads summary() — a new, cheap
GameSession accessor — rather than exportSave(), which would copy every intent of every game to
answer a question about none of them.
Three administrative routes are new, gated by an ADMIN_SECRET env var in an x-admin-secret
header: GET /api/games (every game and lobby, summarised — players, names, started-at,
last-move-at, Day/Stage/phase, and who it waits on), GET /api/games/<id>/save, and
DELETE /api/games/<id>. Until this, a started game could not be ended by anybody: no route, no
player action, no resignation. An abandoned game stayed active in the index and was faithfully
resumed on every boot, forever.
Three deliberate choices in that:
- The admin secret is not the join secret. Every player holds the join secret, so gating a delete with it would let anyone at the table destroy anyone else's game.
- Unset means the routes are not there — 404, the same answer as any unknown path, with or without a header. A server never given an administrator does not advertise that it has one.
- A delete returns the deleted game's save. The intents are the game (D5), so that is the whole thing and not a summary: nothing is destroyed without being handed to whoever destroyed it.
SavedGame gained lastMoveAt so "has this stalled?" survives a restart. It is optional and falls
back to createdAt, and it is kept out of history for the same reason the turn timings are — a
replay must reproduce a game from decisions alone, and wall-clock is not a decision.
Boot
Resuming N saved games… is logged before the replay loop rather than one line per game after
it, so the pause before the port opens has a reason on screen while it is happening. Measured at
100 ms for a full 4-player game, and only unfinished games are replayed — so the pause is
tenths of a second in practice, and listening before loading would have bought nothing for the cost
of a "still loading" state on every route.
0.5.2 — 2026-08-21
Found packaging Phase 6 for StartOS: the splash's "Play multiplayer" door had sat disabled,
labelled "Coming soon," since before the server existed — Phases 2 through 4 built a working
lobby and nobody ever pointed a link at it. Loading the site landed on the exact same solitaire
splash whether a real multiplayer server was behind it or not, with no visible way in.
index.html's door is now a real link to ./play.html?lobby, matching the other two doors.
main.ts's start() checks for ?lobby and routes straight into the lobby screen — the same
showScreen('lobby'); runLobby(beginRemote) the in-game Multiplayer button already used — instead
of dealing a solitaire game first and leaving the player to find that button themselves.
The page can now tell whether a server is behind it
The same dist/ ships two ways — served by src/server/, or uploaded as flat files by
scripts/deploy-web.ts with no server at all — and the bundle is byte-identical in both, because
there is one client and the mode is decided at runtime (D4). So the splash could not know from its
own build which it was, and nothing else distinguished them either: every route in http.ts
answers a 404 for a path it does not have, exactly as a static host does.
GET /api/health is new, and exists to be failed: { ok, service, engineVersion }, no
authentication (it says only that a Station Master server is answering, which is what the door is
about to offer anyway — no game, no seat). The splash probes it on load and closes the door when
nothing names itself in reply.
The door starts open and only ever closes, deliberately. A wrong "no server here" is the bug above all over again — invisible, and it strands a player who does have a server. A wrong "there is one" 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. The reply has to name itself rather than merely return 200, since a host that answers every path with its own index page would otherwise pass.
Bug fix: the lobby machinery was already complete and tested (Phase 4, v0.5.1); this only re-enables the door to it, and teaches the splash when to.
Worth recording about the tests: the first version of the probe's test passed with the naming
check deleted outright. Both of its "door closes" cases happened to reach close() through the
.catch arm, so the branch that actually reads the body was never run — and the comment claimed
otherwise. Caught by mutating splash.ts and re-running rather than by reading it.
0.5.1 — 2026-08-21
Multiplayer Phase 4 — lobby, sessions, reconnection (docs/architecture/multiplayer.md §12 steps
17-20, fully specified in docs/architecture/lobby-and-sessions.md). Phases 0-3 shipped in v0.4.0
and v0.5.0; a real server existed but nobody could reach it without a hand-built URL. This is what
makes it a game you can actually create or join.
The server hosts more than one game, and knows who you are across a reconnect
src/server/lobby.ts is new: pure logic, no sockets, no filesystem, the same split session.ts
draws for a running game. createLobby/joinLobby/setBotSeat/reassignHost/startLobby, plus a
speakable game code (RAIL-4471 style) and the player cap.
persistence.ts gained one directory per gameId and a top-level index, so index.ts resumes
every saved game on boot, not just one. /api/stream and /api/intent now authenticate by session
token instead of ?seat=&secret= — lobby-and-sessions.md §1: the token alone proves identity,
so the join secret's job ends at the lobby door (/api/lobby/create//api/lobby/join).
Bots fill empty seats, never take over a disconnected human (D8)
session.ts gained driveBots() — after any accepted intent, and once at construction for a
resume that lands exactly on a bot's turn, it plays developerBot forward through every
consecutive bot seat before the push goes out. Reuses legalActions/developerBot wholesale.
SavedGame gained botSeats so a bot seat survives a restart. Bots are assigned once, at
Lobby.Start, and never afterward — a disconnected human's seat waits, exactly as before.
Disconnect keeps the seat and says so; reconnect gets a full view, not a tail
Push gained an optional presence field — connection news about another seat, built entirely by
http.ts (which owns the connection table) and never routed through session.ts or the engine: a
disconnect is transport news, not a GameEvent, and the engine must stay replayable from a seed.
The page shows a small banner naming who has dropped and clears it the moment they reconnect.
Host rights pass to the earliest-joined remaining player if the host's own connection drops before
Lobby.Start — tracked by join order rather than seat, since a bot-filled seat never joined at all.
The client: an actual lobby, not a URL you hand-build
src/web/lobby.ts, wired from main.ts: create-or-join forms, a live seating screen (host-only bot
toggles and Start button, updated over its own SSE stream), and localStorage in place of ?seat=
for "was I already in a game" — found on load, it reconnects straight through and skips the lobby
screen entirely. A Multiplayer button sits beside New game; the New Game dialog itself is
untouched and still solitaire-only, its old "needs a server" note repointed at the new button.
Found only by the live smoke test, not by typechecking
/api/intent read its token from the JSON body; web/session.ts's submit() — unchanged since
Phase 2 — sends it in the query string, the same as /api/stream. Every intent failed no such game. Both sides typecheck cleanly on their own (an HTTP body is unknown on the wire), which is
exactly the gap a curl-level smoke test exists to catch: create a lobby, join a second player,
start, submit from both seats (including a wrong-actor rejection and an idempotent resend),
kill and restart the server and reconnect both tokens, start a bot-filled coop lobby and confirm it
never stalls waiting on the bot, and watch a live stream receive a disconnect/reconnect presence
notice for another seat.
Doc fix
multiplayer.md's decision table (D18) said the player cap was 6; lobby-and-sessions.md §2 —
more detailed, and what test/multiplayer.test.ts actually exercises — says 2-4 with the reasoning
for it. The two had quietly drifted apart; "6" was never implemented or tested anywhere. D18 now
reads 2-4.
Not verified: an actual browser walking through the lobby screens — none is available in this
environment, the same limitation Phase 2's RemoteSession shipped under. 656 tests, 0 failures.
0.5.0 — 2026-08-21
Multiplayer Phases 2 and 3 (docs/architecture/multiplayer.md §12): a real server exists now, one
game at a time, and it survives being restarted mid-game. Phases 0 and 1 shipped in v0.4.0; Phases
4–6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Also
folds in the three playtest fixes already released as v0.4.9b/c/d on the patch line, plus two rules
bugs and a victory-condition redesign found along the way.
Phase 2 — server core, one game, no lobby
src/server/session.ts— the game session host: pure logic, no sockets, built entirely ongame.ts's existingGame/submit/currentActor/actionMenurather than re-deriving intent application or narration. Found while building it:submit()derives the acting player fromcurrentActor(game)and never checks who is actually calling it — harmless forLocalSession(only one possible caller) but not safe for a server, so the session host now verifiesseat === currentActor(game)itself before callingsubmit, rejecting withNOT_YOUR_TURNotherwise. Idempotent resend (a repeatedseq) and the illegal-intent path (checked viacheck()directly, so a rejection never pollutes the shared narration log with text meant only for the submitter) are both handled here.src/server/http.ts/src/server/index.ts— plainnode:http, no framework:POST /api/game,GET /api/stream(SSE, per-seat, 20s heartbeat,id:line per push),POST /api/intent, and static serving ofdist/so the server is same-origin with itself.src/sim/frame-delta.ts— the live per-seat board delta (deltaFrame/applyDelta), a smaller replacement purpose-built for a single live push rather than reusingreplay.ts'scompress(), which interns strings across a whole recorded array with nothing here to intern against; only its one-step-back "null if unchanged" idea carried over.- Found and fixed:
actionMenu(game, seat)only usedseatfor thehandfield — everything else came fromcurrentActor(game)regardless of who asked, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat, paired with the wrong seat's cards. Fixed with a guard ingame.ts; tested inmultiplayer.test.ts. - The redaction test (§7,
test/redaction.test.ts) passed on the first run against the existingsnapshot(), confirming it was already correct rather than just apparently so. src/web/session.tsgainedcreateRemoteSession;main.ts'sstart()switches on?seat=presence (one bundle, unchanged). EveryLocalSession-only call site inmain.tsnow goes through anisLocal()type guard instead of assuming.- Verified two ways:
test/server/session.test.tsexercises the session host directly, and a live end-to-end smoke test (server started, a 2-player game created, two SSE streams opened, an intent rejected from the non-acting seat, accepted from the acting seat and broadcast to both, a resentseqproducing no second push, the board correctly nulled on the second push). Not verified: an actual browser — no browser binary in this environment, soRemoteSession's DOM-facing code compiled and typechecks but was never clicked through visually.
Phase 3 — persistence and resumption
src/server/persistence.ts—game.json({engineVersion, seed, config, playerNames, history, status, createdAt}) andturn-timings.json, both atomic-rewrite-then-rename.game.tsgainedfromMultiplayerSave,fromSave's multi-player sibling. Found while testing it:fromSave's replay loop callsrecord(game, result.events)without theactorargumentsubmit()always passes, so every replayed line loses its "Player X" attribution — invisible for solitaire, immediately visible for multiplayer, where anonymous "Chose to…" lines are unreadable the moment there is more than one seat. Fixed in the new function;fromSaveitself still has the gap, deliberately untouched here since it's used far more widely (undo, save/restore, the replay viewer) and deserves its own pass.session.tsgainedexportSave(),resumeSession(), and turn-timing tracking — a span (player, phase, day, stage, start/end wall-clock) that closes and reopens whenever the acting player, phase, Day or Stage changes, recorded entirely in the session host and never insidehistory(a replay must reproduce a game from decisions alone).index.tsloadsgame.jsonon boot before starting the listener: version match → resumed and replayed straight through; mismatch → refused explicitly and loudly, file left untouched, server starts with no active game rather than replaying under the wrong rules.- Verified live: server started against a fresh data directory, a 2-player game created, intents
submitted from both seats, the server process killed and restarted, both
?seat=streams reconnected and picked up exactly where they left off — same Day/Stage/phase, correct whose-turn, correct narration attribution. Separately confirmed the version-mismatch path with a hand-editedengineVersion. - Found and fixed an infrastructure bug along the way: adding
test/server/brokenpm test's glob."test": "node --test test/**/*.test.ts"relied on the shell passing the literal, unexpanded pattern through whenever it matched no files at the shell level — the moment a subdirectory existed, the shell expanded it to just that one file, andnpm testsilently ran only the new suite. Fixed by listing both depths explicitly.
Two rules bugs found while building this
- New Train phase car-placement is one player's job even in competitive mode — it should be a
round. §7 is explicit: starting with the Superintendent and working left, each player places one
car, and the round repeats until the consist is full.
newTrainPhasenever implemented the round —enterPhaseresetsactorOffsetto 0 on entry and nothing ever incremented it the way Local Ops does — so the actor was always the Superintendent alone, for every car of every train, in every mode. Fixed by reading the round position offtray.consist.length, which already counts placements toward that tray and resets per train with no new state needed. - Victory conditions unified across solitaire, competitive and coop. One shared, configurable
GameConfigset (days,minCombinedRevenue,maxCollisionsPerDay,maxCollisionsTotal,pvpCardsAllowed) replaces the old fixedLENGTH_PROFILES.target, a deadfirstToTargetvictory condition, and a flat collision-floor constant. Collision caps stay flat rather than scaling with player count — Jesse's call: more players means more independent chances to collide, not a bigger shared budget, so multiplayer is deliberately riskier than solitaire at the same default. One New Game dialog now covers all three modes, with fields greyed out wherever a mode forces a value.
The three v0.4.9b/c/d playtest fixes, folded in
Already shipped on the patch line — see 0.4.9d below for the full writeup of each. Summarized: a
switching train's crew badge failed to draw once it left the Office square (display only, game state
was never affected); unloading a freight car always took the westmost one regardless of which car
was picked; and a legal decision (newTrain.startExtra) could render with zero buttons, which was
indistinguishable from a hang. 635 tests, 0 failures.
0.4.9d — 2026-08-21
Three bugs from the same playtest session, patched directly onto 0.4.9a rather than the in-progress 0.5.0 line: a display bug that made a switching train look like it had vanished, an engine bug that silently ignored which car the player chose to unload, and a UI bug that could render a legal decision with zero buttons — indistinguishable from a hang.
A switching train vanished from the board the moment it left the Office
REPORTED: Train 3 stood at the Office; dropping the loaded boxcar off the back at the Freight
House made the train "vanish off the face of the earth," regardless of which way it moved
afterward. The game state was never wrong — replaying the reported save
(docs/station-master-seed116956197-day2.json) through the engine directly showed tray3 fully
intact, in the right place, with the right consist, for the rest of the game. Nothing was ever lost
from state.trays.
The bug was in officeSvg (board-svg.ts): selectedTrain — the value that decides whether a
card draws a train's crew badge (its label, engine arrow, and cars) — was assigned only inside the
cell.adTracks !== null branch, which is true for the Office card and nothing else. A train
standing on any other card, which is every card it stands on while actually being switched, got no
badge at all. It disappeared on the very first move off the Office, not only at the Freight House —
that was simply the first place a player was likely to pause and look. Traced to the Roster Pass
(37b1e5b), which rewrote CellView.train into CellView.trains[] to show every train sharing the
Office's A/D tracks and, in doing so, nested the general consist-drawing logic inside that
Office-only branch instead of leaving it to run for any card with a train on it.
Fixed by hoisting the selectedTrain assignment out of the adTracks !== null guard so it
runs for every card; the A/D roster-chip loop itself stays inside the guard, since only the Office
has more than one A/D track to draw chips for. New test: a train parked on an ordinary facility
card, adTracks: null, now has to draw its crew badge — it did not before this fix.
Unloading always took the westmost car, whichever one was picked
REPORTED: three loaded cars stood at a Freight House; asking to unload the east (right-hand) car unloaded the west one instead, every time, no matter which car was actually chosen.
laborer.beginUnload already carried the player's choice as carIndex, and check() validated
that specific car — but the event it produced, unloadBegan, carried only the car's carType, not
which one it was. The reducer that actually performs the swap re-derived the target itself with
industryTrack.cars.findIndex(c => c.loaded), which always answers the first loaded car in track
order regardless of what was requested — so the player's choice was thrown away between check and
the reducer, and the westmost loaded car came off every time.
Fixed by adding carIndex to the unloadBegan event and using it directly in the reducer
(src/engine/apply.ts, case 'unloadBegan'), instead of re-deriving a car by scanning for
loaded. New test in apply.test.ts: three loaded cars of three different types, asking to unload
index 2 (east) now leaves indices 0 and 1 alone and empties exactly the one requested — it did not
before this fix.
A legal decision could render with zero buttons — the game looked hung
REPORTED: working the Freight House mid-cycle (unloading three cars, then reloading them), the game "seemed to have hung up. Cannot advance." No trains were on the Division or the Limits.
Replaying the reported save turned up no engine deadlock at any point — every step had a legal
action — but the exact final state (an Extra train due to start, no tray yet being made up) exposed
a UI bug: newTrain.startExtra's action group is titled by trainCardTitle, which begins "Making
up Extra X22…" — the same prefix renderActions() (main.ts) used to strip from the action list on
the assumption it always belonged to the separate yard-chip car-placement panel. With no tray being
filled yet, that panel (menu.makeUp) is null, so nothing else rendered the Extra's "choose where
it starts" decision either — a legal, present, correctly-computed option with no button anywhere on
the page. Clicking nothing did nothing, which is indistinguishable from a hang.
Fixed by matching the exact title of the one group menu.makeUp actually covers
(g.title !== menu.makeUp?.title) instead of a title-prefix regex, so any other group that happens
to start "Making up …" — however it got that title — stays on screen. The existing softlock
regression test (never strands the player with a legal move and no way to make it) now mirrors the
real render filter instead of only checking the raw menu, so this class of bug fails that test
directly; a new deterministic test constructs the exact reported scenario (a pending Extra, no
active make-up) and asserts its group survives the filter.
590 tests, 0 failures.
0.4.9a — 2026-08-20
Four small tweaks to the splash page (index.html, splash.ts), no engine changes.
Box art opens full size on click
The thumbnail beside the title was too small to read. Clicking (or Enter/Space on keyboard focus) now opens it in a full-size lightbox overlay; click the overlay or press Escape to close.
A third card balances the bottom row
The doors row was nested beside the box art, two cards wide, leaving the width under the box art unused. Moved to a full-width row below the hero (three columns at roughly the same card width as before) and added Play multiplayer, modeled on the Play Solitaire card but describing the not-yet-built feature, with "Coming soon" in place of a link. Its text is shorter than Play Solitaire's so the row height — and the page's vertical size — doesn't change.
Footer's "runs in your browser" claim scoped to solitaire
Now reads "solitaire runs entirely in your browser" rather than an unqualified claim that would no longer hold once multiplayer needs a server.
0.4.9 — 2026-08-19
A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the first real audio beyond a placeholder. Six changes: one display fix, two rules corrections, one engine bug found while explaining a report rather than reported directly, three new sound cues, and the splash page's box art.
Office coordinates read X,Y now, not Y,X
Reported as confusing for anyone reading them the ordinary way: "at game start the office is 0,0;
the western limit should be -1,0, the eastern +1,0; a restaurant due north should be 0,1." The
engine's internal GridCoord{row,col} already has exactly that geometry — row increases north, col
increases east (track.ts:59) — so this was a display-only fix, not a data-model change: every
place a coordinate is shown to a player now prints (col,row) instead of (row,col). The on-card
label in board-svg.ts (the one everyone sees), the switching-crew tooltip and button label in
main.ts, the rejected-option and switching-group text in game.ts and view.ts, and the
blocked-move text in narrate.ts. Internal Map keys built from ${row},${col} are untouched —
nothing reads them but lookup code.
"No switching" now means no adding or dropping cars — not "never touch it"
Reported from play: a train sitting at the Office with NO SWITCHING printed on its card (the two
expresses, Light Engine, Campaign, Circus, Military) could not be moved onto Secondary Track to
clear the mainline for other traffic, even though nothing on the card says that. switchingRefusal()
(apply.ts) blocked switch.move outright for these six cards; it now only refuses a move that
would COUPLE a fresh car — the same way rules.dropOnly was already handled for X13 — so the crew
can still be shunted clear, it just cannot pick up, set out or sort. switch.dropCars and
switch.sortConsist stay blocked. The switching-moves list (view.ts) no longer excludes these
trains, and the card description text was rewritten from "it runs the Division and does not shunt"
to "may not add or drop cars, but may still be moved clear of the mainline."
Q3, corrected: Expedite is about WHERE a train may be left, not WHEN it leaves
The reported symptom: Train 1 arrived at an Office and was forced out at the end of the same Stage — the read since v0.4.2 — leaving no Local Operations turn to switch it. The rule question underneath it: why should "Expedite" mean a train is rushed out early at all, rather than simply never being left parked off the station?
Corrected to the latter. An expedited train now arrives and stands exactly like any other train,
released by the ordinary §8.1 "stood a full Stage at the Office" rule — it can be switched normally
in between. Deleted entirely: arriveAtOffice's departsThisStage flag and the whole
expedited-departures pass that used to run in shiftChange, plus the now-dead pass: 'mainline' | 'expedite' parameter on moveTrain.
In its place: a new fault. At the top of every Mainline Phase, any expedited train sitting on the
grid but not on the Office square itself — left on Secondary Track to clear a switching move, say —
costs 1 Revenue (EXPEDITE_FAULT_PENALTY, expediteFault), charged again every Phase it is still
caught there. A train the ordinary rules are legitimately holding AT the station — even one §8.1 is
asking the Superintendent to rule on — is not a fault; only being left elsewhere in the district is.
This resolves "3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE" (TODO.md) as a side effect: the
Express can now actually reach a Local Operations turn to use its printed "may drop or pick up one
freight car at every location," which the old same-stage departure made structurally impossible.
Measured (30 fresh bot games, standard length): the fault fires, but the bot has no strategy for
it yet — one game left Train 4 (3/4 Express) parked off the station from Day 3 Stage 10 to the end of
the game, taking the -1 penalty 26 times. Not an engine bug: the mechanism is working as designed and
correctly reveals that the bot doesn't know to bring an expedited train home before ending a
switching turn. Logged in TODO.md under Bot Performance for a future pass.
evaluateClearance now checks every occupant, not just the first one it finds
Found while explaining a second playtest report from the same game: the Superintendent was asked to
rule on a same-direction train instead of being automatically held against an opposite-direction one
also occupying the card. Root cause: the occupant loop (advance.ts) returned on whichever occupant
it examined first, in node.transits insertion order — correct while a Subdivision held at most one
train, but a card can now legitimately hold two at once, which is exactly what the
Telegraph/Telephone/Radio dispatch exception creates (a facing train dispatched past another sits on
the same card as it). Fixed to two passes: every occupant is checked for an opposite-direction
absolute bar first (subject to the dispatch exception) before any same-direction judgment call is
offered. Pinned with a regression test that fails against the old single-pass code
(enhancements.test.ts).
Three more sounds, synthesised like the rest
arrive, depart and crash join stage/couple/drop/schedule/completed/day/train in
sound.ts — a train pulling into an Office (decelerating chuffs, a soft bell tap), one highballing
out of one (accelerating chuffs, a falling horn note), and a collision (dissonant tones bent sharply
down over a noise boom and a sub-bass thump), wired to trainArrived, trainHighballed (Office
departures only, so it doesn't double up with a fresh make-up's "All aboard" or a full division run's
completed), and trainsDestroyed. Three real WAV clips were sourced for this and considered, but
carried no license information anywhere — no LICENSE file, no embedded metadata, no note of where
they came from — so they were not committed; the synthesised versions are good enough to keep as the
real thing rather than a placeholder.
The splash page has its box art
docs/StationMasterSplashScreen.png (1122×1402, 3.0 MB) is now a 560×700 JPEG at 145 KB
(public/images/, copied verbatim into dist/images/ by a small addition to build-web.ts) and
sits beside the title, tagline, blurb and both buttons in a side-by-side hero on index.html,
stacking to image-above-text under the existing mobile breakpoint.
0.4.8 — 2026-08-19
Four reports from the same game, all about squares: which ones a card may go on, which one a button in the action list means, when there is more than one legal road to the same square which one a train actually takes, and — when more than one train is standing at the same Office — which is which.
The Limits bound the district, not just the Running Track
Reported:
"Sidings should not be allowed to be built outside the limits."
The Limits sign "denotes the limit of your control area" (§2.1) and §3 defines Secondary Track as
"all tracks in your limits that are not the Running Track" — but the bound was only ever enforced
on the Running Track row itself, with a comment in canPlaceAt stating outright that "the district
below the Running Track is unbounded". So a siding could run east past a player's own sign, take
industries with it, and put cars on track that §8.1 and §10 do not consider his territory at all: the
rules reason about "the track between the train and the Limits", and running past another player's
Limits is what makes a collision his fault.
withinLimits is now the district's rule at every row, and a Facility is bounded by it too —
§11.2 is explicit that "Facility cards carry their own rails: placing a Facility places track". The
refusal is its own code, OUTSIDE_LIMITS, rather than NOT_CONNECTED, for the same reason
BREAKS_RUNNING_TRACK is: the card would join perfectly well, and is refused for a different reason.
Inclusive of the sign's own column, which is the half that keeps the game playable. Jesse's call. The sign stands on the boundary rather than beyond it, and a district opens with signs at ±1 around the Office — so the strict reading would leave exactly one buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier neighbourhood, are usable from the first Stage. A siding may run under the sign; nothing may go past it.
What it costs the bot: nothing measurable. Out-of-limits building was rare for it to begin with — 5 cards across 100 games, in 4 of them, never more than two columns over — and paired over 200 seeds the bound moved 4 games and −0.015 Revenue (t = −0.26). It is a human building deliberately who hits this, which is exactly how it was found.
The nine spots really are nine — the four diagonals were legal and unofferable
Reported:
"Modifiers should be able to go on any diagonal — we were unable to place it to the south-east."
§9 places a Modifier "adjacent to a Facility, on any of the nine nearby spots", and check has
always accepted all eight neighbours. The fault was upstream in placementCandidates, which walks
north, south, east and west from every occupied square: a diagonal square with no orthogonal
occupied neighbour was never generated, so it was never offered. Measured on a facility below the
Office: check says legal at all four diagonals, the menu offered the three orthogonal ones. At a
stub industry — the case in the report — every diagonal is in that position.
Generated in a second pass, only around a Facility. Track has to join and a diagonal shares no
edge, so a Modifier is the only card those squares can ever take; generating diagonals around every
card instead costs 54% more simulation time producing candidates check then rejects. Appending
rather than interleaving leaves the existing candidate order untouched, which matters because the bot
breaks ties by first-best — measured over 200 seeded games, 0 played differently, so every figure
in TODO.md still stands.
A Modifier may hang outside the Limits — but not in the Running Track's row
Jesse's call, both halves. A Modifier is not track (§9), so unlike a siding it keeps all nine of its spots when its host stands at the limit: refusing the outer three 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 the rule bites only beyond the sign — and that is the ground the main extends onto, where a parked Modifier would block the player's own sign from moving outward (§2.1) with nothing on screen to warn him. Industries have been barred from the Running Track since the "Placed" column was read properly; this is the same rule for the same reason.
The board draws where the district ends
Two signs on one row cannot say that nothing may be built outside their columns at any row: a
player looking at open ground beyond a sign had no way to know it was unbuildable until the square
failed to light up. officeSvg now draws a quiet dashed edge down the whole canvas at each limit,
labelled, and just outside the sign's own column so the picture agrees with the rule — a siding
may run under the sign. It moves outward on its own as the Running Track is extended, which is the
reward for extending made visible. The Frame carries limits for it, resolved server-side like
everything else a remote client cannot look up (multiplayer.md §5).
The margin on the viewBox is not cosmetic: the west sign is usually the leftmost card on the canvas, so the line outside its column lands at x = −1.5 and simply is not there on a canvas starting at 0. Caught by drawing one, not by reading the code.
Hovering an action points at the square it means
Reported:
"When I have my mouse over the action for a particular square, could the corresponding square in the office area be highlighted to minimize my mistakes of selecting the wrong square — 1,3 versus −1,3? In a solitaire game with Undo it is not fatal. In a multiplayer game that could be a major disaster."
The action list is a column of near-identical sentences separated by a coordinate, and the board
beside it said nothing about which was which. Every action that names a square now carries it as
data-square, and hovering — or focusing, so the keyboard route is not a lesser one — lights that
square on the board, ghost targets included, which is precisely the moment a player is choosing
between coordinates.
The coordinate rides on the menu, resolved from the intent by coordOf, rather than being parsed
back out of the label: a remote client holds no GameState and cannot look up where a tray is
standing, which is the same reason the menu already carries card descriptions. Checked over 12 bot
games: 870 direct actions name a square, and all 870 carry it.
Two routes to the same square
Reported:
"There are times when a train can take two different paths to get to a destination. See game at undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two different ways. It can go through the refinery and pick up the tanker on its nose, or it could go straight and then make the curve and skip the refinery and not pick up the car. The player should have the two different options."
A district with a passing loop — turnout off the main, curve down to an industry, curve back up —
offers two legal routes between the same two squares, and exploreMoves (track.ts) only ever found
one: it keyed its visited set on destination and entry port, both routes rejoin through the same
port, and the shorter one won the race before the longer one could be recorded. Which route survived
was an artifact of search order, not a choice.
Ruling (Jesse, docs/rules/open-questions.md Gap 14): the player may choose the path. §A.4 says "cars on your track" — the industry spur is a different track, and declining to enter it is not going around the car standing there. The mandatory half still bites in full once a route is chosen: every car standing on it couples.
The walk now enumerates every simple route — a per-path visited set replaces the old global one,
so a card may be revisited across different routes but never twice within one — capped at 4,000
frontier nodes so a dense district cannot blow the walk up combinatorially; BFS order means the cap
loses only the longest routes first. Routes are deduped on outcome, not on reaching the square:
(destination, entry side, [(origin card, car)…]), so two routes coupling identical car types off
different cards are both offered and each sweeps its own card, while two routes with nothing to
tell apart collapse to one.
switch.move gained an optional via: GridCoord — one intermediate square on the chosen route,
never the start or the destination. legal.ts emits one intent per distinct route with a via that
distinguishes it from its siblings; via absent resolves exactly as before (the first route
enumerated), so every existing save and every bot decision replays identically. Checked against
public/replays/*.json and the full suite: 575 pass, 0 fail, unchanged.
Threaded through the label (describeIntent finds the route via names, so "couples 1 tanker"
reports the cars that route actually lifts), the action-list dedupe (two routes to one square would
otherwise print the identical button twice), the hover highlight (data-route lights every square a
route runs over, not just its destination), and the history (trayMoved.via, when the move was
ambiguous, names which road the crew took).
Balance impact: none expected, and none measured able to be attributed to this. Instrumented over
60 developer-bot games (17,884 destinations enumerated): squares reachable by 2+ distinct paths are
~1.2% (208), and in every one of those 208 the routes coupled identically — the bot has never yet
built a district where the discarded route would have mattered. 400 full games (200 developer bot,
200 random bot) completed 200/200 with no exceptions on the reworked walk. compare.ts needs an
ablation flag to run at all and this is not a bot heuristic, so paired A/B was not applicable here;
the enumeration-based argument above is the actual evidence.
The Roster Pass — every train at the Office visible
The Office is the one square where more than one train may legally stand at once — it has
several A/D tracks, each holding one — and the board drew it as if only one ever could. Two trays
at a Station couple onto the identical officeCoord; trainOnCard() returned on the first match
and CellView.train had room for exactly one, so a second train was counted in the old A/D pips
and never drawn at all. Reported directly, and analysed in "Two Trains, One Card": the pips and the
picture were reading different fields.
CellView.train is now CellView.trains: TrainView[] — every train standing on the card, each
carrying its own trayId. The A/D pips are gone; in their place is one roster chip per A/D track,
always (adTracks of them, not one per train), free tracks reading a dashed, dimmed "free" rather
than vanishing. The selected train's chip is lit the same amber the crew strip and action buttons
use, and its consist is the one drawn at the rail — clicking any occupied chip sets selectedCrew,
the same value the "Which train are you switching?" picker already wrote, so the board and the
action panel drive one value in both directions.
standingWest moved from CrewTray to TrackCard. The split still means what it always meant
— west/east of the row of standing cars — and it is still only meaningful while an engine stands on
the card (an unattended cut has no near or far side; that reasoning did not change). What forced the
move: two trays sharing one Office card could otherwise hold two different splits of the identical
row, and there is no such thing as "west of one particular A/D track" — a cut lies west or east of
the whole block of A/D tracks, so there has to be exactly one number for the card to carry. No
save migration: Save = {seed, history, rules?} replays through the engine rather than being
loaded, and a stale value on an emptied card is inert because nothing reads a split with no train
standing there.
The Division map stopped drawing trains on their neighbours. The Office's Running Track cell was
a fixed 78px regardless of how many A/D tracks it had, so two chips centre-spread wider than the
cell and spilled onto the Limits cards either side. The cell is now sized by capacity
(max(78, adTracks·54+12)), not by how many tracks are occupied — sized by occupancy instead, the
board would have shifted the East Division Point sideways every time a train arrived or left — and
chips are laid into fixed slots, one per A/D track, so none can ever overhang the cell.
Verified: two trays at a Station are both drawn, each with its own chip; a Terminal's four
tracks all fit; a bare cut with a stale standingWest still draws left-aligned; every roster chip's
x-extent lies inside its Office cell at full occupancy; and a packed replay row from before this
feature — a lone train object, not an array — rehydrates into a one-train roster rather than
throwing. 584 tests, 0 failures.
Also
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a
placement rule kills every replay containing one —
harness.test.tscatches it rather than letting them go quietly dead, which has happened twice before. Old saves are not preserved across rule changes and are not meant to be yet. node_moduleswas a tracked symlink pointing at a path that does not exist, sotscwas missing and 20 tests failed before any of this was written. Replaced with the real dependencies.
0.4.7 — 2026-08-19
Eight play reports and one design that had been written up and not built. The through-line is the switching game: what a card can hold, which end of a train a cut comes off, which way a train meets cars standing on the line, and what the board and the log say about all of it.
Track order for standing cars, and the cut you left on your own card
Reported, twice, and it turned out to be one bug wearing two faces:
"If I put cars off the nose on a given track, and my next move is go forward, I need to couple those cars right back on. If I drop cars off the back and I move back, then I will automatically recouple the cars onto the back of my train. Right after dropping my cars I need to be able to see if those cars are ahead or behind the train."
"When dropping all 4 cars, order was reversed. It worked properly if we dropped cars individually. Also, when adding four cars, again the order was reversed."
The single root cause. TrackCard.standing was a bare array whose doc comment claimed "in track
order (§A.3)" and which in fact had no defined orientation at all. CrewTray.consist is
oriented — nose first, relative to facing — so every transfer between the two is a conversion that
nothing performed. §A.3 says what the orientation should be outright: cars "occupy the track, in the
same order they originally held, left-to-right". Left-to-right is west-to-east.
So standing (and an industry track through carsOn) now runs west to east, which is the
board's own orientation rather than whichever train last touched the card. Three consequences, each
of which was one of the reports:
- Setting out is batch-invariant. A drop costs no Moves, so one drop of four and four drops of
one are the same turn played two ways — and they parked three different orders, one of them
physically impossible (
2+2gavereefer tank boxcar hopper). Successive cuts off the same end stack up towards the engine, so the insertion point is the train's own place in the row, and all three now park identically. - Approaching a cut from either end mirrors.
coupleswas accumulated in path order with no reference to the direction of travel, so running onto a parked cut eastbound and westbound gave the identical consist. It is built nearest-first along the direction of travel now, and reverses on its way onto the nose — the farthest car met ends up nose-most, which is what makes a run-around worth the Move it costs. - A train no longer drives through its own cut. The movement walk began at the neighbour of the start square and never read the start card at all, so a crew could set cars out and pull straight away from them in either direction. Coupling is mandatory (§A.4) and your own square is no exception: pulling out through the end the cut sits at picks it back up, and the cut counts against the four-car limit. Setting out off the end you are not leaving by still works, which is the whole reason the choice of end is a decision.
CrewTray.standingWest records where a train stands among the cars on its card — a train may set
out off both ends on one square, so which side a cut is on is not recoverable from the array alone.
It answers all three questions that needed it: which cut a departing train must couple, which cars are
ahead of the engine and which behind, and which side of the chip the board draws them on.
On the board. The cut used to be drawn as one strip along the bottom of the card whether a train was there or not, so "are those cars ahead or behind" had no answer in the picture. The row is split at the train now — west cars left, east cars right, the engine in the gap — and every car's tooltip says "standing AHEAD of the engine — it would couple onto the nose pulling forward" or the reverse. The history says which end a cut came off, and a move's button distinguishes "takes your own empty boxcar back off this card" from cars found standing on the line.
The printed-rule interaction, decided. Trains 3/4 spend a per-location freight budget on setting
out, so recoupling would have been refused with FREIGHT_WORKED_HERE and a legal-looking drop would
have become silently one-way — same shape for X13's "drop but not pick up" and X22's "empties only".
Taking your own cut back on the square you are standing on is undoing the drop: exempt from those
restrictions, and the budget is refunded. The alternative — treat it as an ordinary pick-up — never
strands a train, since backing up stays legal, but it makes a legal-looking move a trap, which is
exactly what the "why can't I move" panel exists to prevent.
Measured, 200 paired seeds, developer bot: -0.55 revenue (SE 0.15, t = -3.63), 3 seeds better,
27 worse, 170 identical, with freight revenue 1.11 → 0.56. This is a real cost and it is the bot's,
not the rule's. The bot's trains run engine-first with every car behind, so at a stub industry it sets
a car out between itself and the only way out — and the correct play is §A.5's "facing point" move,
shoving the car in ahead of the engine and backing out, which is the same cross-turn planning
TODO.md already records as out of reach of any bot. What the bot could be taught, it was: moves that
drag its own cut back on are filtered out of its options before any heuristic sees them, which took
recoupling from 625 of 1,029 set-outs in 60 games to 101 of 677 — and all 101 that remain are the
stub-industry case above. Read the revenue as a bot measurement, not a balance one.
Twenty-one tests in test/cut-ordering.test.ts: batch-invariance off both ends at both facings, the
mirror property, the nearest-first coupling order in both directions, the round trip at every batch
size, §A.5's trailing-point step 1, the own cut counting against the four-car limit, and the freight
budget refunded for a 3/4 Express but still charged for a genuine pick-up. The published replays were
re-recorded twice — legality changed, so bot play changed.
The Superintendent's ruling names the trains it is about
Reported: the Superintendent could not tell which train he was clearing without hovering the button. The §8.1 clearance is the sharpest decision in the game and it was posed as "Superintendent — rule on this train", over buttons reading "ALLOW" and "HOLD".
Which train is exactly what the ruling turns on, and it was the one thing not said. Two changes, and the second is the one that was actually broken:
- The heading asks the question. The pending decision holds both trays, so it now reads "may Train 6 follow Train 4 onto the same Mainline card?" instead of naming neither.
- The train moved to the FRONT of each button. They already carried the trains and the
consequences — but
actionButtonsplits a label at the first em-dash and shows only the head, so "ALLOW — Train 6 follows Train 4…" put the whole point behind a hover. They read "ALLOW Train 6 to follow Train 4 — onto the same Mainline card, closing up behind it" and "HOLD Train 6 — it waits where it is, losing the Stage but keeping the line clear", so the visible half of each button is now the half that decides it.
An industry track holds four cars, like every other card
Fixed: a crew standing at an industry could set out fewer cars than it was carrying. Reported from a playtest at undo 188 — "we wanted to drop two cars, but were only allowed to drop one."
The industry track was built length: baseOut + baseIn cars long, so its capacity for ROLLING STOCK
was silently its capacity for WORK. A Mine Tipple prints one green box and no red one, so it had
room for exactly one car and refused the second; the same arithmetic gave a Power Plant one and a
Freight House two. Nothing in the rules says this. An industry track is ordinary Operating Rail, and
what actually limits a set-out is the same thing that limits it anywhere else: a consist may not
exceed four cars, so four is all that can ever be shoved onto a card.
industryTrack.length is gone rather than corrected. Leaving a number there invites the next reader
to derive it from something, which is exactly how this happened. spaceOn is now one line for every
card — MAX_CONSIST - carsOn(card).length — and carsOn picks the industry track by asking whether
the facility is freight, which is what the length was standing in for.
Also fixed, in the same place: ordinary track was unbounded. It could be piled with five or more cars, and once it was, no train could legally couple them — mandatory coupling would exceed four — so the pile was unrecoverable. Both exceptions are gone; there is one rule now.
Changed: the siding graphic is off the industry cards. Every renderer drew one empty square per unit of capacity, along the bottom of the card and labelled siding in the panels. It asserted two things that are not true: that an industry card prints a siding, and that the siding is as long as the box count. No industry card prints one. Industry cards now draw what is standing on them and nothing more, exactly as a plain straight does, and the panel row is relabelled spotted.
A Modifier still adds its box and never adds room for a car. A Grocer's Warehouse receives into one red box; set a Truck Dock beside it and it has two. Neither number has anything to do with how many cars fit in front of it, which was four before the Truck Dock arrived and is four after.
Test note: spots cars on industry tracks went red on this change while measuring an
improvement. It summed cars left spotted across eight seeds, and across those eight the old engine
managed exactly ONE — a coin-flip standing in for the claim "structurally zero". With longer tracks
the bot leaves whole cuts instead of single cars, which lands on fewer seeds and delivers more cars
(22 against 19 over forty games). Widened to twenty seeds so it fails for the reason it names.
The Truck Dock unloads, and brings nobody
Changed: the Truck Dock is now +1 inbound slot, no Laborer. It printed +1 outbound and +1 Laborer, which made it a longer-host-list copy of Forklifts — three of the seventeen Modifiers were the same card with different names on them. A dock is where a truck backs up to take delivery, so it adds the red box rather than the green one, and pays for the only inbound grant in the deck by bringing no man to work it.
Two consequences, both intended and both visible before the card is played:
- Beside Packing Sheds it now does nothing at all. Packing Sheds is
flow: 'outbound', andusableGrantdrops a grant on a direction its host cannot use — the same rule that used to swallow the Ice House's outbound slot at a Grocer's, running the other way. The hand tooltip reads "+1 in · goes beside Freight House or Packing Sheds or Grocer's Warehouse · Packing Sheds only ships, so the inbound slot does nothing there", which is the whole decision stated while the card is still in hand. - It is the first Modifier that grants no worker. A facility's Laborer count no longer rises with every card set beside it, so an unloading industry can now be capacity-rich and man-poor — which is a queue at the industry track, not a bug.
Every tooltip, the panel's grant lines and the card reference are computed from MODIFIER_PROFILES,
so the one data change carries all of them; nothing prints the old numbers.
Measured, 200 paired seeds, developer bot: -0.03 revenue, 0 seeds better, 4 worse, 196 identical, with a Truck Dock standing at the end of 16 games either way. Neutral for the bot, which places one in 8% of games and does not plan around unloading capacity. A human building toward a Power Plant or a Grocer's is the player who feels this, and the harness cannot see that.
Both published replays that depended on the old grant went dead and were re-recorded
(node src/sim/save-replay.ts 400 --top 3). They went dead twice more before this release shipped —
the cut-ordering work above changed what is legal — so the three the site publishes are
seed-2717217, seed-404869 and seed-2701379, all recorded against the final rules.
Mainline cards say what they do
Reported: "mainline cards need a tooltip stating what they do. Hilly and Uncontrolled Siding — I have no idea the impact they have on game play."
Both are invisible without one: Hilly charges freight double what it charges passengers, and Uncontrolled Siding is one of only two cards where a following train is not stuck behind a slower one. The tip carried the card's name and its modifiers and nothing else.
Crossing times are computed by crossingStages rather than written out, so a tooltip cannot
drift from the rule it describes — including the Hilly split, which is decided by whether the train
carries a coach:
Hilly — P60 / F30 — a train carrying ANY coach crosses as a 60 (1 Stage for a fast train), and a freight-only train as a 30 (2 Stages). A slow train adds one Stage either way. · One train at a time — anything following has to wait for it to clear.
Uncontrolled Siding — 60 — 1 Stage for a fast train, 2 Stages for a slow one. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one. The Double Track and the Uncontrolled Siding are the only cards that allow it.
An Extra starts where its number sends it
Reported: "Extras should start at Eastern or Western Division point based on their numbers. Even trains run to the east (start at western DP), odd run to the west (start at eastern DP). They can also start at a control point (any office except whistlepost) at player's choice."
Every Extra used to launch eastbound from the West Division Point, hardcoded, with the simplification flagged in a comment — so half of them ran the wrong way and the Control Point option did not exist. §2.3's "odd runs west, even runs east" now governs an Extra exactly as it governs a timetabled train, and the New Train phase stops for the decision the same way it stops to have cars placed. Measured over 60 deals: 32 Extras started at the West Division Point and 29 at the East, where before it was 61 and 0.
A Control Point is any Office above a Whistle Post, so upgrading is what buys the option — check
refuses a Whistle Post, and an Extra starting at an Office takes an A/D track like any other arrival.
A modifier's grant comes back when the Office can use it
Reported: "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."
Exactly right. hosts: ['office'] includes a Whistle Post, which is not a Passenger Facility, so
usableGrant correctly dropped the +1 outbound when the card was played — and it was gone for good,
because the upgrade only ever applied the difference between two tiers and knew nothing about what
had been discarded. The porter landed, because porters have no direction gate, which is why the
Restaurant looked half-applied rather than suppressed.
TrackCard.modifiers already records which Modifiers served a facility, so what was dropped is
recoverable: when the Office becomes a Passenger Facility, each of them is granted the capacity it
always printed. Keyed on the transition, so a Depot → Station upgrade does not pay them twice.
The Grocer's Warehouse ships as well as receives
Reported: "grocer's warehouse didn't get extra outbound slot for truck dock."
It could not — and the reason was in the card data, not the modifier code. card-reference.md:
| Facility | Car | Direction | Laborers | Out | In | Track |
|---|---|---|---|---|---|---|
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
| Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4 |
and in prose: "'Freight House' is not a card. It is the collective term for a freight facility that loads and unloads — the Grocer's Warehouse and the Oil Refinery." The engine had the Grocer's inbound-only and the Refinery outbound-only, so §9.3's "Passenger Facilities and Freight Houses permit cars to move each direction" named neither of them, and every Modifier grant on the missing direction was silently dropped — Truck Dock, Ice House and Forklifts at the Grocer's, and every inbound grant at the Refinery.
TODO.md had recorded this in v0.4.2 as "checked, and there is no bug", on the reasoning that a
Grocer's is inbound-only. That premise was the bug, and the note is corrected.
Base capacities stay at the engine's own scale — 1 per direction a facility allows — rather than the
card reference's 2/2. Every industry here is scaled down the same way, Mine Tipple included, so
raising one alone would be a balance change rather than a correction. Two things left for Jesse in
TODO.md: those numbers, and the fact that the engine deals a Freight House card (6 copies) that
the rules say is not a card at all.
A defence goes out with the attack it answers
Reported: "just like the opponent directed cards are removed from the solitaire game, remove any of the defensive cards whose only purpose is to answer them (Facing Point Locks and Water Column and ???). No need to have them in the deck when they can never be used."
The third is the Overpass, and there is a fourth: Facing Point Locks exists twice, once as an Enhancement and once as a Mainline modifier. Seven cards in all, and every one of them answers a card that is already held out of the deck:
| Card | Copies | Answers | Which is a… |
|---|---|---|---|
| Facing Point Locks (Enhancement) | 2 | Derail | Action card |
| Facing Point Locks (Mainline modifier) | 2 | Derail | Action card |
| Water column | 2 | Watertower | Space-use card |
| Overpass | 1 | Railroad crossing | Action card |
The engine already knew two of them were dead — ENHANCEMENT_RULES marks Facing Point Locks and the
Water Column dormantSolo, and the Overpass is the one card with no code path at all — and dealt
them anyway. The pairing now lives on the card, as SimpleCard.answers, so it is visible where
the card is defined and they come back automatically the moment opponentCardsInDeck does.
The dealt deck drops 213 → 206. The catalogue is unchanged, and so are the rules: the tests that exercise Facing Point Locks and the Water Column now mint the card directly rather than fishing it out of a deck that deliberately no longer contains it — a rule nobody exercises is a rule that rots, and these fire the moment their attacker returns.
Also: a pending Extra's placement now gets its own heading naming the Extra and its card. It had landed in the "Making up the train" group, which — with no tray being filled — had no train to name.
Measured drift
- Deals producing a completed unload: 12 in 40 → 6 in 40. The Grocer's can ship now, so the bot often loads there instead of unloading. Unloads themselves are unharmed — 30 completed across the 40 deals measured after the change — and the test that needed one widened its sample rather than lowering its bar.
- Moves per productive act: ~8 → 9.9, with the crew doing more work (1.48 → 1.84 acts a game), not less. Westbound Extras exist for the first time and the Grocer's gives more switching worth doing; a crew shuttling for its own sake would show this ratio climbing while the work stood still.
- Both published replays were re-recorded: the rules genuinely moved.
- Three reachability canaries were under-powered and are now sampled properly, not relaxed. The anomaly check ran 60 games while its rarest subject, Red Flags, fires in about 4 games in 200 — so it reported "unreachable" on the luck of the draw, which is the opposite of what a canary is for; it runs 200 now. The sound test pooled three deals while coupling happens in 39 games in 200, and on this seed stride the first game that couples anything is index 13 — twelve seeds still contained none, so it pools 24. The action-list width bound went 13 → 14, re-measured across five seeds in all three opening deals.
The Freight Agent may stage a load before the car is there
Reported: "A freight agent should be able to load an outbound green box prior to having the car there that matches the load that he's putting in there. But in order for the laborers to move it from the green box into the men at work track, they would need to have an empty car of the appropriate type waiting there."
Correct on both halves, and the engine had the requirement one step too early. §6.3 lists what the Freight Agent does — "select one Rolling Stock from the Division Yard pile and place it onto a Facility's green Outbound box" — and asks for nothing on the industry track. The empty car belongs to §9.3's Load the car: "a load in the Green Loading Box and an empty car of the required type on the industry's track". That is the Laborer action, the one that walks the load Green → MEN → AT → WORK.
freightAgent.stockOutbound was rejecting with NO_EMPTY_CAR_SPOTTED when no matching empty was
spotted, which made the ordinary sequence illegal: you could not have the cargo waiting on the dock
while the car to ship it in was still being switched in. Now the gate lives only on
laborer.startLoad, where startableLoad already enforced it — including the type match and the
count against loads already staged or on the sign, so two loads can never walk toward one car.
Nothing can jam as a result. A load in a green box is waiting, not stuck; only a load on MEN | AT | WORK locks the industry track (§9.3). The staged load simply sits there until a crew sets an empty car out.
Also updated so nothing still says the old order:
- Impediments told players to "bring one in with a crew FIRST, then the Freight Agent can stage a load onto it". They are not bound to that order — it now reports the empty siding as the second errand rather than a reason to hold the Freight Agent back.
- The bot ranks a stock whose load a Laborer can start next Stage above one that must wait; it falls back to staging ahead rather than wasting the option. Previously "first legal stock" was enough because only ready facilities were offered. 40 games: revenue and trains unchanged, cards played 16.8 → 16.9 — the bot barely exercises the new freedom, which is a human's to use.
Four tests: stocking over a bare industry track, stocking still refused with no matching loaded car in the Division Yard, a staged load held in the box until an empty is spotted and started once it is, and a spotted car of the wrong type not counting.
0.4.6 — 2026-08-16
Four play reports in one release: trains that seemed to move before you could work them, a train card you could not look at again, a switching list that never said which train it meant, and a Freight Agent button that looked like it might be where the money was.
Why did that train move? The history now says
Reported: "some trains seem to be moving before I can switch or do other operations on them. It may be that the rules as written and implemented are just wrong. It may be that it's my perception."
It was perception — but the log was feeding it, in one place with an outright falsehood.
The arrival line was wrong about Expedite. It said an expedited train "leaves again this same Mainline Phase; there is no turn in which to work it". The expedited departure was moved to Supervisor Shift precisely so the Porters and Laborers get their Stage with the train, so Cargo is available and only Local Operations is not — the log was talking players out of the one turn they had. Both branches now name the phases:
Train 8 ARRIVED at the Whistle Post carrying loaded boxcar, empty coach — 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.
Every departure now carries the rule that released it. They all read alike before, so the one that matters — an Expedited train going at the end of the Stage it arrived — looked exactly like an ordinary train going a Stage later:
Train 8 HIGHBALLED — departed the Western Division Point onto the Mainline. Why now: it was made up and the Subdivision ahead was clear, so its run begins
EXPEDITE TURNS OUT TO BE CONDITIONAL, which is most of why it feels arbitrary at the table.
shiftChange has always said so in a comment — "an expedited train that would need a ruling simply
stays, and runs normally next Stage" — and it happens often: on one seed with ordinary traffic
running, Train 6 The Sparrow was held this way and collected four Local Operations turns instead
of none. That was silent. It now says so, and the card and the arrival line no longer promise that an
Expedited train can never be switched.
Three tests pin the claims the log makes, so a phase-order change cannot leave the narration lying: an ordinary train gets a Local Operations turn and leaves in a Mainline Phase; an Expedited train alone on the Division gets Cargo, no Local Operations, and leaves in Supervisor Shift; an Expedited train held for a ruling gets its Local Operations turns after all.
Also fixed: a Division Point departure was narrated as a fake phase marker — "▸ train 8
highballed phase" — and the new departure line reported the wrong end of the railroad, because it
read the train's position after enterMainline had already moved it onto the Mainline.
The train's card, after the card is gone
Reported: "once a train card's been played, how would I see that particular train card again — what it's allowed to do and not allowed to do, and how it has to be loaded? Could I see it on a timetable tooltip? If a train is on the board, could its tooltip include its special rules?"
Yes to both. trainRules already produced the card as one line and the Office card already showed
it; it now also rides on:
- the Timetable — hovering a slot gives the train's card under
ITS CARD —, which is where a player already looks for that train; - the Division map chip — a train out on the Mainline can now be asked what it is, which is exactly where "why did that leave without me?" gets asked.
Two lines in that card were wrong and are corrected. The coach rule said "a cut carrying it may only be set out at the Office", which reads as a place you can do it — you cannot, §A.4 refuses the Office square outright, so the coach can never be set out anywhere. The Expedite rule said only "it departs in the same Stage it arrives", which is true and useless; it now names the phases, and the clearance exception above.
Which train are you switching?
Reported from play: "when switching, make it clear which train you are switching — it is possible to have more than one train available."
A train standing on an A/D track while a local shunts is ordinary, and the page got it wrong twice
over. Frame.moves was built from the first tray in the map, with a comment admitting it — "One
crew. Solitaire has one, and with more the answer would depend on which is selected — a question the
page does not yet ask." So the board highlighted one crew's reachable squares while the action list
offered every crew's moves under a single "Switching" heading of bare coordinates.
Worse, and not noticed until this was pulled apart: identical labels were collapsed across the whole action kind. "move to (0, 2)" describes one crew's move exactly as it describes another's, so one of the two was silently dropped and could not be chosen at all — a legal move with no button.
Now:
- One heading per crew, naming the train and where it stands — "Switching Train 8, standing at (-1, -3)". The train is named in the heading rather than on every button, so the buttons stay short.
- A "Which train are you switching?" row when there is more than one, and the crew chosen there is the crew whose squares the board draws. One crew at a time on purpose: every crew's highlights at once merge into a blob and stop meaning "here is where this train can go".
- De-duplication is per crew, so two trains that can both reach the same square each get a button.
- A train that may not switch is not offered as one.
movesForis pure track geometry and six cards print "no switching", whichcheckenforces and it does not — the Circus Train was being offered as a crew to switch, with every one of its moves refused.
Display state, deliberately not in the game: which train a player is looking at is not a fact about the railroad, and two players may reasonably be looking at different ones.
Found while fixing it: the grouping key had briefly been type + separator + trayId, and a stray
byte in that separator collapsed every crew back into one group. The crew now travels as data on
the entry rather than encoded into a map key that GROUP_ORDER also prefix-matches on.
The Freight Agent's red box says what it does, and what it does not pay
Reported: "it wasn't obvious if that was a mechanical thing or if that's the actual revenue generation. I believe that's actually where you get the revenue, and that completes unloading the car."
It is the mechanical one, and the button now says so. The Revenue for an inbound load is paid one
step earlier — §9.3: "The last [Laborer] places the load on a red Unloading box. Earn a Revenue
point." Clearing the box is §6.3's Freight Agent operation, "select one Rolling Stock from a
Facility's red Inbound box and place it into the Classification Yard pile", and the printed rule
attaches no Revenue to it. Confirmed against the engine as well as the rules: clearInbound emits
inboundCleared alone, with no revenueChanged beside it, and the score does not move.
clear red box at (0,0)
becomes
send the loaded coach in the red Inbound box at (0,0) to the Classification Yard — pays nothing
(the Revenue was paid when the passengers detrained); it frees the last slot so more passengers
can detrain here
The wording follows the facility: an inbound freight load and a coach whose passengers have detrained both wait in the same red box, and both were paid for a step earlier.
Making up a Local says which order the cars go on in
Reported from play: "I can't drop a car at all — trying to get an empty to an industry, but I can't drop any cars on the siding first." Chased through two wrong guesses (it was not movement, and it was not the 0.4.5 fix falling short) to a single train and a single arrangement.
Trains 7/8 Local print "coach must remain on station track if switching", which the engine reads as "the coach is never set out". A cut always comes off an outer end, so if the coach is on one outer end and the engine is on the other, every cut on offer contains the coach — and the train is locked. It cannot set out its freight car, and it cannot even uncouple to run around, because that means leaving the coach standing too. The one escape is a Small Yard, of which there is exactly 1 copy in the 213-card deck.
All six arrangements, checked against the rules rather than reasoned about:
ENGINE boxcar coach NOTHING — the train is locked
boxcar ENGINE coach can set out: boxcar
boxcar coach ENGINE can set out: boxcar
ENGINE coach boxcar can set out: boxcar
coach ENGINE boxcar can set out: boxcar
coach boxcar ENGINE NOTHING — the train is locked
Two of six lock, and they are exactly the two where the coach holds one outer end and the engine
holds the other. It is only the Local. Over 60 games, every other train that stood in a district
with cars where a set-out was allowed managed one — Drag Freight, Heavy Freight, the Freight Extra,
the Director's private car, Yard Xfer, Appleseed — 127 positions, zero blocked. The Local: 1,181
positions, every one refused, all COACH_MUST_STAY.
The cruelty is that the locking order ENGINE boxcar coach is both the prototypical mixed-train
make-up and what the game naturally produces: cars are appended as they are clicked with the engine
on the nose, so the last car added takes the outer end, and the freight car is the natural first
pick.
So the make-up panel now says so. The whole remedy is "do not add the coach last", and it is stated at the only moment it can still be acted on:
- nothing on the train yet → "Add the coach FIRST… ENGINE, coach, freight is the order that works."
- coach on, freight still to come → "Now add the freight car — it takes the outer end, leaving the coach safely inside." A hint, not a warning: the coach lands on the outer end the moment it goes on, including for the player who has just been told to put it there, and colouring that as a mistake punishes them for taking the advice.
- coach on the outer end with nothing left to add → a real warning, because there is no next step.
- freight already on and the coach still to come → "send it out without the coach if you want it to work the district", since "add the coach first" is advice it is too late to take.
Shown only for a train the order can lock, so it is not a standing caption a player learns to skip — every other train, and a Local with no coach coming, get nothing.
The rules are untouched. This is guidance, not a rules change; the open §A.4 question about where
the Local's coach stands while its engine works is still open in TODO.md, and answering it (letting
the coach be set out at the Office, as the card's wording suggests) would let the prototypical
make-up work and make this advice unnecessary.
Verified end to end, not just unit-tested: played to a real Local make-up, followed the advice
through both steps, and confirmed the resulting ENGINE coach boxcar can set a car out.
0.4.5 — 2026-08-14
A train can back out of a curve again
Reported one commit after 0.4.4 shipped: "seems like I can't drop a car at all. I'm trying to switch to get an empty car to an industry, but I can't drop any cars on the siding first."
Setting out a cut needs no Move and is refused almost nowhere — but you may only set out where the train is, and the Office square is barred outright (§A.4). So "I cannot drop" is nearly always "I cannot get there", and getting there means leaving the Running Track through a turnout and a curve. 0.4.4 fixed the forward half of exactly that and left the reverse half wrong:
- Forward exits by
facing— fixed in 0.4.4 to read the card's far end rather than assumingopposite(entry). - Reverse still exited by
opposite(facing), which is the other end of a straight and of nothing else. A crew facing north on a north-west curve backs out through west;opposite('n')is a south port the card does not have, andexploreMovesreturns nothing at all from a port the card lacks.
So 0.4.4 moved the problem rather than solving it. Before it, a crew that rounded a curve could only back out; after it, a crew could only carry on. Either way a siding entered one way could not be left the other, and a siding that had to be backed into could not be entered at all.
reversePort now asks the card for its other end, the same way farPort does going forward. A train
always stands on a two-port card — §A.1 forbids finishing a Move on a turnout — so there is exactly
one other end to find.
How much of the board this was hiding. Over 25 bot games, at 1,937 points where a crew could have been switching, comparing what the board highlights now against what it highlighted before:
| squares offered, before → now | 7,461 → 10,040 (+35%) |
| positions hiding at least one legal square | 694 (35.8%) |
| positions with no legal move at all | 300 (15.5%) |
Both figures understate it: the "before" reckoning was run with occupancy ignored, so it was allowed squares that another train was actually sitting on. A crew counted as stuck was stuck even on the generous reading.
Tests. The failing case is pinned three ways — backing off a curve, carrying on round one, and the whole errand from the report: take a cut off the Running Track into a siding, set it out, and come back for the industry. That last one fails on 0.4.4 with "the crew is stranded on the siding — it cannot return to the Running Track", which is the report in one line.
sim.test.ts needed a wider sample rather than a lower bar: crews now have real switching to do, so
the bot lays fewer track pieces per game and five seeds no longer produced the 40 placements the
dead-end rate is measured over. Eight seeds now, same stride; the rate itself came out at 0.147
against a bar of 0.25.
0.4.4 — 2026-08-14
Out of a play session: engines that pointed north, a card whose name collided with five other things, a New Game dialog that only asked for a seed — and then, chasing a mirrored consist, two movement bugs that had been there all along.
Both playtest reports were confirmed against Jesse's own saved game,
docs/station-master-seed493290760-day2.json (seed 493290760, two Days, 127 intents), which is kept
as the evidence for what follows.
A curve is not a straight, and a train that rounds one knows it
Reported: "a train reversed into a siding and the display of the cars was reversed."
facing is the port the engine would leave by, and a Move recorded it as opposite(entry). That is
the far end of a straight and of nothing else: a curve is an arc between two ADJACENT edges, so a
train entering a north-west curve through its west port comes out facing north, not east. Every
train that rounded a curve was left facing a port its own card does not have.
Two things went wrong with that, and only the second was visible:
- Movement.
movesForexplores fromfacing, and a port the card lacks yields no destinations at all — so a crew that rounded a curve could only ever back out the way it came. It could not continue round the corner it had just taken. - Display. The east-west sense the board draws is carried from
facing, so a curve that had really turned the engine west could leave the board still drawing it east — the consist mirrored, which is what was seen.
The forward case now asks the card for its far end (farPort). The reverse case is unchanged and
was already right: backing up, the engine trails and points out through the port the train came in
by, whatever the track does underneath — there is a test pinning that so the fix cannot drift into it.
The reported moment, out of the save. Three times in two Days the crew backs off the Running
Track into the curved siding at (1,-2) — a sw arc, so entering it from the south leaves the engine
facing south. Intent #109, drawn both ways:
came from (0,0) office [ew] west cab tnk [>] east
BEFORE the fix west [v] box tnk cab east
AFTER the fix west cab tnk box [>] east
The engine was on the east end before the move and is on the east end after it — backing up does not
turn a train around. The old renderer flipped the whole strip to nose-left the moment facing stopped
being 'e', which is precisely the mirroring that was reported.
A train leaving a district drops its spur port
The same family, found while fixing the above. Nothing reset facing when a train left an Office, so
a crew that had been shunted onto a north-south spur carried a compass port out onto a Division that
runs east and west — and then into the next Office, whose card has no north or south edge at all.
With neither facing nor its opposite on the card, movesFor returned nothing in either direction:
the train arrived at the next Office unable to make a single Move. It also drew a ▲ on the
Division map, where there is no north to point at.
A train out there is running one way along an east-west railroad with its engine at one end, so
enterMainline now says so. This is the same stale port that made the ▲ on the Division map — the
railFacing change above stopped it being drawn, and this stops it existing.
Departures after Cargo: investigated, not a bug — but one card contradicts itself
Also reported: "a train left at the end of the Cargo phase — shouldn't it wait for the next Mainline phase?" It should not, and it did not: only Expedite trains do this, which is Q3 working as agreed. In the save it is 6 The Sparrow, twice — arriving in Stage 9's Mainline and highballing in Stage 9's Supervisor Shift, then the same in Stage 10 of Day 2. The one non-Expedite train in the game, 12 Drag Freight, arrived in Stage 8 and left three Stages later in a Mainline phase, exactly as it should. Measured more widely, over 40 bot games, the split is perfect: every departure with no Local Operations turn was an Expedite train, and every non-Expedite train got at least one.
Two things are worth writing down because they read as bugs and are not:
- Expedite departs in Supervisor Shift, not in Cargo. Q3 as recorded says the train "gets a
second
moveTrainin the same Mainline Phase"; the code deliberately does not, because that had expedited trains gone before a single Porter could reach them. Supervisor Shift is the last phase of the Stage, so it is still the Stage the train arrived in — it just looks like "I finished Cargo and it left", because Supervisor Shift needs no input and runs itself. - Expedite costs the train its Local Operations turn, since Local Ops is phase 1 and the train arrives in phase 3 and leaves in phase 5. Harmless for four of the five — Crack Limited, The Sparrow, the Military train and the Light Engine all print "no switching" and would decline it.
Except for 3/4 Express, which prints "may drop or pick up one freight car at every location"
and is also Expedite. That budget is spent by coupling and setting out, which happen only in Local
Operations — so the Express has a printed ability it can never use: 31 Office visits in 40 games, 31
with no turn to use it in. X14 Fruit Growers Express is in the same position, though its extra-reefer
line is a note today with no mechanics behind it. Left alone pending Jesse's call and recorded in
TODO.md; it is a rules contradiction on the card, not a fault in the timing.
Replays are replaced rather than piled up
Both fixes change which Moves are legal, so the published replays stopped replaying — correctly, and
the liveness test caught it. Re-recording used to write the new set beside the old one, and the
old set is precisely the one whose rules have just moved, so dead files accumulated and the test
failed on them forever. save-replay.ts now retires what it replaces (only once a replacement has
verified), and writes the rules block into each file so a replay can never again be silently
re-dealt. harness.test.ts was passing { seed, history } and dropping rules on the floor, which
would have replayed every published file under the pre-dialog defaults whatever it said.
The engine points east or west, always
Reported: a crew that turned onto a north-south spur was drawn with a ▲ over it, and the change of convention was harder to read than no arrow at all.
facing on a Crew Tray is a port — 'n', 's', 'e' or 'w' — because movement needs one: a crew
standing on a north-south spur has to be able to leave by 'n' or 's', and the last attempt to derive
east/west from the direction of the run stranded 29 of 62 leftover crews on north-south track with no
legal move. So the port stays exactly as it is, and what changed is the drawing.
A new railFacing on the tray carries the east-west sense across north-south track: it updates
whenever facing becomes 'e' or 'w' and holds its value in between. That is the railroad's own
convention, where compass north on a branch is still timetable east. It follows the engine around
180° of curves, because a train that runs forward through two curves really has turned around — and
it does not move when a train backs up, because a train that backs up has not.
It also fixes a bug nobody had reported yet. Nothing resets facing when a train leaves a
district, so a crew that shunted onto a north-south spur and then departed carried its 'n' out onto
the Division map — where there is no north or south — and drew ▲ there too.
The Frame's facing is now typed 'e' | 'w', so this is enforced rather than merely observed, and
the Office card lays a north-south crew's consist east-west like every other train instead of pinning
it nose-left.
The Yard mainline card is now the Interchange
Same 60, same "sort cars into any new order", same entry points, same art. What it did not have was a
name of its own: Division Yard, Classification Yard, Salvage Yard, Yard Office and Small Yard are
five other things in this game, and none of them is this card. The internal key is renamed with it
(MainlineKind 'yard' → 'interchange') so the two cannot drift. The other five are deliberately
untouched — they merely shared a word.
New game asks for the rules, not just the seed
It was a prompt() asking for a seed. Two of the three things that decide what kind of game you are
about to play had no way in at all: the opening hand had been changed twice with no way back to the
earlier rule, and the three revenue rates were constants in the source. Balance is the open question
this game has, and settling it means dealing several games at different settings — which needs a
dialog, not a rebuild.
Starting hand, three options, all of which have been the rule at some point:
- Three random cards (new default) — the prototype rule. At the hand limit already.
- Six random cards — twice the choice, still no guaranteed track.
- Three random track and three random non-track — the v0.4.x deal, from two shuffled piles.
Revenue, three rates, each 0–5:
- Passenger revenue per coach, default 1. Paid when a coach is boarded and again when it is detrained — both halves of the movement, as it has always worked.
- Freight revenue per load, default 1. Paid when a load is made up outbound and again when it is broken inbound.
- Train revenue per transit, default 0 — changed from 1. Paid to every player when a train runs off the end of the Division. At 1 it was worth ~5.4 Revenue against a bot mean of 7.0: the railroad was earning most of its money from the one thing nobody has to work for, and the freight and passenger economies the game is about could not be read through it.
Zero is a real setting rather than "one, suppressed" — no revenue event is emitted at all, so the history does not fill with "+0 Revenue" for work that did not pay.
A seed no longer names a game, so the settings ride in the URL beside it
(?seed=430&hand=sixRandom&passenger=1&freight=1&transit=0) and the header carries a readout of what
is in force. A playtest note reading "scored 4" is worthless without it.
Saves carry the rules they were dealt under. A save is a seed and a list of intents: replay it
under different rules and it is a different game, and the symptom is not an error but a replay that
quietly stops early — which TODO.md records happening twice unnoticed, one of them 42 intents into
360. Save.rules is written from now on, and a save without it replays under the pre-dialog rules
(3+3, and 1 per transit) rather than today's defaults, so the three published replays still run.
Under the hood. The settings live on GameConfig.houseRules as a partial, resolved in one place
by houseRules(), which clamps and rounds — so a hand-edited URL cannot deal a game at 900 Revenue a
coach. They reach the page on the Frame rather than off the config, because a remote client holds
no GameState and still has to be able to answer "what does a load pay here?"; createLocalSession
takes house rules rather than a whole GameConfig for the same reason.
Exercised, not just compiled. Ten developer-bot games per setting, solitaire Standard — far too small a sample to conclude anything about balance, and enough to show the dials are wired to the game rather than to the dialog:
| Setting | Dealt | Mean Revenue |
|---|---|---|
threeRandom (default) |
3 | 4.00 |
sixRandom |
6 | 2.00 |
threeTrackThreeOther |
6 | 0.50 |
| pax 1 · frt 1 · trn 0 (default) | — | 4.00 |
| pax 1 · frt 1 · trn 1 (the old rule) | — | 9.30 |
| pax 0 · frt 0 · trn 0 | — | −0.20 |
| pax 5 · frt 5 · trn 0 | — | 20.80 |
The transit rule is worth +5.30 here against the +5.4 measured over 200 games before it became a setting, which is the number this change was made to get out from underneath. All zeroes lands just below zero — collision penalties, with nothing left paying — which is the right shape for an economy switched off. Read no balance conclusion from the two random-deal rows: ten seeds is noise, and the hand rows are three different games rather than the same game dealt differently.
Tests. 513 passing, six of them driving the dialog through the emitted bundle — the failure
mode here is wiring, not logic. Four existing tests had to be re-pinned rather than merely updated:
the opening deal changes the RNG stream, so advance.test.ts was relying on whichever mainline card
seed 1 happened to lay down and now pins its own terrain, and the widest action list was re-measured
over five seeds in all three deals (13 under the random deals, 10 under 3+3).
0.4.3 — 2026-08-14
A load has to have somewhere to go
Jesse walked through how loading an industry is meant to work at the table, and the engine got four of the five steps exactly right. It got the first one wrong, in both directions.
You could stage cargo against no car. §9.3 requires an empty car of the right type standing on the industry's track before anything else happens — "otherwise you are just dropping cargo onto the tracks, pointless waste". The engine checked for that car only at the last step, when the load came off WORK. So you could fetch cargo with the Freight Agent, walk it M→A→W across three Stages and three Laborers, and only then find there was nowhere to put it. Worse than waste: from the moment the load lands on M the industry track is locked, so no train can come in to spot the car you now need. The only way out was a Freight Agent unjam — another whole turn, to undo a move the rules should never have offered.
Now gated in three places, all counted rather than merely present:
freightAgent.stockOutbound— no cargo is fetched unless a matching empty is spotted and not already promised to a load already in the box or on the sign.laborer.startLoad— the same check again, because it is not redundant: coupling is mandatory, so a crew running over that industry track must pick up the spotted empty, and the cargo you staged an hour ago can be left with nothing.laborer.beginUnload— the mirror. The red Inbound box is where an inbound load lands, and it was checked only at the final step too. Counted, because only the W box must be free to begin: once a load moves W→A a second can start behind it, and every red box in play holds exactly one car.
Strict type matching throughout — a hopper load cannot be swapped onto a tank. And startLoad no
longer always takes outboundBox[0]: it starts the first load that has a car to land on, so a Power
Plant holding a hopper load and a tank load against one spotted tank works the tank instead of
reporting the whole facility blocked.
Passengers are deliberately exempt and it is written down. People can wait on a platform for a train that has not arrived, so a Passenger Facility still stocks freely. Freight cannot: a crate on the ground is not a shipment.
Measured before building it, which is why it was worth building. Requiring the car removes 78% of
the stockOutbound moves the menu offered (704 → 157 across 200 games) — but 99.3% of the moves the
bot actually took survive (138 of 139). The rule deletes offers a competent player would never have
used. Bot unchanged at 7.3 mean. On the unload side it removes 2.2% of offers, and the jam it prevents
was caught happening three times in 200 games.
The Blocked panel says what to do, and in what order
The refusal codes never reach a player, because the menu simply stops offering the action — so with 78% of stocking moves gone the panel was the only thing that could explain it. It said "green box empty — nothing to load (needs a Freight Agent action)", which is step two told to someone who has not done step one. It now says to bring a car in first, names the commodity, and distinguishes "MEN is occupied" from "there is nothing here to load onto", which send you to fix quite different things.
Rolling stock is exactly conserved
TODO.md recorded the inbound path minting ~1.3 cars a game and blocked tuning ROLLING_STOCK_SUPPLY
until it was settled. Re-audited: exactly conserved, 100 games out of 100, range 0..0. The
asymmetry had already been closed from the other end when unloadBegan and passengersDetrained
started taking their replacement empty out of the Division Yard instead of conjuring it — a load is a
car that moved, not a car that appeared, which is exactly the tabletop procedure Jesse described.
Both conjuring fallbacks now throw instead of minting, so the leak cannot come back quietly;
neither fired across the suite or the audit. ROLLING_STOCK_SUPPLY is unblocked for the rebalance.
(The original audit's arithmetic was off in the same way mine was on the first attempt: cars set out on
a card live in card.standing and are easy to leave out of the count, which makes a conserved game
look like a leaking one.)
Replays
Two of five died on the rule change — they replayed 138/336 and 81/347, which on screen looks exactly like a game that ended early. Re-recorded; three published, all verified to their last intent.
0.4.2 — 2026-08-13
A completed run pays the whole table
The departure Revenue used to pay 1 to the Office a train left — "it cleared YOUR section". On a five-Office railroad that paid five separate times for one train, and paid most to whichever Office it happened to pass first. It now pays once, when the train runs off the end of the Division, and it pays every player: getting a train the length of the railroad is the shared achievement, and every Office it crossed had to clear it to happen.
The log says so in one line — "Train 5 has completed its run, leaving via the Eastern Division Point carrying 3 coaches. All players get 1 Revenue." — and because nobody took a turn to cause it, it is also announced on screen and given a sound of its own: two long horn notes falling away, the second lower and quieter. It is the only cue in the game that is not somebody's action.
Solitaire is nearly unmoved, 7.0 → 7.3 mean over 200 games, because one player's departures and completions run at almost the same rate. A multi-player game is a completely different shape and needs measuring once there is one.
Expedited passenger trains could never be worked, and now can
Reported from play: "passenger trains arrive at my Office and move on before I can load or unload." They did. Every coach-carrying express prints Expedite — 1/2 Crack Limited with three coaches, 5/6 The Sparrow with two, 19 Military with two — and Expedite was implemented as a second move inside the same Mainline Phase. Load/Unload runs after Mainline, so the train was always gone first. Measured over 60 games: Train 2 arrived 32 times and stood for a Load/Unload phase in none of them. Trains 4 and 6 likewise zero. The Crack Limited's own card says "stop at Terminals only", which it could never do.
An expedited train now stands through Load/Unload and departs at the end of the Stage instead. Q3's "departs the Stage it arrives" is still literally true — it does not lay over — and §8.1 still applies, so it can be held. Coach trains standing for Load/Unload went 116 → 229 across the same 60 games; Train 2 from 0 to 35, Train 6 from 0 to 17.
The cost is real and is logged as drift: a train occupying an A/D track and the Office square for a Stage is in the crew's way, so switching work fell ~16% (1.76 → 1.48 productive acts over 400 games) and the worst game went −3 → −9 as Offices fill. That is the change doing what it is supposed to do.
Three silences around passenger work, all of which said nothing at all
The refusal codes never reach a player — the menu simply does not offer an illegal action — so the Blocked panel is the only thing that can explain a missing button, and it skipped every non-freight facility. Its passenger section also only looked at trains carrying a loaded coach, so a train arriving to pick up produced no line at all.
Underneath, the engine's own answers were wrong in two ways worth fixing regardless:
- A Whistle Post reported
RESOURCE_SPENT— "all Porters already used this Stage" — to a player who had used none. Its Office card carries a passenger facility so that an upgrade is a property change rather than a card swap, and that facility hasporters: 0. NowNO_PORTERS_HERE, and the panel says to upgrade the Office. 15 occurrences in 60 games. NO_TRAIN_AT_OFFICEwas returned while a train was standing at the Office, as the catch-all for every other reason. 51 occurrences with a coach train in front of the player: 27 with nobody waiting to travel, 24 with passengers waiting and every coach already full. Those are nowNO_PASSENGERS_WAITING,NO_EMPTY_COACH,NO_LOADED_COACH,INBOUND_BOX_FULLandNO_EMPTY_COACH_IN_YARD, and each has its own line in the panel.
The panel also now warns, in amber, when the train in front of you is expedited: "it leaves at the END of this Stage, so this is the only Load/Unload phase it will stand for."
Replays
seed-6257010 died on the Expedite change — it replayed 161 of 335 intents, which on screen looks
exactly like a game that ended early. Re-recorded with save-replay.ts; seed-3334899 survived
untouched and was kept. Four published replays, all verified to replay to their last intent.
0.4.1 — 2026-08-13
§4.4's opening D12 now decides who sits where
It had been rolled and thrown away — void divisionRolls, with seating fixed by array order — so the
rule decided nothing, and seating was the identity mapping in every game ever played. seating[seat] = player now runs ascending by roll from seat 0 (west, beside the Western Division Point) to the last
seat: "highest is the Eastern Division Point".
The rule names only those two ends, because at a table the players are already sitting in a chain and
the roll only says which way round it is. There is no physical table here, so the roll orders
everybody — it uses a number every player is already told to roll, and it makes the roll matter to
more than the winner. Ties break toward the lower player index sitting further east, the same
first-max-wins convention argmax already uses for the Superintendent roll.
Turning it on immediately found three more of last commit's bug class. Acting order, the opening
deal and the Fedora all did (player + n) % players. Every one of them is a statement about the
physical chain — "starting from the Superintendent and proceeding left" (Gap 1, §4.7, §5) — so every
one of them is seat arithmetic, and all three were right only while seating was the identity mapping.
They now go through a new playerLeftOf(state, player, n). This is the payoff of the split being
exercised by real games rather than only by tests that rotate seating by hand: eight of these were
found by inspection last commit, and three more fell out of simply making the rule work.
state.openingRolls keeps both D12s, indexed by player, so a lobby can show the chain forming rather
than only its result (lobby-and-sessions.md §4). Frame.players gained seat for the same reason —
the list is in player order because it is about people, and a client that wants to draw the table
west-to-east now can.
Solitaire is untouched, and the proof is that it had better be: one player is one seat, so the
permutation is trivially [0]. All four published replays finish on their recorded Revenue (27, 31,
29, 28) and the bot is unmoved at 7.0 mean over 200 games.
Nine multiplayer tests failed on the change and every one of them was the test being wrong — each had
encoded the identity mapping, which is exactly what made them pass before. readyToLeave was putting
a player index into a tray's seat field; the Subdivision tests upgraded "player 1's Office" when a
Subdivision is a stretch of the physical chain; the Fedora test recorded player indices where §5 is
about seats. Two new tests replace the one that asserted the identity mapping outright: seating is a
permutation (and is still [0] in solitaire), and over 40 seeds the highest roller is easternmost with
the chain ordered throughout.
The intents are canonical; the event log narrates
The README, four architecture documents and six source comments all claimed state = fold(events).
It was never true, and it was load-bearing — the stated justification for reconnection, restart
recovery and persistence, none of which were built yet, so nothing had ever tested the claim.
Measured before deciding: advance.ts never calls reduce. Fourteen of the forty-six event types
are emitted after the phase driver has already mutated state — the clock, and the whole Mainline
phase. Folding the log rebuilds a district and not a railroad; every train movement in the game is
missing.
Settled the cheap way, because the plan never needed fold. Persistence is
{ engineVersion, seed, config, history: Intent[] } (multiplayer.md §10), the wire carries Frames
rather than events (D2/D3), and reconnection is a fresh Frame rather than an event tail — so making
the phase driver reduce would have been a rewrite of the most rule-dense code in the project to buy
something nothing uses. lobby-and-sessions.md §6 previously said "persist the event log"; it now
persists the intents, which are smaller still and which fromSave already replays.
test/events.test.ts pins the unreduced set as a deliberate change-detector: shrink it and the test
tells you which documents now understate the engine; grow it and you have added another event on the
mutate-then-describe path. It also asserts the property that does hold — that replaying the intents
reproduces the board, the score, the clock and the crews exactly — and that Save still carries
nothing but a seed and a history.
lobby-and-sessions.md reviewed, and four decisions taken
The persistence rewrite above left the document internally consistent but unreviewed — it was written
before multiplayer.md and contradicted it in four places, and the code in two more.
Contradictions with the newer plan: it had no access control at all ("a game code is the whole
discovery mechanism") where D14 added a server-wide join secret; its timeout table used the fictional
Switch.End-style names and omitted freightAgent.end, so a player who chose Freight Agent never
timed out; it opened "one actor at a time", which D19 stopped being true; and "do not substitute an AI
player" contradicted D8's bots-at-lobby-time.
Contradictions with the code: §4 described the opening D12 for the Eastern Division Point as
happening, when setup.ts:266 rolls it and does void divisionRolls — seating is array order, so the
rule decides nothing. PlayerDisconnected was written as an event; there is no such type, and it
should stay out of GameEvent, which must remain replayable from a seed.
Four decisions:
- The session token names the PLAYER, not the seat.
multiplayer.md§10 said seat; that became wrong in v0.4.0, and Employee Rotation is exactly the case where it bites — a token naming a chair seats a returning player in someone else's Office. Both documents now agree, and say the seat isseatOf(state, player). - 2 to 4 players, enforced in the lobby because the engine enforces nothing. Set to what is actually exercised rather than to the previous guess of 6. Noted as a lobby judgment, not a rules limit — the rules describe 5+ and the engine implements it.
- No forcing turn timer. Cut, on the same objection that keeps bots out of running games: the
clearance decision changes somebody else's score, so anything answering it automatically changes
the game. Moved to
TODO.mdto explore only if halted games prove to be a real problem, keeping the one piece of reasoning worth saving — that deny is the safe default, since a held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor. - A turn clock that records rather than enforces. Wall-clock per player per phase, so "how long does a 4-player game take, and which phase is the wait?" becomes measured instead of guessed — the one measurement bot simulation cannot produce, because the bot does not think. Explicitly outside the rules engine (which has no clock and must not acquire one) and outside the canonical record (a replay must reproduce a game from decisions alone). Phase 3, item 16.
- Plus: host rights pass to the earliest-joined remaining player if the host leaves before start, so no lobby is stuck behind a closed tab.
Documentation reconciled with the code
docs/design.md's status section was four milestones stale: a 115-card deck (it is 213 in play, from
a 235-card catalogue), 134 tests (497), a 0% win rate and 0.9 Revenue/Day (7.0 mean, 1.4/Day, 5 wins
in 200), and "Next: step 7 begins the server" when Phase 2 is deliberately held. Rewritten as the
shape of the project rather than a running tally, pointing at the CHANGELOG and TODO.md for
anything that moves — a hand-maintained tally is exactly what drifted.
The README also said the rules were fully specified, which overstates it: three questions are genuinely open — where the Local's coach stands while its engine works (a §A.4 question rather than a train-card one), Poling, the one card in the deck with no defined behaviour, and whether a Heavy Grade's orientation is rolled or chosen at setup. Thirteen prototype gaps were closed; these three came after, and the bullet now says so and names them.
0.4.0 — 2026-08-13
Phases 0 and 1 of the multiplayer plan — the seams, not the server
docs/architecture/multiplayer.md is the plan; this is its first two phases. Nothing a player can see
changed, and that is the exit criterion: the whole point is that solitaire is byte-for-byte the same
game while the code underneath it stops assuming there is only ever one of you.
Seat and player are now different things. They were the same integer everywhere, which is correct
today and wrong the moment Employee Rotation moves someone to a different chair. Offices and districts
are keyed by SEAT; hands, Revenue and the Fedora belong to the PLAYER. seating[], seatOf and
playerAtSeat make the mapping explicit — it is still the identity mapping, so nothing moves yet.
The split immediately found a real bug: awardDeparture was indexing s.players with a seat. Correct
under the identity mapping, silently paying the wrong railroad under rotation, and unfalsifiable in
solitaire. That is what the change is for.
Per-player turn state. turn: TurnState became turns: Map<PlayerIndex, TurnState>, one per
player at phase entry, read through turnOf(s, player) at ~50 sites. Behaviour-neutral by
construction — the phase cursor still walks one player at a time, and every existing test passed
unchanged. It is done now rather than in Phase 2 because it is a wide, mechanical change to the engine
that costs almost nothing today and would be a rewrite once a wire format depends on the shape. What
it buys later is players acting in parallel where the rules allow it, which is where the latency in a
turn-based game over the internet actually lives.
The page renders from Frame + Menu alone. main.ts had eleven reads through into GameState
— the deck, the RNG, other players' hands — each one a thing a server would never send. Frame grew
option, status, outcome, players[] and handCount to cover them. A test now reads main.ts
and fails if a reader comes back, because the cheap moment to catch that is now and not in Phase 2.
A Session between the page and the game. LocalSession runs the engine in the browser exactly
as before; a RemoteSession will hold no authoritative state at all — it cannot, having neither the
deck order nor the other hands. So the interface is deliberately the smaller of the two, and what only
a local session can do (undo, local save, dealing a new game) is declared in capabilities, which the
page reads to hide those controls rather than calling them and failing. submit is async even though
the local one answers immediately: a page written against a synchronous submit would have to be
rewritten for the server.
test/session.test.ts is the proof — 13 tests, the first of which plays seed 77 to a finish through
both routes and asserts the boards and the histories are identical.
Docs. protocol.md was written from the rules before the engine existed and had become a second,
drifting copy of intents.ts and events.ts; it now points at them and keeps only what the types
cannot say — what is deliberately not an intent, the two loops a client must not flatten, and the
redaction surface. Three architecture documents still said WebSocket where D5 chose SSE + POST; fixed.
Then a review found the seat/player audit was half done, and it was right. Two flagged sites, and sweeping for the pattern found six more. All of them are correct today and all of them break the moment Employee Rotation lands, which is exactly the failure mode the split was supposed to end.
Keyed an Office Area by player when officeAreas is keyed by seat: refusesThisOffice
(apply.ts — the Crack Limited's terminals-only rule), spendDispatchBonus (advance.ts — the
Fedora is held by a PLAYER, the Telegraph is installed in a SEAT), impediments (narrate.ts), and
two district lookups in the bot. adTrackCount(s, player) in narrate.ts was passing a player into
a SeatIndex parameter — the type said seat and the caller said player, and TypeScript cannot tell
two number aliases apart, which is precisely why this needs a test rather than a type.
Read seat 0 regardless of the viewer: the bot's takingRank ranked a face-up Office card against
seat 0's Office tier for every player, so in a multi-player bot game everyone chased player 0's
upgrade. stats.ts and save-replay.ts also index seat 0, which is correct — they are solitaire
summaries — and now say so through areaAtSeat instead of looking like the same bug.
And Frame was seat-scoped in the board and the hand but not in four fields beside them.
revenue, moves, blocked and the pace note all still reported player 0. A player would have been
shown someone else's score, someone else's Moves remaining, and someone else's jammed facilities —
that last one a list of squares that are not on the board they are looking at. Worse, the crew lookup
was keyed by row,col alone while every district shares an origin, so a crew standing at (0, 1) in
one Office Area was drawn at (0, 1) on every player's board.
Five new tests, all of which fail against the previous commit: per-seat Revenue/pace/impediments,
crews staying on their own board, a full 3-player game played with seating rotated before the first
move, impediments following a player to their new chair, and a source-level guard that fails if
anything outside state.ts/apply.ts touches officeAreas directly — the class, rather than the
eight instances of it found by hand.
Bot unchanged at 7.0 mean over 200 solitaire Standard games (the bot fixes are behaviour-neutral with one player), and all four published replays still play to the end — which, for a change of this width, is the whole report.
0.3.1 — 2026-08-13
Groundwork for multiplayer
Two steps that hold whichever way the hotseat-or-server question goes, plus a deck change.
The 22 opponent-directed cards are out of every deck, not just solitaire's. Q6 removed them from
solitaire because they have no legal target with one player; they are out of the competitive deck now
too, because checkPlay answers both categories NOT_IMPLEMENTED and dealing them would make ~9% of
draws reject outright. buildDeck returns 213 in both modes. DECK_SIZE (235) is now documented as
the CATALOGUE rather than the size of any deck in play — the two had quietly become different numbers.
Three Enhancements (Facing Point Locks, Water Column, Overpass) exist only to answer these cards and
stay dormant until they return; recorded in TODO.md as multiplayer work.
A net under the multi-player engine paths — test/multiplayer.test.ts, 13 tests. Everything else
in the suite is solitaire, so these had been running unwatched. They cover 2/3/4-player games playing
to a finish; per-seat Office Areas and Crew Tray counts; the Fedora passing to the next seat, only on
a shift boundary, reaching every seat over a game; Subdivisions splitting at the Offices that have
upgraded and not at the ones that have not; one player's oncoming train barring another's departure,
and no longer barring it once a Control Point puts them in different Subdivisions; and that
awardDeparture pays the Office that ran the train rather than seat 0 — which with one player was
unfalsifiable.
Two failed when first written and both times the TEST was wrong, not the engine: the Superintendent
cases drove the clock by calling advance in a loop, which never moves it — advance stops and asks
for input, so the game has to actually be played. The engine's multi-player paths passed everything
on the first honest run, which is the useful result here.
snapshot and actionMenu take a seat. They were hardcoded to player 0 in eight places across
view.ts, game.ts and main.ts — correct with one player, and a quiet disaster with more: every
seat would have been shown player 0's railroad including player 0's hand, which is the one thing the
state model calls secret. All eight now take a viewer, defaulting to 0 so solitaire and every replay
are untouched, and describeIntent now describes an intent against the acting player's district
rather than seat 0's. A test shows three seats getting three different boards and three different
hands, so the parameter is exercised rather than merely present.
Bot unchanged at 7.0 — solitaire never dealt the opponent cards, so none of this moves it.
0.3.0 — 2026-08-12
Sharp curves are out of the deck
Eight cards, dealt zero copies. The only thing that made a sharp curve different from an ordinary one
was moveCost: 2, and nothing ever charged it — every switching move costs exactly 1, hard-coded — so
they were geometric duplicates taking eight draws from a deck the rebalance already considers too
diluted. track.test.ts even had a case called "a sharp curve differs from a curve only in the Moves
it costs" which asserted the two were identical, describing a difference that did not exist.
Jesse's call: take them out rather than build the Move cost, since a per-card movement cost is a change to the Move model and the rebalance can wait. The rows stay in the catalogue at zero, exactly as Poling does, so the design is still visible and the geometry still works if they are ever dealt again. Deck 243 → 235, track 104 → 96, solitaire 221 → 213.
Four tests broke on the new deck composition and none of them was wrong about the game — all four were resting on a single seed or a threshold that had drifted, so all four are now measured properly:
- The oscillation detector ran on one seed. Measured across sixteen: thirteen games show no aimless shuttling at all and three reach a run of five. So it is a minority behaviour, not the every-game waste the test was written for — it now asserts a rate (most games clean) plus a ceiling.
- Interlocking reaches the board in 7/60 games, down from 15/60. The bot has been pulled toward other work by departure Revenue and spends its opening on the track it was dealt. Floor lowered to match, and recorded as drift rather than quietly restated.
- The action list reaches 9 buttons, up from 8 — all of them Switching, because the opening deal grew districts from 17.9 to 20.3 cards and a crew has more squares it can legally reach. The list getting longer for a good reason, not the cross-products that test was written to kill.
- A test hoped seed 111 would deal an Office upgrade. It now puts one in hand.
Bot after all of it: 7.0 mean, median 5.0, wins 5/200, districts 18.8 cards.
The special-train rules are enforced
All nine. They had been declared on TrainRules and read by nothing, so a Crack Limited could shunt
an industry and a Military train could be worked by Porters — the restrictions are what make a
special train special, and none of them applied. Each is checked where the act happens, and a LOCAL
CREW (trainNumber: null) is exempt from every one of them: it has no card, so it has no rules.
- noSwitching (both expresses, Light Engine, Campaign, Circus, Military) — moving, setting out and sorting are all refused. All three are switching.
- dropOnly (X13 Appleseed) and pickUpEmptiesOnly (X22 Pee-Dee) — enforced on the MOVE, not on a coupling action, because coupling is mandatory (§A.4): there is no running over cars and leaving them, so the restriction has to bite on the move that would pick them up or it cannot bite at all.
- oneFreightPerLocation (trains 3/4 Express) — a budget per SQUARE rather than per turn, which is
what "one freight car at every location" says: the Express works a car here, moves on, works
another there. Dropping and picking up share the one budget, because the card says "drop OR pick
up". Kept in
turn.freightWorked, keyed by tray and square, cleared with the turn. - noPassengerWork (Military, Director's car) and terminalsOnly (Crack Limited) — Porters
refuse the train.
canBoard/canDetrainnow filter the trains at the Office rather than taking any of them, so the rule actually bites; a newpassengerRefusalworks out whether a card is the reason and says which, because "no train at the Office" was both wrong and unhelpful when there was a train standing right there. - stopThenExpedite (X17 Campaign) — the speeches happen at the first Office it reaches: that
arrival is an ordinary stop, and every arrival after it is expedited. A
speechMadeflag on the tray, alongsidestopPointClaimed, for the same reason — it is the TRAIN that stops, and an Extra runs once.
coachStaysOnStationTrack could not be built as intended, and the reason is worth recording. The
chosen reading was "the coach may only be set out at the Office", but §A.4 refuses the Office square
to every drop — "the Office track is Operational Rail, but Rolling Stock may not be left there" —
so "only at the Office" and "nowhere at all" are the same rule. It is enforced as the effect that
survives: the Local may shunt its freight car around the district and may not abandon its coach doing
it. If the station track is meant to become a real place to leave a coach, that is a change to §A.4
rather than to this card; noted in TODO.md.
copiesNextScheduled was deleted rather than implemented. No train card ever carried it: a
Second Section is a Maneuver card played on a train due out, with its own intent
(newTrain.secondSection) that has worked all along. It was an unreachable second description of a
mechanic that already existed, and it had been sitting in the "nine rules read by nothing" count as
the one entry that needed removing.
Cost, measured over 200 games: revenue 8.0 → 7.2 and wins 14/200 → 6/200. Restrictions make the game harder, which is the point — but it is worth knowing that enforcing §7 took back about a sixth of what the departure-Revenue rule gave. The train tooltip no longer says "NOT YET ENFORCED BY THE ENGINE"; it says what each rule does, because the restriction is the character of the card.
Two rule changes, both provisional, both measured
The opening deal is now 3 track + 3 other, from two separately shuffled piles. Track is shuffled apart from the rest, each player is dealt three of each, and the leftover track is shuffled back in for the rest of the game — the split is an opening-deal device only. A player opens holding six against a hand limit of three, so the first turn is spent choosing which district they can afford; §6.2 already permits "any or all" cards to be played in a turn and puts no cap on discards, so the reduction can always be made and the engine needed no special first-turn case.
This is the answer to the run-around problem, which was measured five ways and is a SUPPLY problem: a turnout and a matching-hand curve were in hand together on 0.2% of turns, about once every eight games. It worked, modestly — run-arounds 4/60 → 7/60, districts 17.9 → 20.3 cards — with revenue unmoved on its own (−0.1, inside noise). Nowhere near the 91/100 of the private-supply era, so the supply question is softened rather than answered.
One Revenue for every train that clears your section. Paid when a train is highballed out of your Office, once per train, and not again when it later runs off the end of the Division — that far Division Point belongs to whoever is seated at it. A local switching crew has no train number and earns nothing. Worth exactly +5.40 a game: departures run at 5.40 and each pays 1. The bot goes 2.7 → 8.0 mean, median 0 → 6.5, wins 2/200 → 14/200. Unlike freight and passengers it pays for traffic the player does not have to work, which is the point — keeping the line clear is the Superintendent's job and nothing paid for doing it well. It also makes the victory target live: 20 over 5 Days is now reachable largely on traffic.
A regression that came with the deal, recorded rather than hidden. Forced to shed on turn one,
the bot lays pieces it should discard: track butting a card that cannot accept it went from 7% to
15%. A player would simply discard the ones with nowhere good to go. The regression floor in
sim.test.ts was raised from 0.15 to 0.25 with the reasoning written in, and it is in TODO.md as
bot work for after the rebalance. It also means part of that district-size gain is padding.
Found while fixing the tests this broke. Three of them were passing on luck rather than checking
anything, and the deal change exposed all three at once: the commodity test asserted a tank car is
set out somewhere in 30 games when it happens in 3% of games; the replay-sound test asserted six
cue kinds from a single seed when the bot couples in only 20% of games and sets cars out in 43%;
and two web tests depended on a seed happening to deal a particular card. All four are now decisive —
pooled seeds, a sample sized to the base rate, or the card put in hand deliberately. Separately,
moveTrain's branch for departing an Office straight onto a Division Point turns out to be
unreachable: buildDivision always lays DP · Mainline · Office · Mainline · … · DP, so an
Office is never adjacent to one. It is kept correct rather than deleted, and setup.test.ts now
asserts the flanking invariant that makes it dead.
All five published replays were re-recorded: the deal changes what every seed deals, so the previous recordings died at 2 intents of ~350.
Nine things playtesting found
Eight fixes and one new rule, all from one session at the table. Two of the nine turned out not to be bugs at all, and saying why is most of the work in those.
Left and right were on the wrong diagonal — for turnouts and for curves, the same way. The engine
called a card left when a train entering at its points saw the diverging route leave to its
right: {stem:'w', through:'e', diverge:'s'} heads east, and south is the driver's right. Curves
inherited the same inversion, because a curve takes its hand from the turnout whose leg it continues.
Every track card a player has ever held was labelled as its mirror. The artwork was never wrong —
board-svg.ts draws rails from connectionsFor geometry alone — so this is words only, and the fix
flips the hand field on the TRACK_CARDS rows together with CURVE_VARIANTS and
TURNOUT_VARIANTS. The row ORDER is deliberately left alone: setup.ts builds the deck by walking
that array, so a row's position decides which physical card a seed deals, and reordering to look tidy
would silently re-deal every published replay. Verified by fingerprinting seed 1234's whole track
deck before and after — 101 cards, byte-identical, and the three replays that survived the rest of
this work prove it in practice.
A turnout can now be laid on top of a card already down. Reported as: a district can only hang off
a turnout, so a player who laid a straight along the main and then wanted to branch there had no move
at all — the piece had to have been a turnout when it went down. A turnout may now upgrade a straight
at any rotation, or a curve whose arc matches its own diverging leg. Both are strict port supersets of
what they replace, so an upgrade can never sever a join a neighbour relies on and needs no connection
test; the new leg may reach nothing, which is the point. Blocked on a standing car (UPGRADE_OCCUPIED)
and on a built Interlocking or Telegraph (UPGRADE_ENHANCED) — both about the card being in use rather
than its shape. The rule is stated in ARCS rather than hands so the flip above cannot reach it. The bot
found and played it 40 times across 40 games without being taught anything.
A Depot showed three MEN | AT | WORK boxes it has no Laborer to work. An Office is a Passenger
Facility and the sign is printed "For Freight Facilities" (§9.1), but every facility got a three-slot
array of nulls and the renderers loop it — the green and red rows either side were guarded and the
MAW row was not, in all three renderers. Fixed in the MODEL rather than the renderers: menAtWork is
now null on a passenger facility, which is what the "Freight only" comment on that field has claimed
all along. The type flushed out every reader, freight work is now refused on the shape of the facility
rather than on it happening to hold 0 Laborers, and a fourth renderer cannot reintroduce the bug. Two
more artifacts of the same "an Office is a Facility" modelling went with it: a Depot read
SHIPS + RECEIVES, an industry's flow word, and was drawn a siding square although its industry
track has length 0.
The side panel painted inbound loads green. Its shared box helper used one class for every filled box, so the red row rendered green while the board SVG on the same screen drew it red — two views of one card disagreeing about the colour code at the same moment. Green is outbound everywhere now, red inbound, grey for the siding, which is a place rather than a direction.
The card just drawn now sits first in the hand, badged NEW. The engine pushes onto the end, which
with a wrapping row put the card you just turned over wherever the eye is least likely to be, among
two others that look exactly like it. Reversed in the DISPLAY — actionMenu for the play page and
snapshot for both replay viewers — and deliberately not in the engine: the bot iterates its hand to
generate options, so moving the stored order would reshuffle its tie-breaks and invalidate every
revenue figure in TODO.md. Confirmed: 2.8 before, 2.7 after, over 200 games.
Two Ice Houses could be built 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. One of a kind per Office
Area now. Enhancements are deliberately left alone — an Interlocking is a plant at one junction, so a
second on another straight is a different installation. This is the change that killed a published
replay: seed-4894942 had recorded a game that played two Waiting Areas, so it was a recording of
illegal play and has been re-recorded.
NOT A BUG — "the Ice House added the laborer but not the outbound slot". The card prints +1
outbound; a Grocer's Warehouse is flow: 'inbound', so allows.outbound is false and the capacity
was being raised on a direction that can never be drawn or stocked. The laborer landed because
Laborers have no direction. An industry's printed flow stays absolute — no modifier turns a receiver
into a shipper — so the grant is now dropped rather than credited, and the reason is said out loud in
both directions: the card in hand names which of its printed hosts cannot use which half, and the
facility panel reports a suppressed bonus instead of quietly showing a number that did not move. The
same trap catches Truck Dock and Forklifts on a Grocer's, and Waiting Area, Restaurant and Hotel on a
Whistle Post. Two things fell out of this: TrackCard.modifiers was initialised everywhere and
appended nowhere, so the panel's "Modifier cards standing beside this industry" row had always been
empty; and a Waiting Area was lengthening the Office's industryTrack, which drew a Depot a siding to
spot cars on — the phantom siding above, arriving by another door. That one was found by rendering
12,000 frames of real games, not by a fixture, and it is why the smoke pass exists.
An Interlocking on the board was a bare label. It now carries hover text from the card catalogue,
and the one card nothing reads says so. Seven of the ten resolve in play: the dispatch chain
(Telegraph, Telephone, Radio) plus Interlocking, which holds an arrival at the Limits when the Office
is full; Yard Office, which diverts a coachless train; Small Yard, which re-orders a consist; and ABS
Signals, stored on the Mainline node rather than in enhancements[]. Facing Point Locks and the Water
Column are wired and read but answer opponent-directed cards a solitaire deck omits. Overpass alone
has no code path anywhere, and is the only one that carries the warning.
Got this badly wrong first time, and it is worth recording how. The table was filled in by
grepping for four helper function names — dispatchBonus, hasDistrictEnhancement,
isProtectedFromDerail, watertowersRemovable — and reading "no match" as "no implementation". But
Interlocking, Yard Office and Small Yard are read by key in advance.ts and apply.ts, and ABS
Signals through node.absSignals, so five of ten statuses were wrong and the tooltip told players
that four working cards did nothing — worse than the bare label it replaced. enhancements.test.ts
had covering tests for all four, passing, the whole time. The test written to guard the table made it
worse rather than catching it: it asserted effect === 'live' if and only if the rule carried a
dispatchBonus, which restates the assumption that produced the error instead of checking it. It now
asserts the three sets by name, and every row cites the file that reads it, because a status without a
citation beside it is a claim nobody checked.
Three rules read back from the sheet
Jesse's follow-up after the above. Two were already right, which is worth recording so nobody re-investigates them; one was a real gap that had been sitting in plain sight.
Clearance asked about the next CARD, not the next Subdivision. §8.1 is explicit — "if there is a
train in the next Subdivision moving towards the considered train, the considered train will not
depart" — and evaluateClearance only ever inspected node.transits, the single card being entered.
A train ran headlong into a Subdivision an opposing train was two cards deep in and was stopped only
on the Stage they actually met. subdivisions() had been sitting in state.ts for this the whole
time, called by nothing outside a test. Red Flags and ABS Signals now read the card the OTHER train
stands on rather than the card being entered; those were the same card while this only looked one
ahead, and the protection belongs where the train it protects actually is. Worth knowing how much
this bites: every Office starts as a Whistle Post, so the whole railroad is ONE Subdivision until
someone upgrades, and each upgrade to a Control Point splits one in two and buys capacity back. The
bot is unmoved (2.7 both sides, 200 games) because solitaire schedules only ~1.3 trains a game —
this is a rule that earns its keep with 2–5 players.
Straight-placed enhancements replace the straight, so they no longer stack. An Interlocking's
printed placement is "any Running Track Straight": it goes down IN PLACE OF the straight, and what
stands there afterwards is an Interlocking, not a straight carrying one. Nothing checked it — an
enhancement only pushes a string onto enhancements[] and leaves the geometry alone — so a single
straight could hold Interlocking, Telegraph and a Water Column at once. The Telegraph → Telephone →
Radio chain is untouched and is not an exception: Telephone prints "on Telegraph" and Radio "on
Telephone", so those target a named card rather than a straight, which is exactly why they still
stack. The printed placements settle it; requiresOnSameCard already enforced it.
ALREADY CORRECT, twice. Control Points and Subdivisions were modelled properly — isControlPoint
is false only for a Whistle Post, and subdivisions() splits between them — so only the clearance
use of them was missing. And §8.2's "moved immediately to the Office, where it stops for orders" was
already the behaviour: arriveAtOffice returns moved, and the caller adds the train to
movedThisPhase. The one exception is deliberate and recovered — Q3, an expedited train departs the
Stage it arrives, and gets a second moveTrain still subject to §8.1.
A missing §8.1 condition, restored to the ruleset. Jesse read the highball conditions back off the
source sheet and the first one — "the train did not just initially arrive from a mainline card at the
Office this Stage" — was absent from rules-v0.2.md entirely. The BEHAVIOUR was right, but only as a
side effect: the Mainline Phase visits each train once per Stage in numeric order, so a train that
spends its visit arriving has no visit left to depart with. Nothing anywhere stated the rule, which
makes it precisely the sort of property a later change to that loop breaks in silence. Now written
into §8.1 and pinned by tests, together with the observation that this is the condition the printed
Expedite rule exists to override — without it, Expedite was a special case with no general rule
to be special against, which is why Q1/Q3 found it so hard to place. All six conditions now have
coverage, including the Gap 2b carve-out: a train standing clear on Secondary Track in a Whistle Post
district must not hold up the Subdivision, which the new subdivision-wide scan had to be careful not
to break.
Verification. 438 tests (up from 416) and a clean tsc. Beyond the unit tests: the track deck
fingerprint before and after the flip; all published replays replayed end to end; the bot baseline
re-measured against a worktree at HEAD; and 40 real games rendered frame by frame through the same
functions the page calls — 12,169 frames, which is where the Waiting Area siding surfaced. Not done:
nobody has clicked through this in a browser.
0.2.0 — 2026-08-09
The bot plays twice as well, and a rule it was never following
Adopted, after measuring: cap the trains at the Office's A/D capacity, and operate before
drawing. Together +1.52 ± 0.17 (t = 9.16) over 1600 paired seeds — revenue 1.19 → 2.72, wins
11 → 21 in 1600, collisions 0.38 → 0.02. Both are now default play; the flags that carried them are
gone, and what remains in BotTweaks are two ABLATIONS that turn them off, because the question a
measured heuristic needs later is "is this still true?" rather than "does this help?".
The six candidates that measured neutral are deleted rather than left switched off. Adding all of them on top of the two winners was worth −0.08: +1.44 against +1.52 without.
Jesse's idea — build first, then switch — was right about the axis and wrong about the half that mattered. Reserving Day 1 for development measures +0.52; reserving none and simply preferring the work to the draw measures +0.48 with a far better spread (317 seeds better against 77, where the build phase gives 329 against 144). With the cap alongside, no build phase beats one Day beats two. So the keeper is "operate rather than draw", and the building phase — the intuitive part — is inert: the draw-priority ordering was already developing the district well enough.
It works through the constraint the funnel had been pointing at all along: green-stocked Cargo phases +60%, freight revenue +39%.
And then the same tooling proved that constraint was the wrong one. Stocking green boxes speculatively — filling them before a car is spotted — raises stocked phases five-fold, from 5.6 to 28.8 of 60, and moves freight revenue by nothing at all (1.16 → 1.15, and −0.10 revenue overall, t = −3.62). The green box was never the gate. The gate is the spotted car, and the 8% figure was low because stocking is conditional on a car being there — it was measuring the car supply all along. Switching quality, not Freight Agent turns, is where the freight economy is won.
The engine was not following two rules it prints
Chasing the Rolling Stock census found the cause, and it is not a modelling ambiguity after all. §9.3 says an unload requires "an empty car of that type in the Division Yard" and §9.2 says de-training requires "a white empty coach in the Division Yard". Neither requirement was checked, and both reducers conjured the replacement car instead of taking it — so every unload and every de-training minted a car, 1.29 a game against a supply of 80.
Both now take the car from the Division Yard, as the rules say. The census is conserved: censused after every batch across 80 games, nothing changes it but a collision, where before it drifted to 119 against 80. A test does that check now, because this class of bug produces no symptom until a supply number is tuned against it.
It costs the bot about 0.09 revenue — unloading is meant to consume supply — and that is the point:
ROLLING_STOCK_SUPPLY can be tuned against reality now.
Where the game actually stands
With a bot that no longer wastes revenue, the balance question resolves. Revenue is linear in trains scheduled at about 1.9 a train, and trains are capped by A/D capacity, which is the Office tier, which is a card you have to draw. 37% of games never leave the Whistle Post; 53% earn nothing at all; the median game scores 0 and the best of 800 scored 26.
Twenty Revenue would need roughly eleven trains and eleven A/D tracks. A Terminal has four. The
target is not missed, it is unreachable — and that is now a deck question with numbers behind it
rather than a suspicion. TODO.md carries the three ways out.
Five ways to teach the bot to plan a siding, and why none of them can work
Asked directly whether the bot could think across turns and stop building dead-end stubs. It can be taught to; it does not help, and finding out why was worth more than the attempts.
| attempt | result |
|---|---|
| hold ALL track for the siding | −0.70 (t = −3.27) |
| hold only CURVES — the only piece that can climb back to the main | −0.26 (t = −3.22) |
| finish an open run before cutting another way down | 0.00 — 398/400 identical |
| treat a second turnout as the piece that closes the loop | 0.00 — 400/400 identical |
| spend a curve only on a square that actually closes a run | −0.11, 15 games in 400 differ |
The pieces never meet. Over 12,000 Local Operations turns, a turnout and a curve are in hand together on 0.3% of them, and a turnout with a MATCHING-hand curve on 0.2% — about once every eight games. A run-around needs five specific pieces of the right hands arriving in a usable order, and the bot does not reach the two-piece prerequisite, let alone the fifth.
It is not hand pressure, which is what the first two attempts assumed. The hand is full: mean 2.66 cards, at the three-card limit on 78% of turns. The bot plays 11.4 track cards a game against 1.5 discarded, spending each piece as it arrives because a piece that builds something now outscores holding one that might build more later — and the measurements say it is right to.
So the dead-end stubs are not a planning failure. They are what a five-piece structure looks like
when the pieces are drawn one at a time from a 243-card deck: 91 run-arounds per 100 games when
track was a private supply the player chose from, 29/100 once track was drawn, 4/60 today. TODO.md
carries the options, and all of them are deck changes rather than bot changes.
Ten ways to make the bot better, and one of them works
Every heuristic below was measured paired over 400+ seeds, and confirmed at 1600 before being believed. Nothing is adopted yet — every flag is off, so the bot plays exactly as it did.
The only thing that moves revenue is refusing to schedule a train the Office cannot hold.
trainCapSlack=0 — never more committed trains than A/D tracks — is +1.09 ± 0.16 (t = 6.79),
taking the bot from 1.19 to 2.28 mean. Every other reordering of the bot's preferences measured
inside the noise. Adding all six of the small ones on top of the cap is worth a further 0.10.
Three failures worth more than the success.
Refusing to bury the engine costs 0.35 a game (t = −2.98). The tweak works perfectly — burial falls from 8.4 decisions a game to 0.03 — and freight halves along with it. Coupling is mandatory (§A.4), so the moves that bury the engine are the moves that pick cars up. Burial is the price of collecting, not a mistake to be coached out. The refinement that only refuses to ARRIVE at the Office buried is +0.16 and inside the noise.
Reserving Moves to get home costs 0.55 (t = −2.32), even though 62 of the 120 trains still on the board at game end were stranded in the district, unable to depart from anywhere but the Office. The switching work is worth more than the departures it forfeits.
Granting clearance when the train ahead has one Stage left costs 0.98 (t = −5.24) — a clean rejection of a rule that looked safe. Q13 collides on catching up, so a follower let onto a card whose occupant is leaving "cannot" catch it — except trains move in numeric order, so the follower can enter the region the leader still occupies before the leader has moved. "About to leave" is not "gone".
And three exact no-ops, each for a different reason. Playing Interlocking ahead of a train card changed nothing because the two sit in hand together 0.04 decisions a game. Stocking the Office platform first changed nothing because the bot already chooses it 79% of the time. Spending an idle turn on the Freight Agent changed nothing because it asked the same predicate a branch three steps earlier already acts on — a tautology, and 400/400 identical games is what one looks like.
The funnel explains the pattern: 8% of Cargo phases have a stocked green box, and the bot already takes 42% of the turns where stocking is productive. There is nothing to prioritise better. What remains is the economy itself, which is a deck question rather than a bot one.
A conservation audit, and the bug it found
Clearing an inbound box mints a car — 1.29 a game against a supply of 80. TODO.md had this as
an open question ("no way to tell a load from a car"); it is duplication, and the two directions are
not symmetrical:
- Outbound is paid for.
stockToOutboundsplices a loaded car OUT of the Division Yard to become the load, andloadCompletedbanks the emptied car as the loaded one takes its place. - Inbound is not.
unloadBeganturns one loaded car into an empty car plus a load on MEN|AT|WORK,passengersDetraineddoes the same to a coach — andinboundClearedthen pushes that load into the Classification Yard as a car, while the car it came out of is already back in service.
Over 200 games, cars-at-the-end minus 80 plus collision losses equals the inboundCleared count
exactly in 71/200 games and 258 against 279 overall; the remainder is loads still in flight at the
final whistle. Since ROLLING_STOCK_SUPPLY is the number that is supposed to set supply pressure,
no supply figure can be tuned until this is decided. Not fixed here — it is a modelling decision.
Also found: state = fold(events) is not literally true. Replaying the event log onto a fresh
state throws, because the phase driver mutates state directly and emits a descriptive event
afterwards. Replay works by re-applying intents, not by folding events. Nothing is broken today, but
the README claims the property and reconnection would rest on it.
The replays were all dead, and now they cannot be
All three published replays managed 2 intents of roughly 400 — the site was serving three
recordings of nothing, exactly as TODO.md predicted would happen silently. save-replay.ts records
bot games as saves, and verifies every one round-trips before writing it: same revenue, same Day,
same intent count. harness.test.ts fails if any published replay stops short of its own history.
Six replays published, 18 to 23 Revenue, against a target of 20 and a bot median of 0 — including one game with four collisions that still cleared the target, and two with none.
0.1.1 — 2026-08-08
A way to tell whether a change to the bot helped
The bot averages 1.4 Revenue against a target of 20, and the obvious next step is to teach it to
play better. That step could not be taken honestly, because there was no way to tell whether a
heuristic had helped: revenue has σ ≈ 9 across games, so two runs of the identical bot differ by
about a point through nothing but the deal. Every single-change claim in this changelog before the
Interlocking work is inside that noise, and TODO.md has said so for a while.
node src/sim/compare.ts 1600 trainCapSlack=1 runs the current bot and one variant over the same
deals and reports the per-seed difference. Giving both sides the same seed takes the deal out of the
comparison: σ drops from ~9 on the level to 5.3 on the difference, and 1600 seeds puts the
standard error at ±0.13 — in 1m45s. The noise floor moves from ±1.0 to about ±0.15, which makes
every heuristic in the training plan resolvable. No parallelism needed; 100 games take 4.8s.
The report prints the better/worse/identical split beside the mean, because they are different
claims. The first tweak measured is the case in point — trainCapSlack=1 over 1600 paired seeds:
REVENUE DELTA +0.77 ± 0.13 (t = 5.97, σ of the paired difference 5.13)
seeds better 146 · worse 105 · identical 1349
best seeds: +75, +50, +46 worst seeds: -26, -20, -13
84% of games are untouched. It does not make the bot play better; it removes a rare catastrophe, and the mean rides on a handful of rescued games. Reporting that as "revenue up 65%" would be arithmetically true and misleading about what changed. At 400 seeds the same tweak read t = 2.57 — "not proven" — which is exactly the verdict it deserved there.
The tweak is measured but not adopted: the flag stays off, so this commit changes no bot behaviour. Turning it on is a change to how the bot plays and belongs in its own reviewed commit.
And it prints the funnel for both sides, because revenue can rise two ways: a channel started working, or an expensive channel was abandoned for a cheap one. A strict train cap raises revenue and cuts freight events nearly in half, and that has to be visible rather than inferred.
The funnel is new — GameStats.funnel, sampled live rather than recovered from the log, because
the interesting gates are conditions rather than occurrences. "Was a green box stocked while a car
was spotted" is not a thing that happens; it is true or false at a moment. It needed a per-decision
hook on playGame (TurnObserver), separate from the existing event observer, so a phase is
sampled once instead of once per event in the batch. What it says about the current bot:
- Passengers: of 7.4 arrivals a game, 70% reach a Passenger Facility, 25% carry the empty coach boarding requires, 21% the loaded coach detraining requires.
- Freight: of 60 Cargo phases, a green box is stocked in 8% and all three requirements meet at one industry in 7%. Freight is gated almost entirely on stocking.
- Stuck: 8.4 decisions a game are taken with a train's engine buried mid-consist, and on 3% of them is there a legal way to set the nose cars out — because it happens at the Office, where Rolling Stock may not be left.
developerBot is now makeDeveloperBot({}), byte-identical to what came before (asserted on
three seeds by full event-stream fingerprint, and 1.4400 mean on 100 games either way). Variants
exist so two policies can be compared in one process rather than by editing the bot between runs —
which is how you end up comparing two things you cannot reproduce. Tweaks are temporary: a flag that
measures well becomes the default and is deleted in the same commit.
The first tweak found the bug this tooling exists to find, twice over. trainCapSlack caps
committed trains against the Office's A/D tracks. Written first as a gate on the two branches whose
comments say they exist to play a train card, it measured exactly zero difference over 400 paired
seeds — because followThrough ends with a generic "play what is in hand" fallback that played the
card anyway. A cap has to remove the option, not guard the branches that reach for it.
Then the test for it was wrong in the same shape: it asserted only that some decision changed somewhere, and passed against the broken bot, because a tight cap does change which Local Operations option gets chosen — it just fails to stop the card being played two steps later. It now asserts the contract (committed trains never exceed what the Office can hold) and is verified to fail against the broken version. "Something moved" is not the promise.
Also new: a determinism self-check. The same policy on the same seed must produce an identical event stream, since the paired method rests on it and the failure mode is silent.
Tests 403 → 413.
0.1.0 — 2026-08-08
The first numbered build. Everything below the "second playtest pass" heading was made under
0.0.1, across twenty commits, which is exactly the problem the version convention above exists to
fix: "the current build" was only ever answerable by a commit hash.
A third playtest pass — and three of the "display problems" were engine bugs
Fifteen items came back from two playtest sessions. Several of the ones that read like drawing faults were not: the board was reporting the game accurately and the game was wrong.
Cars could be added to a train that was not being made up. newTrain.placeCar accepted any tray
with room in its consist, while the New Train Phase's own idea of the train it is waiting on —
trainNeedingCars — requires the tray to be at a Division Point and short of its card. So during
any New Train Phase the Division Yard would hand cars to a train standing on a siding in your own
district, or one halfway across the Division. Measured before the fix: 50 such offers across 8
solitaire games, including Train 9 mid-crossing with three cars already aboard. That is the
"cars magically appeared on my train" report, and the answer to it.
The two questions — "is this the train being assembled?" and "may this car be added?" — now come from
one predicate (isBeingMadeUp, in apply.ts beside check), which is what stopped them disagreeing.
newTrain.passCar takes the same guard.
The make-up panel merged two trains into one. Two trains can be built in the same Stage — a timetabled train and a Second Section, or an Extra — and the panel collected every option from every tray, titling the result with whichever tray came first out of the map. Reproduced at seed 99, Day 3 Stage 12: eighteen car chips under "Making up Train 8", covering two different trains. Worse than the wrong caption: the Division Yard chip binds to the FIRST matching option, so clicking a hopper could couple it to the other train. The panel is now scoped to the one tray the engine is waiting on. This was reported as the history announcing Train 9 while the panel said Train 10.
Upgrading the Office deleted Modifier bonuses. officeUpgraded wrote the new tier's printed
numbers straight over the facility, so a Restaurant beside a Depot — +1 passenger out, +1 porter —
was silently erased by the next upgrade, after the card had been spent. The tier is applied as a
difference now, so the upgrade raises the Office by exactly what it is worth and leaves what is
standing beside it alone.
A sentinel inside a coordinate's own value range. ABS Signals goes on a Mainline card, and the
node index travelled as the fake coordinate { row: -1, col: node } — but row −1 is an ordinary
district row, the first one below the Running Track, where most districts start. So ABS Signals
highlighted whichever district card sat at that column, and an ordinary Enhancement laid one row down
was described as being "out on the Mainline, node −2". card.play now carries node as its own
field and a Mainline placement has no coordinate at all; the board simply does not light up for it.
This was the other half of "why is my Depot highlighted?" — the first half being that Enhancements
legitimately attach to played cards, which nothing on screen said.
The train, drawn the way it stands
consist is ordered nose first, and both renderers drew index 0 leftmost whatever the train was
doing. A westbound train therefore came out right and an eastbound one came out mirrored —
reported from seed 270861860, Train 10 running east and drawn engine-first at the WEST end, which
reads as an engine shoving its whole train ahead of it. The board is a map, so the drawing obeys the
map: west on the left, nose toward the way the engine faces. A crew on a north-south spur keeps
nose-left and lets its ▲/▼ say the rest.
The Division chip draws the train now. It was a name and a figure, and the figure was Stages left
to cross — read once as the car count, and once it was labelled · 2⧗, read as redundant beside the
position the card already draws. It is: the region position is DERIVED from that countdown. So the
chip carries the consist instead, in the same vocabulary the district card uses, and the countdown
moved into the tooltip where there is room to say what it is. Stages are not regions — a card is two
regions of fixed distance, and how many Stages a train needs over them depends on the card speed and
the train (crossingStages); they coincide only for a 30 card and a normal train.
Loaded or empty, told the same way in both places. An occupied slot on a district card took a generic blue fill and only a LOADED car overrode it, so at 26×15px empty read as blue-ish and loaded as brown-ish — while the tray beside it made the same distinction unmistakable. Reported as "I left a car in a siding and now I can't remember if it was loaded". The card now matches the tray, and every slot and every car in a consist carries its own words as a tooltip.
Saying what the game already knew
Why a switching move is not offered. The switching game was played off a list of coordinates —
move to (0, -2) as a button, nothing on the board, and no account at all of the squares missing
from the list. exploreMoves returns the walk's REJECTIONS alongside its destinations, out of the
same traversal, so a reason on screen is the rule that actually refused the square rather than a
second guess at it. Five conditions: the rails do not meet, another train is on the card, the
industry is locked by MEN AT WORK (§9.3 — no train may enter it or cross it), the coupling would
overfill the consist, or it is a turnout you may run through but not stop on.
The board now shows where the crew is, where it may go, and marks the rest with its reason on the card's own tooltip. A turnout is drawn differently from an obstruction, because it is not one — and it is kept out of the Blocked panel, where every turnout in the district would otherwise appear.
Mandatory coupling is named on the button. "Move to (0, -2)" becomes "move to (0, -2) — couples loaded hopper, caboose on the way (onto the nose)". Coupling is compulsory (§A.4) and was announced only in the history panel, which is the one place a player is not looking while switching. 70 move buttons in five games now say what they will pick up.
The workers are on the card. "How do I see the number of laborers in an industry card?" — you
could not; the number that decides every Cargo phase was in a side panel. Porters were worse:
porters had been on the view-model all along and no renderer had ever drawn it, which is why a
Restaurant's +1 porter had "no indication anywhere". Both are on the card now as free-over-total, and
the Facilities panel shows porters beside laborers.
Ships-out and receives, in words. A Refinery and a Grocer's Warehouse were distinguished by the
stroke colour of one 13×12px box and the direction of a 10px chevron. Rendered both and diffed the
SVG to check the complaint: identical shape, identical sign, same box count in the same place. The
card says SHIPS OUT or RECEIVES now.
And the Office told the truth about itself. baseOf returned zeros for a passenger facility
despite a comment claiming it read the Office tier, so a plain Depot displayed out 1 +1 — crediting
a Modifier that was never played. The card tooltip had the mirror-image bug, reading the printed tier
rather than the Office as it stands, so a Modifier's effect never showed there either.
Undo, and the chrome around it
Undo, as far back as you like. The save is the seed plus the intents, so undo is a replay without
the last one: no undo stack, no inverse of each action, and no way to reach a position the rules could
not have produced. Solitaire only. It is a deliberate take-back rather than a rewind — the RNG
advances with the replay, so a train card re-rolls the same Stage, but you can see that roll and then
spend the turn differently. TODO.md carries the question of whether a Stage boundary should become
a commit point.
Both Division end labels were clipped. They hung off the outside of the end cells and needed
padding wider than the caption to survive, which the board did not have — and giving it to them would
have spent that width on two captions instead of on the map. Centred under their own Division Point
they cost nothing, and PAD drops from 92 to 22.
The turn chart scrolls away no more. The title bar was sticky and the row under it — Day, Stage, phase, waiting-on — was not, so scrolling the board took away the thing most worth glancing at. They are one sticky block now.
The timetable has a key. Blue is a train due, violet is the Stage you are in, dimmed is gone, and the green is a flash marking the slot the die just filled — which is exactly why it needed saying.
And the rule that shapes every district is finally written down. A district only grows outwards:
the Limits signs are the growth point on the Running Track and move outward with the card, and no
card anywhere can be inserted between two cards already down. Enforced since the beginning, stated
nowhere. It now sits beside the district, and a legal square that already carries a card says what
playing there would do — EXTEND THE RUNNING TRACK HERE, ATTACH TO THIS CARD — instead of lighting
up blue and saying nothing.
Measurements
The two engine fixes take things away, so both were measured rather than assumed: 200 paired games before and after come to revenue 1.4 either way, cards played 24.4 → 24.2. Neither was paying for anything the bot relied on.
Worth stating plainly, since the changelog above still carries older numbers: the developer bot now
averages 1.4 revenue against a target of 20, with 1 win in 200. That is not a regression from this
work — it is the same at f4c0f49 — and it is consistent with what TODO.md already records about
barring curves from the Running Track and making industries stub-only. The bot has not been retaught
since those rules tightened.
Tests 386 → 402.
A second playtest pass
The arrival message told you to do work you could not do. Train 4 is the Express, which carries
expedite: true — Q3 departs an expedited train in the SAME Stage it arrives. So it correctly
arrived and highballed in one Mainline Phase and correctly did not wait at the Depot. But
trainArrived narrated "it will highball again next Mainline Phase, so any work must happen now"
to every arrival, which for an expedited train is exactly backwards, and the log then contradicted
itself two lines later. The event carries expedited now and the message says which case it is.
TX14 (2) was Stages remaining to cross that Mainline card, not the car count — a bare figure
beside a train's name, read as the consist twice by the same player because that is the obvious
guess. Now TX14 · 2⧗.
"stock a coach at (0, 0)" reads as putting a car on the track, and was reported as exactly that confusion: no train at the Depot, so how is a coach being stocked there? It is a load taken from the Division Yard into the green Loading box, waiting for a train that can carry it — for a passenger facility, people on the platform. It now says so.
"trains enter" / "trains leave" claimed the Division ran one way. Odd trains run west and even
run east, so both Division Points are a way on AND a way off; the buffer stops only mean it is a line
rather than a loop. Both ends now read in and out.
The discard targets were "a very tiny highlight". Choosing a card to PLAY lights big ghost squares on the board; choosing one to discard lit a 2px outline on a small tile in a panel you might not be looking at. The piles now grow, brighten, say DISCARD HERE, pulse once, and dim the Salvage Yard beside them so the three that are choices read as a choice.
The timetable, and watching the die land
Playing a train card rolls 1D12 for the Stage it departs, and the card simply left the hand — the answer arrived as one line in the history panel, among many. The twelve slots have been in the Frame all along, and only the standalone replay ever drew them.
The play page now has a Timetable panel: twelve Stages across, the train due out at each, the current Stage lit, Stages already gone dimmed. Playing a train card flashes the slot the die just filled, sounds a die-and-chime cue, and says it in words above the actions — "Train 4 is scheduled to depart at Stage 11 — see the Timetable". The flash marks the moment rather than the state: it is gone by the next render, which is what makes it read as "that just happened".
No acknowledge-click. It would stop the game to say something the highlighted timetable says better, and would be clicked through by the third time.
The load pipeline is drawn the way freight actually flows
Reported after unloading at a warehouse: "it went W, A, M, and then to the red box at the far right."
The engine was right. §9.3 is explicit — "The first Laborer replaces the load with an empty car of that type on the industry's track and places the load on WORK. Additional Laborers move it to AT then MEN. The last one places the load on a red Unloading box." An unload ends in red, and it did.
The drawing was wrong. The pipeline was laid out green → MEN|AT|WORK → red, left to right, with
both arrows pointing right. But loading runs Green → MEN → AT → WORK → car, and unloading runs the
other way entirely: car → WORK → AT → MEN → red. Green and red therefore both belong beside MEN,
and the car belongs beside WORK — so putting red at the far right put it exactly where the car is,
and an unload appeared to run backwards across the whole row and land on the end it had just left.
Now green and red both sit before the sign with their arrows pointing opposite ways, and only the boxes an industry actually uses are drawn: a Grocer's Warehouse has no green box and a Mine Tipple no red one, where before every industry showed both and half of them were dead squares.
The Facilities panel had the same left-to-right assumption in its row labels. green and red are
now waiting to load and cleared inbound, each with the direction its loads travel spelled out,
and a row an industry cannot use is omitted rather than shown empty.
A playtest report, worked through
Backing up turned the train around. facing is which way the ENGINE points, and it was reset to
the direction of travel on every move — so one reverse move silently spun the train about, everything
read "forward" again, and a run-around became pointless: you could change ends for free by backing up
twice. Running forward the engine leads and points the way it went; backing up it trails, still
pointing the way it came, which is the port it arrived through. Both hold around a curve.
"drop 1 car(s)" hid an option entirely. It never said which car, and it read identically for a nose drop and a tail drop — so the action list's duplicate-label filter discarded one outright, and setting out from the front of the train could not be chosen at all. The same failure as the turnout rotation earlier, in a feature added two commits ago to fix the make-up deadlock. Now: "set out the caboose off the back".
ABS Signals offered the Office Area. The card says "any Mainline card", and
checkEnhancementPlacement reads placement.col as a Division NODE index for it — while the
candidate list handed it occupied grid cells. So "(0, 1)" was accepted because node 1 happened to be
a Mainline card: the label and the meaning were different things. It now offers the Mainline cards by
name, "on the Uncontrolled Siding, out on the Mainline".
Two T10 chips on one Office. A train standing at the Office is on the Office grid card AND on an A/D track, so it arrived in both lists and was drawn twice.
The train was invisible on the board, which is what made the switching game unplayable: every decision is about car ORDER — which car comes off next, which end a cut couples onto — and the card showed a name badge. The train is now drawn as it sits in the tray: engine in its place with an arrow for which way it points, cars in order, loaded solid and empty hollow. That arrow is also what makes "(reverse)" mean something.
Modifier effects were applied and invisible. The Ice House and Local Small Groceries both worked
— capacity.outbound and laborers went up — but the card draws Math.max(1, greenCap) green
boxes, so 0 → 1 looked identical, and laborers were never drawn at all. The Facilities panel now
shows laborers, outbound and inbound as numbers with what a Modifier added (2 +1), names the
Modifier cards beside the industry, and says in the tooltip what the card itself prints. Laborers had
been in a hover tooltip only, which is the wrong place for the number that decides every Cargo phase.
Coupling was silent and left no trace. A car simply vanished from the board with only a line of history to say where it went. There are now two new sounds — a knuckle-coupler clank for coupling and a quieter one for setting out — alongside the whistle, bell and conductor.
Moves left were reported only in the history panel, which is the one place a player is not looking while switching. Now beside the buttons, and struck red at zero.
A/D tracks were a tooltip. "3 A/D tracks" with nothing on the card — the number that decides whether the next arrival is an automatic collision. Drawn as pips, filled for taken; there is no room on the card for more rails and the count is what matters.
The phase changed under you. Local Operations ends the moment the last Move is spent and the automatic phases then run themselves, so the page could change between two clicks with no notice. A banner now names the phase it moved to.
What was already right, from the same report
The car type and colour on the board (read as "cab in red" without being told), the Blocked panel
explaining a full industry track, the Facilities panel's accepted car types and spotted cars, and the
Division chip's T10 (2) with its consist on hover. All four were reported as useful, and none of
them changed.
Softlock: a train being made up with no visible way to make it up
Reported at Stage 10 of seed 775569289 — Train 10 at the West Division Point, history saying "now taking cars", and nothing at all under Your Move.
Moving train make-up onto the Division Yard chips took the "Making up …" group out of the action list, and everything that was not a car went with it: the heading naming the train and what its card calls for, and the "no more cars" button. The nine cars the train could take WERE clickable on the yard chips the whole time — nothing on screen said so, and the panel a player looks at was empty.
The panel is back, and now says where to click: "Click a car in the Division Yard below to add it — 9 kinds it may take are highlighted there." It carries the pass button when passing is legal, which §7 allows only when the Division Yard is bare — "must make every effort to find a suitable car".
The engine was never at fault. 2065 New Train decisions across 60 campaign games, and not one offered zero options; the pass/place pair covers the phase. The panel still words the third case honestly rather than implying a button that is not there.
Two guards, because the obvious one would not have caught it. A menu-level invariant — every
option the engine offers must be reachable through something the menu exposes — passes on this bug,
because makeUp.pass was in the menu and correct all along. What failed was the page never reading
it. So there is also a coarse check that main.ts references every field the menu offers: a field
nothing reads is either dead or a control that has gone missing. Verified by putting the regression
back and watching it fail.
Four reports from playing seed 775569289
A curve was described as a turnout. Both read "east-west track with a 45° leg", which is a turnout — a road straight across the card plus a leg off it. A curve has ONE road: in from the east or west edge, along the centre line to the frog, out at 45° through the middle of a north or south edge, and nothing runs past it. Which is precisely why it may not be laid in the Running Track, so describing it as though it had a through track contradicted the rule that stops you.
A curve in hand showed no preview. The shapes were read off the card's legal PLACEMENTS, so a card with nowhere legal to go had nothing to draw — and that is exactly when a player most wants to see what the piece is. They now come from the card itself, which is where they belong: what a piece looks like does not depend on whether there is currently a square for it.
"Realignment on Mainline card 3" named a raw node index. It said nothing about which stretch of the Division it meant or what it would do, and it had no tooltip either — because the action list attaches one only when a label happens to contain an em-dash, which this one did not. It now reads
Realignment on the Uncontrolled Siding — the second Mainline card west to east; converts it to Double Track
and any action naming a card falls back to that card's own description when its label carries no explanation of its own. That was the actual complaint: the same card explained itself perfectly in hand and said nothing in the action list.
It offered only one Mainline card, and that was correct. REALIGNMENTS converts Plains, Curves,
Uncontrolled Siding and Trestle; the Division on that seed is a Heavy Grade and an Uncontrolled
Siding, so only the second could be converted. The engine was right and the label was hiding it —
"Mainline card 3" gave no way to tell a considered restriction from a bug. Naming the card fixes the
report without changing the rule.
The hand is the action surface, and the yard makes up the train
The action list reached 22 buttons, and most of it was a cross-product. A card appeared in two panels under two different models: as a subject under "Play a card from my hand", which then highlighted squares on the board, and as one flat button per Department under "Discard a card from my hand". Four cards times three Departments was twelve buttons repeating the same three choices four times, about 290px of the list. Separately, making up a train offered up to ten buttons reading "add loaded hopper", "add empty boxcar" — while the Division Yard sat on screen already showing exactly those cars by type and load state.
Both are now on the objects already being looked at, using the pattern board placement always had: pick the thing, then pick where it goes.
- Every card in hand carries its own verbs.
playhighlights the squares it may go on, exactly as before; a card needing no square (an Office upgrade, a train, a maneuver) goes down in one click.discardlights up the three Department piles as targets — they already show their top card and their depth, which is precisely what you choose between. - A make-up car is picked off the Division Yard chip that shows it. The loaded and empty counts are separate targets, because a car of a type and a load state is exactly what the choice is.
- The action list keeps what is not about a card or a car: the Local Operations choice, drawing, switching moves, the Freight Agent, and finishing.
Measured over a full game of seed 430: the widest action list went from 22 buttons to 5. A test now walks the same game and fails if it climbs back above 8.
The rotation step stays where it was and is now the only thing the placement panel shows — the card is picked in the hand and the square on the board, so a rotation is the one question neither of those can ask. Its hover previews, added earlier, are unchanged.
A note on the two replay viewers
TODO.md now carries an item to decide between them. The standalone node src/sim/replay.ts writes
a self-contained HTML file that nothing links to and that .gitignore excludes; the site reads JSON
saves from public/replays/. The standalone one carries the bot's decision trace and a timetable
panel, which is debugging material rather than something a player wants. No action taken.
A replay now looks like the game it is a replay of
"Extra slow" was there and did nothing. The site's replay viewer builds its interval with whatever the speed select held when play started, and nothing re-read it — so changing pace mid-replay had no effect at all and the pace looked stuck. The standalone replay had always restarted its timer on change; this viewer was missed. Both offer the same five paces, extra slow through very fast, and a test now asserts they stay in step.
The turn chart lived on one screen out of three. Where you are in the Day — the five phases with
the violet "you are here" — was in main.ts alone, so both replays reported the Day and the phase as
two plain strings. The same position looked like a different game depending on which screen you were
on. It is now sim/turnchart.ts, shared exactly as the board renderers are: the playable page and
the site viewer import it, and the standalone replay embeds it by Function.toString() because it
is a single file with an inline script and cannot import anything.
And the side panels were three against eight. The viewer showed the Division, the Office Area and
the log; the play page shows those plus cards in hand, the Department decks, the yards, the blockers
and the facilities. A replay could not answer "why is nothing moving?" — which is most of what a
replay is for. web/panels.ts now renders all of them for both pages, and the duplicated CSS is gone
from play.html.
Three tests hold it there: both screens must call the same panel renderers, all three must use the shared turn chart and none may keep a private copy of the phase table, and the two viewers must offer the same paces.
You could not rotate a turnout at all, and now you can see what you are laying
The rotation was being thrown away before it reached the menu. actionGroups drops duplicate
labels, and describeIntent for a card play said only play right-hand turnout at (0, 1) — no
rotation in it. So a turnout's two orientations produced the same label and the second was silently
discarded. The "choose a rotation" step existed and worked; it was never given more than one
rotation to choose between. The label now names the orientation, and both survive.
And the buttons are pictures now. Hovering a rotation draws the piece as it will land on the
board, and hovering the card itself draws every shape it could be laid as — which is how a player
sees a turnout has two orientations before picking a square at all. Rendered by officeSvg, the
board's own renderer, on a one-card board: the preview and the board cannot disagree about what the
piece looks like, and the rails come from the engine's connectionsFor, so a preview cannot promise
a shape the placement will not produce.
Placeable.spots carries the links for this. data-tip-html on the tooltip renders a figure above
the caption; it is only ever set from markup this app builds.
Turnouts say what they do, and a curve may not break the Running Track
"Right-hand turnout, stem east, through west, diverges north at 45°" is three pieces of jargon and a compass reading, and none of it answers the only question being asked: if my train comes in from over there, where can it go? Turnouts now read
allows traffic from the east to travel west or turn to the south
and curves
carries traffic from the west round to the south
everywhere they appear — on the card in hand, on the placement it would make, and on the card once it is down. §A.1's rule falls out of the same sentence rather than needing a shouty clause of its own: a train coming the other way, from the through end or the diverging one, may only leave by the stem, so the two roads never join.
A curve laid in the Running Track dead-ends the main. A curve has ONE road — from an east or west
edge round to its 45° leg — so a card of it standing in the running row stops the through route at
that square and cuts the Office off from its own Limits. Nothing forbade it, and the bot did it: an
early trace shows it laying a ne curve at (0,−1), turning the west end of its own Running Track
into a stub. Every card that may stand in that row now has to carry the road across it —
carriesThroughTrack — which straights, turnouts, Limits signs, the Office and industries all do.
Reported as BREAKS_RUNNING_TRACK rather than NOT_CONNECTED, because it is a different mistake:
the card would join perfectly well and would still leave the main stopping dead at it.
The crossover is confirmed, both hands. A turnout that "allows traffic from the east to travel west or turn to the south" joins the one directly beneath it that "allows traffic from the west to travel east or turn to the north" — the two 45° legs are one continuous rail across the card edge. It already worked; it now has a test that says so in those words, along with its mirror built from the other hand, the mismatched pair that must NOT join, and a crew actually running down through it onto the parallel track.
Measured, and worth flagging: barring curves from the running row cost the bot a good deal — districts 28.0 → 19.7 cards, facilities 2.23 → 1.85, revenue about 2.0 → 0.8. The rule is right and the bot was partly living off an illegal placement. Two tests needed widening rather than weakening as a result: the unload regression sampled three deals and now samples twelve (unloads still happen, 34 across a 40-deal sweep, just not on those three), and the canvas bounds test now holds a straight as well as a curve, since a curve alone is offered only one square once the running row is closed to it.
The Crew Tray is a train, and a train must be made up to leave
§8.2 was not checked at all. A train could highball onto the Mainline engine-last with its caboose in the middle. Now a train held at the Office is held until it is made up: the engine at an end of the tray — pulling or pushing, both are real — and the caboose at the far end from it. The check is deliberately direction-free, because what a train may not be is broken-backed, with the engine buried among its own cars and some ahead of it and some behind. That state is only reachable through switching: a train arrives made up and comes apart because the player took a cut onto the nose or picked cars up in a run-around.
engineAt was written and never maintained. Cars taken onto the nose go AHEAD of the engine —
Appendix A: "a train can pick up two cars and add them to the Crew Tray in order that they were in,
pushing them into the Facility" — so the engine stops leading, and its recorded index did not
follow. It does now, and drops adjust it the other way.
Setting out from the nose was missing entirely. Appendix A uses the move in its own worked
example — "Back up and drop off everything on the nose of your train (red and blue) on Card B" —
and switch.dropCars could only ever take from the tail. Without it, cars taken onto the nose could
never come off, so an engine buried in its own train had no way back to an end: the first version
of the make-up rule stranded a train permanently in 6 games of 40, holding an A/D track for the rest
of the game. fromNose fixes it, and a cut is now guarded to come off an OUTER end only — lifting
cars from beside the engine would leave the far end of the train coupled to nothing.
The bot learned both remedies: dig the engine out when it is buried, and shed a misplaced caboose when it is at a reachable end. Measured over 40 games: 106 make-up holds across 6 games → 13 across 1, revenue 1.55 → 2.05. The one that remains is a caboose stuck mid-train, which genuinely needs a run-around or a Small Yard — the game working, not a defect.
A knock-on worth recording: with cars now set out properly, the freight pipeline runs clean — 55 loads started and 54 completed across the 40-game sweep, with zero jams of either kind. The regression test that asserted the bot clears a MEN|AT|WORK jam had become untestable, so §6.3's unjam is now asserted directly against a constructed jam instead of hoping the bot stumbles into one. It also checks that clearing the jam reopens the track, which is the point of it.
You can see which car is where. The board printed the first three characters of each car's
label, which for "loaded hopper" and "loaded boxcar" alike is loa — every car on the map looked
identical. The switching game is entirely about getting the RIGHT car to the right industry, so type
now reads by colour and by three letters (box hop tnk rfr cch cab), and loaded shows as a
filled slot against an empty one's outline — the same distinction the printed game makes with
coloured tokens.
Operational Rail — a locked industry could be driven straight over
Checked the whole rule against Appendix A of StationMasterPrototypeRules.pdf, which defines it
outright: "Operational Rail is any track card that a train can stop and leave Rolling Stock
(uncouple) on. Operational Rail is any track card with a train wheel icon on it."
Most of it was already right, and the wheel icons in the rules diagrams confirm which cards carry one. Straights, curves, industries and the Limits cards all do. The turnout does not, and the page says so in words — "Since there is no Operational Rail wheel icon, the train may not stop on this card" — so a train runs through one and may not stop or uncouple on it. Both already held.
The Office is Operational Rail. The Depot card in the diagrams carries a wheel icon and is drawn
as one of the green squares a Crew Tray may move to, and the Special Rules say "While your Office
Track is considered Operational Rail, Rolling Stock may not be dropped off here." So a train may
stop there and may not leave cars — which is what canDropCarsAt already did. Worth stating plainly
because it is easy to read the "no cars here" half as "not Operational Rail", and the A/D track
mechanic depends on trains being able to hold at the Office.
The real gap was §9.3's lockout. "While ANY loads are in the MEN | AT | WORK track, the industry's track is locked down... It loses its status as Operational Rail. No cars can be picked up or dropped off, and no trains may occupy or move on it." Losing Operational Rail status only stops a train FINISHING somewhere — a turnout is not Operational Rail either and trains run through one all day. Passage was never blocked, so a crew rolled straight over a locked industry and, because coupling is automatic and mandatory, picked up the cars spotted on it on the way past. Those are precisely the two things the safety lockout exists to prevent.
isLockedByWork is now separate from isOperationalRail for that reason: "cannot stop here" and
"cannot pass through here" are different properties and only a locked industry has both.
Covered by a new suite in track.test.ts that walks each card type against the wheel-icon rule,
asserts the Office may be stopped at but not unloaded on, asserts a turnout may be passed but not
stopped on, and asserts a locked industry blocks both stopping and passage — then reopens when the
work clears. It also checks the supply catalogue's isOperationalRail column against the rule, so
the data and the diagrams cannot drift apart.
Industry cards: on a stub, one of a kind, and never both ends of a chain
From the sheet, the "Placed" column reading identically for all six industries — "Straight, Stub (not on Running Track)" — and the "Lockouts" column beside it.
An industry may no longer be built on the Running Track. It was allowed and merely warned
about: the card text noted that a car left standing there would be hit by the next arrival. That is
a hazard, not a rule, and it let a player skip the district entirely and spot cars on the main line —
which removes the whole switching puzzle, since the point of a siding is getting a car down off the
main and back. check now returns ON_RUNNING_TRACK.
No two of the same industry in one Office Area. The sheet states this in the Freight House row,
which lists Freight House among its own lockouts; the catalogue had dropped that self-reference as if
it were a typo. It is a general rule, so it is enforced for every kind in isLockedOut rather than
repeated in all six entries.
Producer and consumer of the same commodity stay apart. Mine Tipple makes the coal a Power Plant burns; the Refinery makes the oil it also burns; Packing Sheds fill the reefers a Grocer's Warehouse empties. Build one end of a chain or the other, never both — which is what pushes freight to run between districts instead of circling inside one. The pairs were already right; they had no test coverage at all, and now have a suite that checks each pair in both directions, checks that industries sharing no commodity may stand together, and checks the catalogue against the sheet column so a change to it has to be deliberate.
Measured, 60 solitaire Standard games: 0 industries on the Running Track, 0 duplicates. Industry placements fell from 3.84 a game to 2.23 and revenue from 2.87 to 1.35, because the bot builds shallow districts and there are now far fewer legal squares. Not rebalanced — deliberately. The counts, the industries and the track mix are all due a pass together once the rules are right.
One consequence worth naming: flyingSwitch stopped firing in the 60-game reachability sweep. The
rule is fine — mainline-cards.test.ts exercises it end to end on a hand-built siding — but the bot
no longer gets a crew next to an industry. It is exempted by name in that test, with the reason
written next to it, so the other forty-odd event checks stay live and deleting the line is what
proves the bot has been fixed.
§6.2's reshuffle, and the Departments and Salvage Yard on screen
The reshuffle existed as a fiction. events.ts declared { type: 'deckReshuffled' } and
narrate.ts had a line of prose ready for it — "Home Office deck ran out — Salvage Yard reshuffled
back in" — and nothing anywhere emitted or reduced it. check just returned DECK_EMPTY. A declared
event with narration written for it reads as an implemented feature to anyone grepping for one, which
is worse than an obvious gap.
Now real: when a draw takes the last card, the Salvage Yard and all three Department decks are
collected, reshuffled, and §4.6-4.7's opening is re-run — three cards turned face up as the
Departments, the rest face down as the deck. Cards played onto the board are not recovered; they
are on the table, which is where they belong. A game that has genuinely used everything still ends on
DECK_EMPTY rather than reshuffling an empty sweep.
The full shuffled order rides the event rather than being recomputed from rngState. A save is a
seed plus the intents, so events are never serialised and the size costs nothing — and an event that
states the outcome outright cannot drift from the reducer the way a re-derivation can. Covered by a
test that builds the same position twice and asserts the two decks come out identical.
It has not fired in play yet, which is worth knowing. Solitaire Short/Standard/Campaign end with 186.8 / 176.2 / 168.8 cards left of 243 and never ran dry across 60 games each; four-player Campaign ends with 86.6 across 25 games. It is a safety net, not a live mechanic. It is not the case that the Departments only grow — a player takes the top card of one as their draw as readily as discarding onto it, so a Department can be drawn down and refilled from the Home Office deck. What the reshuffle guards is the Home Office deck itself running out, which is possible whichever way the piles happen to be moving.
The Salvage Yard is a pile like the others, and now shown like them. It was already modelled as a list; what it lacked was any presence on screen, which mattered the moment it became the thing that comes back in a reshuffle. Watching it fill is the only warning a player gets that the deck is about to turn over.
All four piles now show their top card and their depth. Each is drawn as a card with the pile's name and a count badge on it, then the face-up card underneath. The depth is a count and not a hint: only the top card may ever be drawn, so everything below it is out of reach, and choosing where to discard is choosing what to put there.
The Departments are decks, and you choose which one to discard onto
From the designer: "when discarding from their hand, the player can select which department card deck they want to place the discard on top of. Department card decks are shared across all players. When pulling from a department card deck players may only pull the top card."
The rules already said so and the code had read them the other way. §6.2: a discard is placed "face
up on top of one of the three Department slots", and a draw takes "the top face-up card".
Both phrases only mean something over a pile. Gap 4a had concluded the Departments were "three
face-up market slots fed from the one deck, not decks with their own contents"; that finding is now
marked corrected in open-questions.md.
It was destroying cards. cardDiscarded did departments[toSlot] = cardId — assignment, not a
push — so discarding onto an occupied Department annihilated the card already face up there. A closed
deck was quietly leaking. It survived because the only card-conservation test ran at setup and never
again; there is now one that counts after a full game, and one that asserts no id is ever in two
places at once.
And the choice was invisible. All three discards described themselves as discard X, and the
action list drops duplicate labels — so three genuinely different decisions collapsed into a single
button and the Department could not be picked at all. Discards now read discard Freight House onto Department 2, burying Brakeman, and a draw reads take Depot from Department 1, 3 buried beneath it. The browser shows each pile's top card with a +n under count, because a deep pile is where
cards have been put beyond reach and that is what a discarding player is choosing between.
Refill timing changed with it. §6.2 refills an empty Department from the Home Office deck. The old code refilled after every Department draw, which was harmless when a slot held one card and would now drain the deck into the piles. It refills only when taking the last card empties one.
The bot got the strategy this opens up. It used to take the first discard option, always Department 1, burying whatever sat there — including the Depot it was waiting on. It now covers the face-up card least worth keeping reachable, never one it would take, and breaks ties toward the shallowest pile. Measured over 100 games: spreading discards 2.87 revenue, concentrating them on the deepest pile 2.67, indifferent 2.67. Three piles offer three face-up cards, and piling onto one of them leaves the other two showing whatever they started with.
In a competitive game the same call reads the other way round — burying a card a rival wants is an attack rather than housekeeping — which is exactly why the choice belongs to the discarding player.
Measured, 100 solitaire Standard games: Departments hold 8.3 cards between them at game end, deepest single pile 17. The Home Office deck ended with 176.6 of 243 and never ran dry, which matters because §6.2's reshuffle — collect the Salvage Yard and all three Departments, reshuffle, re-establish the deck — is not implemented. It has never been reachable in a 5-Day solitaire game; it will matter for Campaign length and for four players. (Implemented in a later entry.)
Track is a deck card, not a private supply
Reported by the designer: "all track cards are included in the home office deck and are played from there like any other card."
The error is visible in the spreadsheet. docs/Deck cards2.xlsx has a column B headed "Number in
Deck" — 32 straights, 16+16 curves, 4+4 sharp curves, 16+16 turnouts, 104 cards — and a LAST
column headed "Track Per Player" reading 8/4/4/1/1/4/4 = 26. The code took the last column as a
separate physical stack and wrote "Track is NOT in the Home Office deck — this is the single biggest
structural change from the placeholder." It is 104 shared among four players, not a second pile. The
sheet's own totals settle it: "Sum other 115", "Total track 104", grand total 231 — and 115 + 104 +
12 start cards is exactly 231.
What went. TRACK_SUPPLY/TRACK_PER_PLAYER, OfficeArea.trackSupply, the track.lay intent,
the trackLaid event, protoTrackCard, TurnState.laidThisTurn (the one-piece-a-turn cap, which
only existed because a private supply had nothing else bounding it), and the "Your Track Supply"
panel. CardKind for track gained a hand, because handedness is the diagonal and a track card
without one cannot say what it may be joined to.
Most of the plumbing was already there and dead: checkPlay had a track branch, protoCard had a
track branch, and the cardPlayed reducer already placed a track card with a rotation. Track had
been a card once, and moving it back was largely deleting the parallel path.
The deck is 243 cards, of which 104 are track — the largest category by some way, and the point of the change: building a district is now paid for in the industry or train you did not draw, and a three-card hand is the real constraint on how fast a railroad grows.
Measured over 100 solitaire Standard games, against the same run with track as a private supply:
| private supply | in the deck | |
|---|---|---|
| deck size | 139 | 243 |
| district size | 28.5 cards | 28.0 |
| mean max depth off the main | 1.45 rows | 1.94 |
| turnouts / curves / straights per game | 7.6 / 7.2 / 3.4 | 5.6 / 7.4 / 5.6 |
| facilities placed | 4.56 | 3.84 |
| districts with a run-around | 70/100 | 29/100 |
| facilities on a run-around | 0.51/game | 0.18 |
| revenue | 5.80 mean | 3.05 |
Revenue nearly halved, and that is the headline for the next decision, not a defect to paper over. A run-around needs a turnout, a matching curve, straights, a second curve and a second turnout — all of the right hand, arriving in a three-card hand in a usable order. It used to be a shopping list; it is now a draw. Two test floors were re-baselined against the measurement with the old figure recorded beside them, deliberately set BELOW what was measured so they detect the loop machinery breaking rather than endorsing 29%.
Two bot fixes fell out of it, both real. Track became an ordinary card.play, so the generic
"play anything placeable" fallback started dumping track on whatever square was legal — bypassing
bestTrackLay, which had already looked at the same piece and declined it. Measured: 26 of 60
districts ran the siding past the last column with a way up. A track card the scorer will not use is
a card to discard. And arcsLeft, which asked a supply that no longer exists, became arcInHand:
"have I got a curve of this hand?" is now a question about the hand, not a certainty.
Still open, and now urgent. The office counts are doubled (Depot 4→8, Station 2→4, Terminal 1→2,
Q12) and the industries tripled (Gap 12), both tuned by measuring a deck with no track in it —
25 of 100 games never drew a Depot and never escaped Whistle Post. Adding 104 cards dilutes every
draw by 43%, which is exactly what those multipliers were compensating for, so they are now either
badly needed or badly wrong and only a measurement will say which. test/setup.test.ts records both
departures and flags them for re-measurement.
The placement labels described the mirror of the card they would lay
Reported as "I can't play a turnout north or south of an existing turnout". It was always legal — a turnout under a turnout is a crossover, and it is how a siding gets a track running parallel to the Running Track. The engine accepts left-over-left and right-over-right today, and the square was offered and clickable. What was wrong was the words on it.
rotationNote in src/web/game.ts called variantsFor(geometry) without the hand. Hand is the
diagonal, so without it variantsFor answers for the left-hand card whatever you are holding. Every
right-hand turnout was offered as "stem west, through east, diverges south" — the exact mirror of
the card it would lay — and every right-hand curve named the wrong edge. The placement was always
correct and only the description lied, which is the kind of bug that survives a green test suite and
makes a working feature feel broken.
Fixed by threading hand through rotationNote → variantLabel, and covered by a test that walks
every geometry × hand × rotation and checks the label against variantsFor's own answer.
And a placement now says what it would connect to. Two cards meeting at an edge is not a rail —
on a north or south edge their 45° legs must also share a diagonal — so "is this square legal" and
"does this piece meet the one I am aiming at" are different questions and only the first was on
screen. Spots now read (-1, 1) — stem east, through west, diverges north at 45° · joins the track above, which is the crossover named outright.
A New game button, instead of finishing the one you have
start() restores from localStorage on every load and the only "new game" button lived on the
game-over screen, so a game you no longer wanted followed you across reloads with no way out.
New game sits beside Save replay in the header. It confirms once past the opening Stage — the
save is the game, there is no undo, and the replay download is right there — then clears the save
and reloads. It drops any ?seed= from the URL as well: leaving it would deal the same game again
and look like the button had done nothing.
The track is 45° geometry, drawn from the printed cards
The prototype designer's feedback: there are no north–south tracks. Straight runs are always east–west, the east–west line sits dead centre on the card rather than biased to the top, and a turnout is an east–west through track plus a curved leg meeting the north or south edge at 45°, which must line up with the corresponding leg on the card it abuts.
Measured off docs/tracks.png rather than inferred. Card grid lines at y = 15/164/314/464/614/764/
914/1064/1213 on a 2136×1397 sheet; rail centres at 89.5/236/–/–/689.5/839/989/1135.5/1288.5 — the
card's exact vertical middle, within a pixel, on every one of the nine rows. Cards are 259×150
(aspect ≈ 1.73). Every leg crosses the middle of its edge. Four shapes exist: turnout with the leg
off the west end, turnout with the leg off the east end, curve west↔south, curve east↔south, plus the
plain straight. Depot, Station, Terminal, Refinery, Freighthouse, Coal Tipple, Manufacturing and Town
are all plain east–west straights with no diverging leg at all.
Slope is now part of adjacency. Name the two diagonals after the pair of arcs that mate across a
horizontal card edge: ne_sw (port pairs {n,e} and {s,w}) and nw_se ({n,w} and {s,e}). An sw card
above an ne card is one unbroken rail; sw above nw is a V — both cards have the port, both legs
meet the same point on the edge, and they still do not connect. joins(a, p, b) in
src/engine/track.ts is the single adjacency test, and every hasPort(nb, opposite(p)) in the
engine, the bot and the tests now goes through it. Leaving one behind reintroduces the V.
Handedness is that diagonal. A printed card has a back: it turns 180° but never flips. So a
straight has ONE orientation, and a curve or turnout has two — 0° sends the 45° leg south, 180° sends
it north, and neither changes diagonal. Left-hand reaches sw/ne, right-hand se/nw. That is
what makes the 4-left/4-right split of the curve and turnout supply mean something: a run-around needs
one card of each hand — a left turnout down, its matching left curve, straights along, a right curve
back up into a right turnout. variantsFor takes a hand argument for this reason.
What went, and what opened. TrackAxis and every axis field are deleted outright — there is
one axis now, and a one-valued field invites the old branching back; deleting it turned tsc into
the migration checklist. The Office's e-s/w-s stubs are gone, as are north–south facility
placements. Q7's "a district hangs below the Running Track" is lifted: a turnout turned 180° reaches
north, so placementCandidates and adjacentFacilityCoord no longer reject the rows above, and §9's
"nine nearby spots" really is nine. legalIntents offered six variants per placement per card; the
widest set is now two.
The renderer draws the card rather than approximating it. RAIL moves from 30 to H/2, and the
card widens from 132×96 to 166×96 to match the sheet's 1.73 — the aspect is not decoration, since the
frog where a 45° leg meets the through track sits H/2 from the centre and a squarer card pushes it
almost to the edge. The fixed elbow at (W/2, RAIL + (H-RAIL)/2) is replaced by the real frog at
(W/2 ± H/2, H/2) and a segment at exactly 45° to the edge midpoint. That elbow sat below the rail
on the assumption everything diverged downward, so a leg reaching north was drawn as a hook that
dropped past the rail and came back up. Four tests in test/web.test.ts now measure the emitted SVG:
the through rail level and centred and spanning the full card, every leg at 45°, ne/sw on one
diagonal and nw/se on the other, and the leg crossing the edge at its midpoint. The enhancement
label moved above the rail, where it used to print along it.
Measured, 100 solitaire Standard games, against the same run before the change:
| before | after | |
|---|---|---|
| mismatched 45° joints on finished boards | — | 0 |
| district size | 25.4 cards | 28.5 |
| mean max depth off the main | 2.38 rows | 1.45 |
| built above the Running Track | 0/100 games | 93/100 |
| straights laid | 1.01/game | 3.44 |
| districts with a run-around | 99/100 | 70/100 |
| facilities on a run-around | 0.82/game | 0.51 |
| revenue | 7.34 mean | 5.80 |
The shallower districts are intrinsic: no card joins north to south, so descending a row costs at least two cards — the leg out, then a curve turning the run back east–west. Districts are now wide and shallow, which is what the printed sheet and a real yard both look like, and the run along the siding is straights, which is why the bot lays three times as many (13 of the 18 enhancement cards need one).
The revenue and run-around drops are honest, not a regression to chase. A run-around costs more
pieces than it did, and the Office no longer offers a free way back up onto the main — it used to
carry e-s/w-s stubs, so every district had one guaranteed climbing point. Two bot heuristics were
tried against it and both rejected on measurement: preferring one side of the main changed nothing at
−4 and made things worse at −100 (62/100 run-arounds), and paying more for the second turnout that
closes a siding bought 86/100 run-arounds and 1.08 facilities on a loop but cost 9% of revenue
(5.80 → 5.25). Revenue is the game's own measure, and both structural floors in test/sim.test.ts
still pass, so neither shipped.
The bot is otherwise rewritten around the new geometry: descendFrom/waysOff/reachableOffMain/
runAroundCells take a side rather than assuming "below", the arc score asks joins instead of
reading the arc name, and arcsLeft is asked per hand — a left-hand turnout's leg can only be
continued by a left-hand curve, and a supply full of right-hand ones is no help to it.
Q13 — a train that catches the one ahead runs into it
Answered, and implemented as option B: collide on catching up, which is the version that rewards judging the gap.
§10 makes a Mainline collision the Superintendent's fault and removes both trains, and ABS Signals exists to stop trains rear-ending each other — but §8.3's trigger list never named one and nothing was implemented, so granting clearance was FREE: both trains survived, no penalty, and ABS Signals protected against nothing. Now a following train that closes on the one ahead runs into it, and one that never closes is fine, so clearance is a bet on relative speed rather than a formality. §2.1 divides the card into two regions, and sharing one is what "caught up" means. ABS Signals does what it prints instead: the follower stops short and holds.
Not on a card that prints "trains may pass". The first version fired 0.41 times a game while the bot never once granted clearance, which is the tell — those were all Double Track and Uncontrolled Siding, cards that hold two trains because they HAVE two roads. Catching up there means going past, which is what the card is for.
With that corrected the mechanic is invisible to the current bot, because it always denies clearance. That is the right shape, and the teeth are real:
| Superintendent | revenue | rear-enders | ABS holds |
|---|---|---|---|
| always denies (the bot) | 7.34 | 0.00/game | 0 |
| always allows | −5.13 | 2.20/game | 19 |
So the bot's always-deny policy — a deliberate choice made when clearance was free and the arithmetic only guessed at — turns out to be correct, and is now correct for a measured reason.
Tested deterministically rather than through the bot: two trains built onto one single-track card with the follower closing, which collides whichever order the phase processes them in, and the same pair again with ABS Signals to prove it holds instead.
The engine has a place in the train, and the yards are visible
The engine had a position the game recorded and never used. engineFront was a boolean, written
in three places and read in none — so every consist was drawn as an anonymous row of cars. It is now
engineAt, an index into the consist, because a Crew Tray is an engine plus its Rolling Stock and
the engine may be PULLING (ahead of everything), PUSHING (behind everything), or in the middle doing
both at once. A boolean cannot say the third thing. Consists are drawn with ENG where it sits.
The engine is deliberately NOT one of the consist entries: §8.2 counts the consist as Rolling
Stock, and the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
Both yards are now on the page, by car type and split loaded / empty, with the Division Yard outlined the moment it goes bare. This matters more than it did: the Classification Yard returns to service only when the Division Yard is empty, so the supply genuinely runs down, and a game that never showed either yard gave no warning at all.
It shows the pressure immediately. In a finished game the Division Yard held 30 cars — hoppers, tanks and cabooses — and no boxcars, coaches or reefers at all, while 19 of them sat in Classification unable to come back, because the Division Yard was not bare.
Crew Tray scarcity was already implemented and is left alone: a train with no free tray is held
(trainHeld), which is §7.
The Classification Yard rule, from the source — and my guess was worth 2.4 Revenue it should not have been
Answered: used Rolling Stock is set out in the Classification Yard, used engines and cabooses go straight back to the Division Yard, and the Classification Yard empties only when the Division Yard is bare — then all of it returns at once.
That is a much harder rule than the one I invented. Returning cars at every DAY boundary keeps the yard topped up continuously; this lets it run down to nothing and refill in one go, which is the whole of the supply pressure the game is meant to have. Measured paired over 400 seeds:
my Day-boundary guess 9.67 the real rule 7.25
paired change -2.42 ± 0.49 (t -9.67) 235 of 400 seeds affected
So the +2.32 celebrated when the Classification Yard was first made readable was very largely an artefact of getting the trigger wrong. The refill is now checked wherever a car leaves the Division Yard, so it fires the moment the yard empties rather than at the next convenient tick.
Poling is dealt zero copies rather than deleted. Its effect is "TBD in the source", so there is nothing to implement and a card that cannot be played is worse in a hand than absent from the deck. The catalogue entry stays so the gap remains visible. Deck 140 → 139, solitaire 118 → 117.
Heavy Grade orientation stays rolled from the seed, and is now documented as temporary in the
code rather than only in TODO: the card says the player sets it, but it is dealt during setup and
setup has no decision point — createGame is a pure function of the seed, which is also what makes
a save portable.
A counting question this raised, and could not answer. A census of every holder of rolling stock
comes to 92 against the 80 dealt. That is not proof of duplication: outboundBox, inboundBox and
menAtWork all hold RollingStock, and stocking a green box takes a LOADED CAR out of the Division
Yard — so some of those objects are cargo in transit rather than cars, and nothing distinguishes
them. An accounting test was written and then withdrawn, because it could not tell the two apart.
Logged: until a load is its own type, "is any stock being created or destroyed?" is unanswerable.
The freight figures were counting one half of freight
A measurement fix, not a game fix — but it is the instrument every balance decision is read from.
rev.freightUnload was assigned eventCounts['unloadBegan']: unloads STARTED, not Revenue EARNED,
and the two differ by every unload that never finished. grossFreight then used freightLoad
alone, so freightShare omitted the unload half outright. A completed load and a completed unload
each earn a point, on two distinct revenueChanged reasons.
The comment that stood there claimed "an unload scores through the same event as a load completion
in the reducer". It does not — apply.ts emits freightUnload separately. A comment asserting a
fact about code a few lines away, and wrong.
freight share of gross: 39% -> 49%
The harness now prints both halves. strategyBuckets counted "a scoring game" the same wrong way,
and so did the test guarding it — so a game that scored only by unloading was bucketed but not
counted, which is how the fix first showed up as a failure.
This matters backwards as well as forwards: the "freight is only 13-18% of gross" finding was read from this number, and it is what drove the Gap 12 industry-density change. That decision was taken against an instrument reading roughly 60% low.
Turnouts that go nowhere — reported, confirmed, and mostly not the problem
Reported from two replays: of 8 turnouts off the Running Track, 2 formed a run-around, 2 served industries and 4 went nowhere. Measured across 120 games, that holds exactly:
| what a turnout leads to | |
|---|---|
| part of a run-around | 30% |
| a stub, but serves an industry | 12% |
| a stub ending in bare track | 44% |
| nothing below it at all | 15% |
4.61 wasted turnouts a game. Then three attempts to stop it, each measured paired over 400 seeds, and each WORSE than leaving it alone:
| attempt | paired change | |
|---|---|---|
| no new way down while one leads nowhere | run-arounds → 0 | deadlocked: a run-around needs TWO ways down, and the second cannot be justified by what hangs off the first |
| first two free, gate the rest | −0.84 ± 0.53 (t −3.08) | track spend collapsed 15.4 → 4.8 |
| forbid rail that butts an incompatible card | −0.62 ± 0.57 (t −2.11) |
The reason is that a turnout is not only a way DOWN. It is also a way UP, and both the east-west extension and the closing arc are gated on one existing beyond them — so cutting the turnouts cuts the places a siding can rejoin, and the sidings stop forming too. The apparent waste is optionality. This also re-confirms, with proper statistics, a note left in the code by an earlier attempt.
Rail that can never go anywhere
The one that did work, and only as a tie-breaker.
A port facing an EMPTY square is a promise: something may be built there later. A port butting an OCCUPIED square whose card has no matching port is not — that square is taken, so the rail stops dead and always will. Reported from seed 618682, where an arc came off a turnout with its far end jammed into a curve that could not accept it. Measured: 28% of all pieces laid, 4.26 a game.
Forbidding it cost 0.62 revenue a game. Applying it as a tie-breaker on the distance score — never able to veto a piece, only to choose between two the heuristics rate equally — measured +0.43 ± 0.49 (t 1.74), with 108 seeds better against 67, and cut these from 28% of pieces to 7%. Not significant on its own, but it is the only one of four attempts pointing the right way, and the mechanism is sound.
And it fixed the reported problem after all — sideways. Re-running the turnout taxonomy:
| what a turnout leads to | before | after |
|---|---|---|
| part of a run-around | 30% | 66% |
| a stub, but serves an industry | 12% | 13% |
| a stub ending in bare track | 44% | 2% |
| nothing below it at all | 15% | 18% |
| wasted per game | 4.61 | 1.65 |
Bare stubs all but gone and run-arounds more than doubled, without ever refusing a turnout. Refusing them directly had destroyed the run-arounds; declining to lay rail INTO a dead end leaves the bot free to cut every turnout it likes and quietly stops it building the stubs. The remaining waste is the last turnouts of a game, cut with no turns left to build beneath them.
Also added, and honest about it: a turnout is not cut when no arc remains to hang beneath it. Measured at 0% today — the bot lays the arc immediately after the turnout and never runs the supply dry — so it is a guard against the supply changing rather than a fix for anything happening now. Its test constructs the situation by draining the arcs.
A source file git was hiding
Found while checking the above: src/web/replays.html had never been committed. .gitignore
carried replay*.html to catch the throwaway files generated at the repo root, and unanchored it
also matched a source page — the website's replay viewer. git archive HEAD confirms it: a fresh
clone does not contain that file, and build-web.ts copies it unconditionally, so the build would
have failed for anyone but this working copy.
The pattern is anchored to the root now (/replay*.html), which still ignores the generated files
and no longer ignores the source. A test asks GIT — not .gitignore — whether each source page
would survive a clone, because that is the actual question.
Three places draw a game, and they had drifted
Reported from playtesting: the replay had lost its "extra slow" speed, and neither the sound nor the auto-hide could be found. Both true, and the same cause — a game is drawn in THREE places and only some of them had kept up:
| speeds | sound | auto-hide | |
|---|---|---|---|
| the playable page | — | yes | yes |
| the standalone replay file | 5 | yes | yes |
| the website's replay viewer | 3 | no | no |
The website viewer is its own implementation — it replays a save through the engine in the browser
rather than reading a rendered file — and it never got what the other two grew. It now has all five
speeds (extra slow through very fast), the sound, and the auto-hide, using the same shared cuesFor
and playCue as everywhere else.
Three tests hold them together from now on: the two viewers must offer the SAME set of speeds, all
three pages must carry the sound and auto-hide controls, and replays.ts may not ask for an element
its page does not have — the same total check the playable page already had, and the one that would
have caught this. Verified by removing a speed and a control and watching them fail.
The published replays had stopped replaying
Both saves in public/replays/ were dead. A save is a seed plus the intents, replayed through the
real engine — so it cannot describe a position the rules could not produce, and an intent that no
longer applies stops the replay rather than being forced. That is the safe direction, but it is
silent: seed-202 got 42 intents into 360 before halting, and seed-430 managed 4 of 338.
They were recorded before this run's rules work — Modifier hosts, the industry-to-car mapping, the Classification Yard — so most of what they described is no longer legal. Replaced with three games generated against the rules as they stand, each verified to replay every intent to the final Day:
| A winning run | 36 Revenue, seed 1038389 |
| A strong run | 32 Revenue, seed 618682 |
| Collisions | 10 Revenue and 27 smashes, seed 919604 |
The third is deliberately a bad game: a full Office is a collision, and it costs more than the freight was worth.
This is the "save/restore is not version-aware" item in TODO doing exactly what it warns about. The saves are cheap to regenerate, so the fix is not to freeze them — it is for a stale save to say so instead of quietly ending early.
The replay behaves like the game it is replaying
Sound and the district auto-hide were built for the playable page and the replay had neither, which made watching a game back a poorer experience than playing it. Both are there now, and both are the SAME implementation rather than a second copy:
cuesFordecides what happened — a Stage ended, a Day turned, a train was built — and is imported by the live game and called by the replay recorder, so the two cannot disagree about when a Stage ended. Frames carry their cues.playCuedecides what that sounds like. It was three module-level functions with a sharedAudioContext; it is now one self-contained function, embedded into the replay bytoString()exactly as the board renderers are. The context is parked onwindowbecause the embedded copy has no module scope to keep it in.
Sounds fire only when stepping FORWARD one frame. Scrubbing across a hundred frames would otherwise fire a hundred whistles at once, and stepping backwards would sound a Stage ending that is being un-done.
Both default to the quiet, tidy setting: muted, and auto-hide on.
The replay's DOM stub had no classList, so the page threw while rendering rather than folding a
panel. It has one now — the same gap the playable page's stub had, and the same fix.
Playtest fixes
The end-of-line labels were cut to three letters. "trains enter" and "trains leave" hang off the ends of the route, and the canvas padding was sized for the buffer stops alone — so the west end read "TER" and the east "tra". Padding now allows for the words.
"End my turn" became "End Local Operations". It ends the phase, and the old wording invited the reading that it ended something smaller.
The hand limit is a limit, not a toll on drawing. Drawing a card set "you must now play one",
so a player who had already played two cards and drew back to three was still forced to spend one.
§6.2 is a hand LIMIT — "reduce his hand to no more than three cards", four with a Red Flag — and
that is now the only thing that blocks the end of a turn. It is derived from the hand at each
render, and a test asserts the page and the engine never disagree about whether the turn may end.
The engine removes draw.end outright when the hand is over the limit, so the page draws a disabled
button naming the reason rather than silently offering no way out.
A train now says what its card calls for. Reported on seed 22222: Extra X22 offered a caboose and nothing else with no reason given. The rules were right — "Pee-Dee" is a per-diem train whose consist is one caboose and no cars, and §8.2 forbids the wrong cars however few are carried — but the screen never said so, which reads as a broken game. The New Train header now names the train and its consist: Making up Extra X22 "Pee-Dee": its card calls for 1 caboose — Per-diem train. May only pick up MTs.
The auto-hide control said what the panel was doing, not what pressing it would do. "auto · folded" reads as a status line and was missed; it now reads "auto-hide: on — click to keep open", and looks like a control rather than a caption.
The whistle is twice as long, and sound now defaults to OFF.
A legal move you could not click
Reported from playtesting: laying a straight offered three places in the list and highlighted one on the board.
Empty squares were drawn by a separate ghostSvg and spliced into the SVG afterwards. It computed
its origin over cells PLUS the offered spots, while officeSvg sized itself over cells alone — so
the two disagreed the moment a legal square lay outside the played cards, which is every square that
would EXTEND the district. Reproduced on seed 555: three placements offered, the empty one drawn at
y=99 on a canvas 103 tall, i.e. off it entirely.
officeSvg now takes the ghosts and sizes itself over both, so there is one origin and one canvas,
and ghostSvg is deleted rather than fixed — a second coordinate system was the bug, not a detail
of it. It was also imported by the replay and never called there.
A test walks six seeds and asserts every square the menu offers has a target drawn inside the canvas: 129 squares across 47 placeable subjects. It fails on the old code with the exact coordinates above.
The railroad, heard
A whistle at the end of each Stage, the grade-crossing bell at the end of each Day, and the conductor when a train is built. Off by default — everything here is synthesised rather than recorded, so it is a placeholder for real audio and a playtester who did not ask for noise should not get any. One click in the title bar turns it on, and that click doubles as the gesture browsers require before audio may start.
Synthesised, not sampled, and that is a constraint rather than a preference: the site is a static folder that fetches nothing — there is a test asserting no page reaches an external host — so audio would have to be committed to the repo, and a plausible whistle is not something to invent. A real steam whistle is a CHORD of several chambers slightly out of tune with each other plus the breath of the steam, which three detuned partials and a band of filtered noise get most of the way to. The bell is inharmonic partials struck twice, which is what separates a bell from a beep.
"All aboard" is speech, and speech cannot be faked with oscillators. speechSynthesis is built
into the browser, needs no asset and works offline, so it says the words; where the platform has no
voice installed a two-note conductor's call takes its place rather than nothing happening.
The model names WHAT happened — a Stage ended, a Day turned, a train was made up — and the page decides what that sounds like. A Day boundary rings the bell only: both would collide, and the bell is the bigger event. The first Stage of the game announces nothing, because a Stage beginning is the previous one ending and there is no previous one.
Counted against the clock rather than trusted: over a full game, 60 Stage boundaries produced 55 whistles and 5 bells, and 5 Days produced 5 bells.
Regions, drawn from what the crossing already cost
§2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. The
engine had replaced that with crossingStages() — implications.md records it plainly: "The
Region model is gone" — because ten card types with real speeds cannot be expressed by one region
a Stage. Both REGIONS_PER_MAINLINE_CARD = 2 and entryPoints survived as dead constants.
The map draws regions again without reinstating the mechanic, because the printed cards say how: they carry Start positions, so a train with Brakemen enters further along the card. That is the same fact as "takes a Stage off the crossing", in different coordinates. So position falls out of what the engine already knows:
entry = REGIONS - stagesTotal position = clamp(entry + elapsed)
| crossing | drawn as | |
|---|---|---|
| 1 Stage (a 60 card) | enters at region 2, gone next Stage | the printed Start position |
| 2 Stages (a 30 card) | region 1 → region 2 | §8.2 exactly |
| 3 Stages (slow train) | region 1 → region 1 → region 2 | fixed distance, slow train |
entry is deliberately allowed to go negative and only the final position is clamped: that keeps a
slow train's extra Stage at the START, where being slow shows. Clamping the entry instead parked it
at the exit, reading as a train that raced across and then waited — which is what the first version
did, and what the test now forbids.
Transit gains stagesTotal, set on entry. State, not rules: nothing reads it to decide anything,
so the bot and every balance figure are untouched, and a save is a seed plus intents so there is
nothing to migrate. It cannot be recomputed later — a modifier played onto the card mid-crossing
would change the answer and make the train jump backwards.
Division Points draw one region, the queue. Running Track cards inside a district draw none: a crew moves there by Moves, not Stages, so it occupies a card outright.
The Division map shows the whole route
The map drew one box per node, which collapsed each player's entire district into a single "Office" tile — so the track a train actually runs along was invisible on the only view that shows where trains are.
An Office now expands into its Running Track, Limits to Limits. That is the right cut rather than a compromise: the through route IS the Running Track, and everything hanging beneath it is secondary track a crossing train never touches. Sidings, industries and the load pipeline stay in the district view, which is where they can be read.
West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
Trains are shown wherever they are. On a Running Track card, on a Mainline card, queued at a Division Point. A crew working BELOW the Running Track has no position on the through route, so it is reported against the district — "2 switching below" — rather than drawn somewhere it is not. Projecting a siding onto the through line would be a lie the map cannot support.
Seating. Players sit around a table, so the route is laid out the way they do: one row alone, two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop — trains enter at one Division Point and leave at the other — so the shape is deliberately left open, with buffer stops at both ends and the gap labelled "trains enter" and "trains leave". Closing it into a ring would promise a connection the rules do not have.
Layout is geometry with no visual feedback loop, so it is tested rather than eyeballed: for 1, 2, 3 and 4 players no two cells may overlap and none may fall outside the canvas.
The district folds itself away
Auto-focus. The district is worth its vertical space during the phases that change it — Local Operations and Cargo — and not during New Train, Mainline or Supervisor Shift, where the Division map is what matters. Folded, it leaves a summary line rather than vanishing, because a panel that disappears entirely reads as broken.
A manual toggle overrides it and stays put. This is DISPLAY state and never game state: two players at the same table may reasonably want it set differently.
The replay was storing the same things over and over
Halved, near enough: 3415 KB → 1877 KB on a 735-frame game.
The TODO said to carry links forward. Measured first, that would have bought 5% of the cells
payload — cells is 62% of the file, and inside it the cost is elsewhere:
what 32% of cells long prose, identical on every turnout in the district
facility 24% of cells the SAME object already serialised in the frame's `facilities`
identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
So all three are interned. A card's identity and its description are written once for the whole recording and referenced by integer, and a cell points at its facility instead of carrying a second copy of it. What stays per-frame is what genuinely changes: the cars standing there, the crew, and any Enhancement laid on the card.
rehydrateCells is exported and emitted into the page by toString(), the same trick the two board
renderers use — a second copy inside the page's inline script could drift from the packing and the
symptom would be a board drawing the wrong cards rather than an error. The round-trip test runs that
exact function over every frame, resolving nulls the way the page does.
Rolling stock was leaving the game
Four things were tried against bot revenue. One of them was worth more than everything else in this changelog combined, and it is the one that had been ranked third and predicted not to matter.
The Classification Yard was write-only. Seven places pushed cars into it — retired trains, collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained one way out of the game: 30 cars dead by the end of a game, 37% of the 80 dealt at setup. Gap 2c says engines and cabooses return to the Division Yard and everything else to Classification; the recovered rules never say how Classification empties.
ASSUMPTION, flagged rather than derived: sorting cars for redistribution is what a classification yard is for, and a Day is its natural cycle, so they now return at the Day boundary. This is a rules decision that wants confirming against the source.
Paired over 400 seeds: +2.32 ± 0.52, t = 8.79, 194 seeds better against 44. Revenue 6.70 → 9.02.
Why it was mis-ranked is worth recording. The Division Yard does not run dry in five Days — 16.6 loaded freight cars remain, empty in 2 games of 100 — so it was reasoned that supply could not be binding. The aggregate was never the point: what starves freight is not having the RIGHT commodity at the moment a green box needs stocking, and returning classified cars keeps the mix alive.
Three things that did not work, kept because the measurement is the result
Measured paired over the same 400 seeds, which is the only way to see effects this size.
Capping the draw option: worth nothing. 62% of all Local Operations actions went to drawing and 12.6 of 29 cards drawn were discarded, so a draw into a full hand converts straight into a discard. Refusing it: -0.10 ± 0.13, and 379 of 400 seeds byte-identical. The branch almost never fires.
Pairing the two halves of a load: worth nothing. Sampling every outbound industry at every loadUnload phase, 76% of the time it had NEITHER a stocked green box NOR a spotted car, and only 5% of Stages had a single workable facility. Letting the bot switch for a stocked box without waiting for a train at the Office changed nothing measurable — folded into the -0.10 above. The diagnosis was right and the prescription did not address it: there is usually nothing to switch.
Waking a dead branch made things worse. chooseLocalOption tested
options.some(i => i.type === 'mainline.modify'), but that intent requires s.turn.option === 'draw'
and the test runs while the option is still null — legalActions had already filtered it out, so the
branch could never fire and never had. Rewriting it to check the hand cost -0.64 ± 0.32
(t = -3.90), 104 seeds worse against 35. Its comment claimed the value compounds like a train
card's; it does not. The branch is now deleted, with the measurement in its place.
The Enhancements other than Interlocking are worth exactly nothing — and cost nothing either:
-0.01 ± 0.41 (t = -0.04) when the bot is forbidden to place any of them. Left as they are; there
is nothing to gain by restricting them. Note the first attempt at this experiment showed 0/400 seeds
changed, which was the experiment failing rather than the answer: a card.play fallback with no
placement filter was still placing them.
| before | after | |
|---|---|---|
| revenue (200 games) | 6.7 | 8.7 |
| wins | 6.5% | 12% |
| freight loads | 2.8 | 3.2 |
| cars dead in the Classification Yard | 28.4 | ~0 |
A Running Track with nowhere to put an Enhancement
Every penalty in the game has one cause. Across 100 games, all 48 were collision: no free A/D track — 2.70 revenue a game, 27% of gross, concentrated in about a fifth of games and
responsible for the −47 tail.
The rules already answer it. Interlocking prints "may stop an inbound train on the Limit Track", and
advance.ts:621 holds the train at the Limits instead of colliding. It had never once been
placed. Nor had any train ever been held at the Limits.
The reason was structural and nothing to do with Interlocking. The bot builds minimal two-arc
run-arounds — an ne arc meets an nw arc directly — so it never needed a straight and laid none:
0.00 straights on the Running Track across 100 games. Interlocking, Water Column and Telegraph
all require one; Yard Office and Small Yard want a Secondary Track Straight; Telephone and Radio
chain off Telegraph. Thirteen of the eighteen Enhancement cards that go on the board were
unplayable. They were drawn 3.78 a game and placed 0.64.
bestTrackLay now scores one straight onto the Running Track. Enhancements placed went 0.64 → 3.01
across nine types where only Overpass had ever appeared, and Interlocking now reaches the board in
about a quarter of games.
On whether it pays — measured properly, and the answer is qualified. Revenue per game has a standard deviation of ~9, so a 100-game run carries roughly ±1.0 of noise. Run against the same 400 seeds, paired:
| without | with | |
|---|---|---|
| revenue | 5.99 | 6.70 |
| collisions cost | 2.70 | 1.91 |
| worst single game | −47 | −24 |
| most collisions in a game | 10 | 6 |
| wins | 5.3% | 6.5% |
Paired per-seed the change is +0.70 ± 0.74 (95% CI), t = 1.87 — not significant on its own. And 154 seeds improved against 165 that got worse: the mean gain comes from removing catastrophes, not from making a typical game better. What justifies keeping it is that the mechanism is measured directly and accounts for the whole effect — collision cost falls 0.79, and revenue rises 0.70.
A correction to the numbers already in this file. The per-100-game revenue figures reported for the earlier changes carry the same ±1.0 noise, so the individual steps (5.0 → 6.0 → 6.5) were stated more precisely than the sample supports. The cumulative move from 3.2 to ~6.7 is far larger than the noise and stands; the individual increments should be read as indicative only.
The bot was throwing away its own freight
Routing turned out not to be the problem, which is why it was worth measuring first. Of 1650 drops across 100 games, 940 (57%) landed on a facility that wanted the car and zero landed on one that did not. The crew makes 9.4 correctly-targeted drops a game; the one-move-only destination test costs nothing measurable. "70% of waiting loads have nothing spotted" was a misleading signal — the cars arrive.
Following the freight from the other end found the leak. Loads were being destroyed:
stockToOutbound 9.45/game
loadStarted 2.71/game
facilityUnjammed 3.10/game from outbound <- healthy waiting loads, discarded
facilityUnjammed from outbound splices the load out of the green box and pushes it to the
classification yard. That load cost a Local Operations action to stock, so discarding it is strictly
negative — and the bot did it more often than it started a load.
Two fallbacks meeting, and the root cause is a familiar one: two rules for one act.
canStockProductively decided the Freight Agent option was worth taking if a matching empty car was
spotted. The engine's stockOutbound additionally requires a loaded car of that commodity in the
Division Yard — which the predicate never checked. So the option was chosen believing a box could
be stocked when none could; the follow-through then found nothing stuck, nothing to clear and
nothing stockable, and fell through to "clear whatever is stuck" with nothing stuck.
Fixed by making the predicate ask the same question the engine does, by no longer using Freight Agent as the idle default (switching at worst moves the crew toward the Office, which a train must reach to depart at all, §8.1), and by ordering the last-resort unjam by what it costs to lose — MEN|AT|WORK first, then the red box whose Revenue is already banked, and the green box last.
| cars fixed | freight kept | |
|---|---|---|
| loads discarded from a green box | 3.10 | 0.00 |
| revenue | 6.0 | 6.5 |
| wins | 5/100 | 8/100 |
| trains scheduled | 3.0 | 3.4 |
| cards played | 16.6 | 19.3 |
The gain is development, not freight. Loads started held at 2.70 and freight revenue at 2.6 — the recovered Local Operations actions went into drawing and switching rather than into the freight chain. Green-box stocking fell 9.45 → 6.34 because the bot no longer stocks boxes it cannot serve. The regression test asserts outbound unjams stay at zero and that genuine MEN|AT|WORK jams are still cleared, so gutting the fallback would not pass it.
Half the industries never asked for a car
Freight had not moved through two rounds of fixing the district, and this is why: three of the six industries were invisible when the bot chose what to put on a train.
wantedCars consulted a hand-written industry -> car type switch that had drifted from the sheet.
It named produceShed and oilRefinery — neither is an industry — and omitted freightHouse,
refinery and packingSheds, which are. An industry it could not name returned null and was
skipped entirely, so it never requested a car. The Refinery is the only source of tank traffic, so
tank cars boarded a train 0.07 times a game and were dropped by a crew zero times in 100 games,
while 23 of 79 waiting loads sat at an industry that wanted one.
It now derives the commodities from INDUSTRY_PROFILES, which is the sheet. The switch is deleted
rather than corrected — a second copy of the mapping is the bug, not the values in it.
A second, narrower collapse. facilityCarType returned carTypes[0], so the second commodity
of a two-commodity industry was unreachable: a Power Plant burns coal OR oil, a Grocer's Warehouse
receives dry goods OR perishables. facilityCarTypes (plural) now returns the full set, and the
bot's spotting and switching checks accept any of them.
Worth stating precisely: the engine's WRONG_CAR_TYPE gate was corrected to use the full set too,
but that changes nothing today — both two-commodity industries are inbound-only, so
freightAgent.stockOutbound rejects them before the commodity is examined. That fix is latent. The
measured gain is entirely the bot side.
| facilities fixed | cars fixed | |
|---|---|---|
| revenue | 5.0 | 6.0 |
| wins | 1/100 | 5/100 |
| freight revenue | 1.3 | 2.6 |
| freight share of gross | 25% | 37% |
| loads completed | 1.27 | 2.60 |
| tank cars dropped | 0.00 | 0.62 |
| reefers dropped | 0.04 | 0.67 |
Both regression tests were checked against the bug they guard: restoring the stale map fails the
tank assertion, and collapsing carTypes to its first entry fails the profile assertion.
Still 70% of waiting loads have nothing spotted at all. The commodity mix is right now; the volume reaching the industry tracks is not. That is a routing question — which facility the crew takes a car to — rather than a car-choice one.
Putting the industries on the siding
The run-arounds were being built and the industries were somewhere else — facilities sitting on one
stayed at 0.00 a game even at 91 districts in 100 with a closed loop. The cause was blunt:
facility placement was options.find(i => i.placement !== undefined), the first legal square the
generator happened to list, unscored, while track laying had sixty lines of scoring beside it.
Facilities are now scored, on the thing that decides whether a crew can serve them at all: a placement in line with a siding still being built becomes part of the loop itself (its own through track is a segment), so the crew reaches it from either end and can pass its standing cars (§A.5). Merely touching reachable track is worth less; the Running Track is a penalty, because a car left standing there is hit by the next arrival (§11.2).
A second bug surfaced immediately, and it is the interesting one. Scoring facilities onto the
siding row sent run-arounds down, 91 games in 100 to 36 — the industry took the square and the
loop stopped closing around it. runsAcross tested the card's KIND ("a track straight running
east-west"), so an industry standing in the line read as a dead end and the run refused to extend
through it. It now asks the card's PORTS instead. A Facility carries its own rails (§11.2), and so
do the Office and a Limits sign; what matters is whether a port faces this way.
The reachability walk is now shared between track laying and facility placement rather than written twice — two copies would eventually disagree about whether a district connects, which is the one thing both decisions rest on.
| before sidings | sidings fixed | facilities fixed | |
|---|---|---|---|
| facilities on a run-around | 0.00 | 0.00 | 1.08 |
| games with a run-around | 0/100 | 91/100 | 71/100 |
| revenue | 3.2 | 4.1 | 5.0 |
| collisions | — | — | 0.4 (was 0.6) |
| track pieces spent | 19.5 | 16.1 | 13.6 |
Run-arounds fall from 91 to 71 because facilities now compete for the siding squares — which is the trade being made deliberately: a loop with an industry on it is worth more than an empty one.
Freight did not follow. Loads completed 1.42 → 1.27 and freight's share of gross 31% → 25%; the revenue gain is passengers and fewer collisions. Of facilities holding a load, 77% still have nothing spotted at all. The industries are now reachable and the right cars still are not arriving — which is the car-selection problem in TODO, untouched by any of this.
The bot was building stubs, not sidings
Measured first: 0 run-arounds in 100 games. A run-around is the engine's own definition of a
useful siding (track.ts) — double-ended, both ends reaching the main, and §A.5's facing-point move
is impossible without one. Every district the bot built was dead-end stubs, 3.86 of them a game,
plus 2.89 cards below the main that reached nothing at all. Before trusting a zero the detector was
handed a run-around built on purpose and found it from both ends.
Three bugs, all the same shape: scoring on local form without checking it reaches anything.
- The +12 rule was commented "close the loop back up to the main: the run-around is complete" and
only tested that a neighbour ran east-west — never that the card above had a south port to join.
The siding terminated in an arc pointing north into empty space. It now requires a way up above,
asked of the engine's
hasPortso the Office counts too;divergesSouthhad looked for a turnout and missed the one way down that is on every board. An arc's facing also decides what it meets —nwjoins west,nejoins east — so closing from the wrong side connected nothing. - The east-west extension had no stopping condition, so the run went on past the last column it could rejoin at, in 96 of 100 games. The loop then missed by one card.
bestTrackLaynever declined. This was the one that actually mattered, and the first two fixes barely moved the overshoot without it: the function returned its best-scoring option unconditionally, so once the useful squares were taken it kept laying track because track was legal. Bonuses are now tracked apart from the distance score, and a piece that earns none is not laid — the Stage falls through to playing a card instead.
Anchors also have to be reachable from the main now. A stranded east-west straight made both its neighbours look like legal extensions, so a fragment joined to nothing grew in both directions.
| before | after | |
|---|---|---|
| games with a run-around | 0/100 | 91/100 |
| track laid east of the last way up | 96/100 | 0/100 |
| track pieces spent | 19.5 | 16.1 |
| revenue | 3.2 | 4.1 |
| freight revenue | 2.6 | 3.8 |
| cars dropped | 11.6 | 20.9 |
| loads completed | 0.99 | 1.42 |
Two regression tests, both walking the district with the engine's own exitsFrom so they cannot
credit a connection §A.1 forbids: one asserts run-arounds get closed, the other that no track is
spent east of the last column with a way up.
Still open. Facilities sitting on a run-around: 0.00. The loops get built and the industries are not on them, so the run-around is not yet paying for itself in freight — which is the next thread, not a finished one.
Freight, drawn where the work happens
The load pipeline moved onto the card. A load crosses green → MEN | AT | WORK → a spotted car,
and that journey is freight. It was drawn only in the side panel, so a Laborer action — the whole
of the freight game — changed nothing on the card the player was looking at. The squares now sit
under the track, which is where the printed cards put them and why they are printed at all. The
tooltip names the same thing in words: which square the load is on, and what the next Laborer action
does with it.
A collision found while placing them. overpass and facingPointLocks are placed onCard, so
they can land on a facility, and the enhancement label's baseline ran straight through the new
squares. The label moved into the gap between the crew tray and the pipeline; a test now asserts the
two do not overlap rather than trusting the two constants to stay apart.
Teaching the bot to use a siding
Nose coupling, which the rules had and the engine did not. §A.3: "engines also have couplers on the front end, so a train can pick cars up onto its nose". Coupling always appended to the back, so which end cars landed on did not exist — and that is precisely what a run-around is for. Cars met running FORWARD now couple in front, cars met BACKING couple behind, so the approach decides which car is next off the tail:
running forward -> reefer, boxcar, hopper next off: hopper
backing up -> boxcar, hopper, reefer next off: reefer
The bot prefers a run-around to setting a car down, since it keeps the car — but only when the drop can actually follow. Without that guard it ran the loop for its own sake (8 a game, 63 Moves), which the shuttling regression correctly failed.
A serious bug found on the way. The carsCoupled reducer cleared every card in the Office
Area, not the ones the crew ran over — its own comment said "every card along the path" while the
code iterated the whole grid. One coupling anywhere deleted every standing car and every industry
track in the district, so loads worked over several Stages vanished when a crew picked up an
unrelated boxcar somewhere else. The event now names the cards it lifted from.
A test replaced rather than relaxed. "Under 60 Moves a game" started failing. That threshold was calibrated when the crew coupled ~0.4 cars a game; it now couples ~8 and drops ~11, and a run-around is supposed to cost several Moves. Counting Moves was always a proxy for aimlessness, so the test now measures the thing itself — Moves per drop-or-coupling — which still fails when the bot is made to wander.
| before sidings | now | |
|---|---|---|
| revenue | 3.9 | 3.2 |
| freight | 0.9 | 1.0 |
| drops per game | 3.3 | 11.1 |
| couplings per game | ~0.4 | 7.9 |
Switching is transformed; revenue is not. The crew now does real work — roughly 4 Moves per productive act, which is what running a loop costs — but that work is not yet converting into Revenue. Where it goes next is an open question rather than a known fix.
Board rendering, three-page site, curve geometry
Drawing the board as track, splitting the site, tooltips, and the curve fix.
Board rendering
Two renderers in src/sim/board-svg.ts, chosen because they fail in opposite places:
- Office Area — the district stays a map. Cards on a grid with the rails drawn edge to edge, so a join is rail meeting rail rather than two descriptions that happen to agree. The through rail sits at a constant height on every card, which is the alignment the printed cards use.
- The Division — not a map but a queue of sections with hard capacities, so it is a dispatcher's
diagram: one line per track,
1 of 2 freeunder each section,no limit — trains queueat the Division Points.
Both are self-contained — no imports, no module-level helpers — because the playable app imports
them normally while the replay is a single HTML file with an inline script that cannot import
anything, and embeds them via Function.toString(). One implementation either way; a second copy
would eventually draw a different board from the same state.
Rails come from the engine's own connectionsFor, now exported. A drawn rail cannot claim a
connection the rules do not have — visible in that Modifier cards draw no rails at all.
Capacity, confirmed from the rules and now shown: Division Points unlimited, most Mainline cards 1, Double Track and Uncontrolled Siding 2, Offices 1/2/3/4 by tier.
Three pages
index.html is now a splash with two doors. The game moved to play.html; replays.html is a
directory.
A replay is a save, and a save is {seed, history}. The engine is deterministic and runs in the
browser, so re-submitting the same moves rebuilds the position exactly — 18 KB instead of 3.4 MB,
about 190× smaller, small enough to email. A save that would describe an impossible position cannot
be replayed at all, because every step goes back through applyIntent; the viewer stops and says so
rather than showing a board the rules could not produce.
Static hosting cannot list a directory, so replays/manifest.json is generated at build time from
whatever is in public/replays/, with each file validated first.
Tooltips
Reference detail is read once and printing it costs the space the board and action list need. A
single delegated tooltip (src/web/tooltip.ts) now carries card effects, action reasoning, and
facility state. Not the native title: that waits a second, cannot be styled, and never appears for
keyboard users.
Curve geometry — the significant fix
A curve was modelled as a through track plus a diverging leg, which is a turnout. Consequences:
- Curves were topologically identical duplicates of turnouts — same connections, no distinction beyond the sharp curve's 2-Move cost.
- Every diverging leg went south, so no piece anywhere reached north except an n-s straight, which connects n↔s and nothing else.
- Therefore a district could only ever be a vertical column: no siding, no parallel track, no run-around, no second turnout back to the main.
The printed cards (docs/tracks.png, rows 3–4) show a curve as a single arc from edge to edge with
no through track. A curve is now a two-port arc, rotatable to ne/nw/se/sw. A sharp curve
is geometrically identical and costs 2 Moves. Turnouts keep §A.1 unchanged — a two-port arc has no
third port, so the absent-edge rule cannot apply to it.
Verified through the engine, not the types: a crew leaves the main at a turnout, runs a siding parallel, and rejoins at the far end.
Measurements
| revenue | freight | sidings built | |
|---|---|---|---|
| before | 4.1 | 0.5 | impossible |
| geometry only | 3.9 | 0.9 | possible, not sought |
| bot builds sidings | 3.3 | 1.1 | 59/60 games |
Freight more than doubled and the harness recorded its first win, but overall revenue is down from geometry-alone and the bad tail worsened (−29 → −59).
Capping turnouts at one or two measured worse (revenue 2.5, freight 0.6) — more ways off the main means more industries the crew can reach, and that beats a tidy Running Track. The uncapped version stands, with that measurement recorded at the code.
The bot builds sidings but does not exploit them. It pays about 20 of its 26 track pieces for them while its switching logic still sets out dead weight on a spur rather than planning a run-around. That is the next piece of work and where the revenue should appear.
Earlier commits
Recorded from memory of the work rather than written at the time; detail thins going back.
f00255c — more fixes and tweaks
Card descriptions on every card in hand and every face-up slot, the three Local Operations options
explained, the objective and pace in the header, and rotations named in words rather than
"rotation 2". Build stamp added (version, git SHA, -dirty for an uncommitted tree) because nothing
tracked what was deployed. Extra X22 was not a bug — a per-diem train whose card calls for a
caboose and nothing else — but "loaded caboose" was.
d1f689d — name the caboose, the train and the Running Track hazard
maneuver.redFlags was labelled "Red Flags on tray3": the earlier tray-id fix checked the history
log, and the action list was a surface it missed. An industry on the Running Track does not block
traffic (§11.2 gives it rails) but a car left standing there is hit by the next arrival (§10).
dd300ac — fix caching, Limits placement, and explain the board better
The first deploy served a fresh index.html against a cached main.js, which threw
missing element: target and never started. Every module import now carries the build tag. The
Limits fix was half-done: the sign could move, but nothing forbade building past it, so one straight
at (0,2) produced limits · Whistle Post · limits · straight · limits.
2eca9de — playable browser build
The solitaire game as a static site, proven to need no server: full games run with every Node global replaced by a throwing stub. Train cards stopped accepting a board placement — seed 555 had offered Extra X15 at six squares with six rotations, all identical.
160190d — the bot's reasoning in the replay
Decision panel showing what the bot chose, why, and every option it passed over, with the reasons reported by the bot itself rather than re-derived by the viewer.
d261ad7 — parking trains, freight deadlock, Whistle Post lock-in
Three faults found by measurement rather than failing tests. Trains parked because destinationsFor
computed reverse as facing === 'e' ? 'w' : 'e', so a crew facing south reversed to east — a port a
north-south card does not have. Freight ran at a 3% load completion rate because a load started
with no spotted car parks on WORK and locks the industry track, blocking the very car that would
clear it. Office density doubled after 25 of 100 games never drew a Depot and never escaped a
one-track Whistle Post.