- The save warning is a warning: 15px, weight 500, bright amber on a deeper ground with a 5px rule down the side. (What Jesse was looking at is v0.7.8, where this line is still the small grey .ng-note — none of 0.7.9 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, a Timetabled train may be discarded) ships ON in all four types, not just Co-op. The note now says only what holds for all of them. Stays in the unshipped v0.7.9. 877 tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
5641 lines
367 KiB
Markdown
5641 lines
367 KiB
Markdown
# 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.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. `markUnavailable` is down to dimming, and `.lb-why` is 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.mark` already 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-note` earlier 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.local` runs 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."
|
||
|
||
### 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".
|
||
|
||
877 tests pass, ten 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 flat
|
||
`Intent[]` with no seat written down; the replay derives who acted from the turn order. That works
|
||
for every other intent, `mainline.clearance` included, because there is exactly one seat it could
|
||
have been. Not here: every seat may vote in any order. `game.extend` therefore carries its voter,
|
||
uniquely, and the server checks it against the seat it authenticated.
|
||
- **An all-bot game hung on the question for ever.** `driveBots` loops on `currentActor`, 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.ts` went from under a second to never finishing. `randomBot` picks 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 to `playGame`'s
|
||
50,000-turn cap instead of a couple of hundred. Giving `developerBot` a policy was not enough: the
|
||
guarantee has to hold for every policy, so **`playGame` itself 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.ts` stopped where `currentActor` went 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 in `public/replays` predate the vote and stop on the question
|
||
when replayed; `test/harness.test.ts` accepts 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 left` with both halves
|
||
false; in an extended game it now reads the Day and how far beyond the timetable play has got.
|
||
- `objectiveOf` paces against the timetable actually being played rather than `config.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 `localStorage` only at
|
||
`Lobby.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 a `stage`, and a reload walks `/api/session` then the lobby stream to
|
||
land wherever the seat actually is.
|
||
- **There is a way out.** `POST /api/lobby/leave` frees 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.** `onmessage` was 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 at `Lobby.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 `startLobby` will 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 `.dim` grey: `LOBBY_FULL` was 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 to
|
||
`SOLO_CONFIG`, and `Save` carries 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_CONFIG` deliberately 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. `pendingExtras` was a bare
|
||
`number[]`, 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 with `NOT_YOUR_EXTRA` and is
|
||
not even offered a start point. **No migration**: a save is a seed plus intents, so a `GameState`
|
||
shape 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. `Frame` has carried `superintendent`
|
||
since 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: `localStorage` held exactly ONE session, so joining a second game overwrote the first
|
||
token and locked that seat out permanently — `TODO.md` had 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.
|
||
- **`?lobby` now 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 `disabled` on 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:
|
||
|
||
1. **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.
|
||
2. **The password is a header** — `X-Password`, URL-encoded — not a JSON body field.
|
||
3. **The path is a query parameter** (`?path=`), and every resource call must also name a
|
||
**`source`**: 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/StationMaster-Mainline-Deck-v0.4.5.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 `StationMaster-Home-Deck-v0.4.5.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 is `SOLITAIRE_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 `trainPerTransit` still 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:
|
||
|
||
- `check` asked 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, `check` said 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.
|
||
`StationMaster-Home-Deck-v0.4.5.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.beginUnload` for the
|
||
district rule, not from play. It gates on `allows.inbound`, a loaded car, a matching empty in the
|
||
yard and room in the red box, but never on `facilityCarTypes`, which `freightAgent.stockOutbound`
|
||
does 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.ts` catches 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 on
|
||
`game.ts`'s existing `Game`/`submit`/`currentActor`/`actionMenu` rather than re-deriving intent
|
||
application or narration. **Found while building it:** `submit()` derives the acting player from
|
||
`currentActor(game)` and never checks who is actually calling it — harmless for `LocalSession`
|
||
(only one possible caller) but not safe for a server, so the session host now verifies `seat ===
|
||
currentActor(game)` itself before calling `submit`, rejecting with `NOT_YOUR_TURN` otherwise.
|
||
Idempotent resend (a repeated `seq`) and the illegal-intent path (checked via `check()` 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` — plain `node:http`, no framework: `POST /api/game`,
|
||
`GET /api/stream` (SSE, per-seat, 20s heartbeat, `id:` line per push), `POST /api/intent`, and
|
||
static serving of `dist/` 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 reusing `replay.ts`'s `compress()`,
|
||
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 used `seat` for the `hand` field — everything
|
||
else came from `currentActor(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 in `game.ts`; tested in `multiplayer.test.ts`.
|
||
- The redaction test (§7, `test/redaction.test.ts`) passed on the first run against the existing
|
||
`snapshot()`, confirming it was already correct rather than just apparently so.
|
||
- `src/web/session.ts` gained `createRemoteSession`; `main.ts`'s `start()` switches on `?seat=`
|
||
presence (one bundle, unchanged). Every `LocalSession`-only call site in `main.ts` now goes through
|
||
an `isLocal()` type guard instead of assuming.
|
||
- Verified two ways: `test/server/session.test.ts` exercises 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 resent
|
||
`seq` producing no second push, the board correctly nulled on the second push). **Not verified: an
|
||
actual browser** — no browser binary in this environment, so `RemoteSession`'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}`) and `turn-timings.json`, both atomic-rewrite-then-rename.
|
||
- `game.ts` gained `fromMultiplayerSave`, `fromSave`'s multi-player sibling. **Found while testing
|
||
it:** `fromSave`'s replay loop calls `record(game, result.events)` without the `actor` argument
|
||
`submit()` 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; `fromSave` itself 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.ts` gained `exportSave()`, `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 inside `history` (a replay
|
||
must reproduce a game from decisions alone).
|
||
- `index.ts` loads `game.json` on 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-edited
|
||
`engineVersion`.
|
||
- **Found and fixed an infrastructure bug along the way:** adding `test/server/` broke `npm 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, and `npm test` silently 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. `newTrainPhase` never implemented the round —
|
||
`enterPhase` resets `actorOffset` to 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 off `tray.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
|
||
`GameConfig` set (`days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
|
||
`pvpCardsAllowed`) replaces the old fixed `LENGTH_PROFILES.target`, a dead `firstToTarget` victory
|
||
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.ts` catches 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_modules` was a **tracked symlink pointing at a path that does not exist**, so `tsc` was
|
||
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+2` gave `reefer 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.** `couples` was 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 `actionButton` splits 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'`, and
|
||
`usableGrant` drops 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.** `movesFor` is pure track geometry and six
|
||
cards print "no switching", which `check` enforces 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 assuming
|
||
`opposite(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, and `exploreMoves` returns 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.** `movesFor` explores from `facing`, 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 `moveTrain` in 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 has `porters: 0`. Now `NO_PORTERS_HERE`, and the
|
||
panel says to upgrade the Office. 15 occurrences in 60 games.
|
||
- **`NO_TRAIN_AT_OFFICE` was 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 now
|
||
`NO_PASSENGERS_WAITING`, `NO_EMPTY_COACH`, `NO_LOADED_COACH`, `INBOUND_BOX_FULL` and
|
||
`NO_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 `Frame`s
|
||
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 is
|
||
`seatOf(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.md` to 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`/`canDetrain` now filter the trains at the Office rather than taking
|
||
any of them, so the rule actually bites; a new `passengerRefusal` works 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 `speechMade` flag on the
|
||
tray, alongside `stopPointClaimed`, 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.** `stockToOutbound` splices a loaded car OUT of the Division Yard to become
|
||
the load, and `loadCompleted` banks the emptied car as the loaded one takes its place.
|
||
- **Inbound is not.** `unloadBegan` turns one loaded car into an empty car *plus* a load on
|
||
MEN|AT|WORK, `passengersDetrained` does the same to a coach — and `inboundCleared` then 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.** `play` highlights 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. `discard` lights 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:
|
||
|
||
- `cuesFor` decides 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.
|
||
- `playCue` decides what that sounds like. It was three module-level functions with a shared
|
||
`AudioContext`; it is now one self-contained function, embedded into the replay by `toString()`
|
||
exactly as the board renderers are. The context is parked on `window` because 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 `hasPort` so the Office counts too; `divergesSouth` had looked for a turnout
|
||
and missed the one way down that is on every board. An arc's facing also decides what it meets —
|
||
`nw` joins west, `ne` joins 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.
|
||
- **`bestTrackLay` never 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 free` under each section, `no limit — trains queue` at 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.
|