Files
station-master/TODO.md
T
Jesse.MarkowitzandClaude Opus 5 6f2a8dff09 The guide still has the workshop showing — TODO #111
Jesse's read of the rendered documentation after 0.8.2: dramatically better, and still
carrying too much of what was said about the game rather than what the game is. Four
things to fix, recorded with the measurements so the pass can start without re-surveying.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-23 07:43:24 -04:00

3668 lines
258 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# To do
Things worth coming back to. **Anything here either gets done or gets an explicit decision not to**
— the point is that nothing quietly evaporates. A declined item stays, with the reason.
## How this file works
**Work sections come first, in priority order**, and each holds every open item for one area of
work. An item is a few lines: what it is, what it blocks, what it would cost. The measurements,
rulings and rejected approaches behind it live in **Reference** at the back, keyed by the same
number — so a section can be read at a glance and the argument is still there when it is needed.
**The numbers are permanent ids, not positions.** An item keeps the number it was raised under for
life, wherever it later moves, because commit messages, Gitea comments and other items refer to it
by number. Nothing is renumbered. New items take the next free number.
**Restructured 2026-08-30**, from eight subject sections plus a 43-entry chronological queue that
had come to duplicate them. The queue was where the priority lived and the sections were where the
reasoning lived, so every live item existed twice and drifted between the two — eight items were
closed in one place and left open in the other, which reads as live work and is worse than no entry
at all. One item per place now.
## Every time — the process this project runs on
Not items. Things that are true of every change, and that have gone wrong when skipped.
- **Update the documentation set in the same change.** `docs/quickstart.md`, `rules.md`,
`home-deck.md`, `mainline-deck.md` and `components.md` describe the game as built, and every
release restamps them — `**Version x.y.z** · date` is the second line of each. **The wrapper's
`instructions.md` is part of the set**: it is what a StartOS operator reads, so a change to how
the service is set up, run or recovered belongs there in the same commit. If a change alters what
a player does, sees or may rely on, the affected document changes with it. Four rules govern what
goes in them:
- **Version at the top**, before anything else on the page.
- **No history and no rationale.** No "this used to", no "corrected in v0.8.x", no ruling dates,
no TODO numbers. The documents say what the rules ARE. The reasoning belongs in `CHANGELOG.md`
and the argument in this file.
- **`instructions.md` is a manual, not a changelog.** It accumulated twenty "What changed in …"
blocks — 380 of its 469 lines — before they were deleted in 0.8.2. What changed in a release
goes in the wrapper's `releaseNotes`, which is what StartOS actually shows on update; the
instructions say how to run the service as it is now.
- **Card tables are generated, never typed.** `npm run build:cards` writes them into `home-deck.md`
and `mainline-deck.md` between `<!-- BEGIN CARDS: … -->` markers, and
`test/card-reference.test.ts` fails if a checked-in table disagrees with `content.ts`.
- **The Markdown is the source; the pages are built.** `scripts/build-web.ts` renders each
document to `<name>.html` through `scripts/markdown.ts`. Never edit a published page — and if a
document needs a construct the renderer does not cover, extend the renderer and test it rather
than writing HTML into the Markdown.
- **Ask Jesse what the version bump should be.** Third digit is a bug fix, second is a new set of
features, 1.0 is the first release worth the name — but which one a batch deserves is a judgment
about how finished it feels, and it is his. The number lives only in `package.json`;
`scripts/build-web.ts` stamps it into every page, so it is visible on the deployed site.
- **Commits and tags are GPG-signed and I cannot make them.** Stage the work, write the message to
a file, hand over a `!` command. Tags must be `git tag -s` — a bare `git tag` makes a lightweight
tag and `git push --follow-tags` skips it *without any error*.
- **Deploy from the right line.** `npm run deploy:web` defaults to the same destination on both
lines, so deploying from `station-master/` silently replaces the playtesters' build with main's.
- **A failing test written before the fix is the only thing that proves a fix.** Three releases
(v0.7.5 through v0.7.8) each reported the same bug fixed, and each fixed something real that was
not the reported fault, because every verification read what the SERVER served rather than
exercising the path with the state a returning player actually has. When a report repeats,
reproduce the user's state first, and treat "I verified it" as unearned.
- **Verifying over the HTTP API is not verifying the game.** It renders no dialog. Extended play
was recorded as checked on `phoenix.local` and was unusable in a browser (#35).
- **Keep `CHANGELOG.md` current in the same change.** Commit messages stay high level; the
reasoning, the measurements and the things that turned out to be wrong live there.
30. **Close a Gitea issue with `Closes #<n>` in the commit — not by hand** (Jesse, 2026-08-30).
Gitea records the linking commit itself when it auto-closes, so a manual close adds nothing but a
step, and the same rule is what the workspace `AGENTS.md` already prescribes.
**What auto-closing does NOT carry is a RULING, and that still has to be written by hand.** When
the fix was not what the report implied, comment on the issue before it closes, saying what was
decided and why. That is the half worth keeping: Gitea#15 was REVERSED on review — the placement
is legal, and what was actually confirmed is that no train crosses the gap — and Gitea#14's
comment lists the ten unbuilt cards so they do not vanish along with the issue. A bare
`Closes #<n>` would have lost both.
This item said the opposite until 2026-08-30 ("close each issue by hand… auto-closing leaves no
record"), which was written from how the issues closed in v0.7.1 and v0.7.2 rather than from a
decision. The token and the API calls, for reading issues and for leaving a ruling, are in the
workspace's `AGENTS.local.md`.
37. **The wrapper release sequence, unchanged since 0.7.2 and worth following exactly.** Tag the app,
fetch the tag into the wrapper's submodule, bump `current.ts` **in place** (the outgoing `up` has
been empty every time, `versions.md`'s common case — no new version file, no migration), rewrite
the release notes in all five locales, update `README.md` and `instructions.md`, then
`npm run check` / prettier / `make x86` / `make install`. `UPDATING.md` in the wrapper is the
authority and has not needed changing.
**Say which way games in progress go, in every locale, every time.** 0.7.2 broke them (206 cards
to 121, so a card id recorded under 0.7.1 refers to a different card or to none) and led with it;
0.7.3 and 0.7.4 carried them and led with that. It fails safe either way — `src/server/index.ts`
refuses a save the rules reject, names the move it stopped at, and leaves the file untouched, so
an operator can put the old version back to finish a game that matters.
## Sections
1. **Play it at a table** — #39 #35 #42a #40
2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #75
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
4. **The screen** — #44 #81 #33 #36
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
6. **Rules** — #12 #80 #82 #83 #85 #108
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
9. **Code health and housekeeping** — #46 #45 #84 #87
10. **Documentation and assets** — #15a #86 #88 #111
Then, at the back: **Reference** (the measurements, rulings and rejected approaches behind the
items above) and **Done** (everything closed, kept because several of them are the only record of a
ruling or a lesson).
---
## Play it at a table
The largest gap in the project, and none of it is a coding gap. Features are shipped, packed,
running on `phoenix.local` — and the items below name the ones no person has met at a board.
Everything else in this file waits behind a release; this waits behind an afternoon.
**Test runs WERE made across 0.7.4 through 0.7.9** (Jesse, 2026-09-07) and produced no change
requests — the two bugs that did come out of them are Gitea#21 and #22, fixed in v0.7.9.1. So this
section is not "nobody has touched it since 0.7.4"; it is the narrower and still-true claim that the
specific paths below have not been exercised at a table.
**The gate moved.** It was "before 0.8.0 starts"; 0.8.0 shipped anyway, through v0.8.0.8, so the
session now runs against that build and covers what it added as well. See **Preparing the session**
below — written 2026-09-10 because the measurement it rests on is the whole point: **three of the
four things this section is named for do not happen by themselves.**
### Preparing the session
**MEASURED, 2026-09-10, across ten full competitive games driven to completion.** What a table will
meet without trying, and what it will not:
| interruption | fires in | so |
| --- | --- | --- |
| Superintendent clearance (§8.1) | **9/10 games** | you will meet it; just play |
| a train held at the Limits | 7/10 | ditto |
| Extras started and queued | 10/10 | ditto |
| collisions | 7/10 | ditto |
| Red Flags set / spent | 7/10, 6/10 | ditto |
| **the Yard Office offer** | **0/10** | must be set up |
| **the Red Flag hold and its prompt** | **0/10** | must be set up |
| **extended play (`dayExtended`)** | **0/10** | must be set up |
Those last three are exactly what #39 and #35 are NAMED for. They are not broken — they are
conditional, and the conditions are these, read out of `advance.ts` rather than guessed:
- **Yard Office** (`advance.ts` ~1290) needs the destination district to contain a card carrying the
`yardOffice` **enhancement**, AND an arriving train with **no coach** in its consist, AND a usable
route. The bot never builds one, so **somebody has to build a Yard Office and then let a freight
train arrive.**
- **Red Flag hold** (`advance.ts` ~1232) needs the destination player to be **holding the Red Flags
maneuver card**, AND an arrival that would genuinely collide — §8.3's own two ways: no free A/D
track, or cars fouling the Running Track. So: **hold that card and let your A/D tracks fill.**
- **Extended play** needs the timetable to RUN OUT, which a five-Day game does not do. Deal it with
**`days: 1`** — that is exactly what the 2026-08-29 API verification did, and why it got there.
**What the session needs**
- **Two people, two browsers, two devices.** #35's remaining gap is specifically what a SECOND
player sees while waiting on a first, and whether "waiting on Carol" still reads once Carol has
closed her laptop. That cannot be tested alone, and it is the half that has never been done.
- **Two games, not one.** A short `days: 1` game to reach the extension vote, and an ordinary game
for everything else — with somebody deliberately building a Yard Office and holding Red Flags.
- **#42a is separate and takes five minutes**, solitaire, one person: click every field on the setup
screen and confirm the dealt game matches what was chosen.
**The caution this section exists because of.** #35's own Reference entry records that the
2026-08-29 verification passed over the HTTP API — **which renders no dialog** — and that is exactly
why the v0.7.9 bug survived: the vote sat underneath a modal results dialog whose only control was
Close. What was proven was that the SERVER supports extended play, not that a player can reach it.
Read that into every "verified on `phoenix.local`" line in this file, and into everything v0.8.0
added, all of which is verified by test and simulation and none of it by eye.
**What v0.8.0 added to this list**, none of it played by a person for a whole game and none with a
second human: the watchable board and its ordered steps, the speed control, the pile highlighting and
the Home Office deck tile, "Your Move" being put away while catching up, the Day-end collision line,
and the Salvage Yard naming its top card.
### The checklist
Grouped by what has to be set up, with the item each observation closes. Nothing here needs a
developer present; what it needs is somebody writing down what they saw.
**Game A — `days: 1`, two humans, two browsers.** Reaches the extension vote in one Day.
- [ ] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is
the exact shape of the bug v0.7.9 fixed).
- [ ] While one player has not voted, the other's turn chart says **who** it is waiting on (#35).
- [ ] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and
does "waiting on Carol" still read once Carol is gone? (#35 — never tested.)
- [ ] Reopen it. The history panel comes back **populated**, not empty, and the board is current
(the v0.7.9.5 reconnect fix, never seen by a person).
- [ ] Vote yes. The extra Day begins and the official result is **unchanged** from when the
timetable ran out (#35).
**Game B — ordinary length, two humans, bots to fill.** Everything else.
- [ ] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer
interrupts the Mainline Phase and asks a question mid-thought — is it legible, and does it say
which train? (#39)
- [ ] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would
collide. The hold is offered out of phase (#39).
- [ ] A **loaded Extra** is made up and run (#39 — the third of its three).
- [ ] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye,
does the caption say who and what? (v0.8.0)
- [ ] Find the speed that suits you and say what it is — it becomes the committed default.
- [ ] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all.
- [ ] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot
contradict itself (v0.8.0.2).
**Solitaire, five minutes, alone.**
- [ ] Click through **every field** on the setup screen and confirm the dealt game matches what was
chosen (#42a).
**Whatever else happens.** The two bugs that came out of the 0.7.4-0.7.9 runs were both things
nobody set out to test. Write down anything that reads wrong, even where the rule underneath is
right — most of this release's defects were legible-but-wrong rather than broken.
- [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end,
packed, and running on `phoenix.local` — and nobody has met any of them at a board. **Two are
interruptions that stop the Mainline Phase and put a question in front of somebody
mid-thought**, which is exactly the kind of thing only play reveals. **Neither of those two
happens by itself — 0/10 games. See Preparing the session above for what to set up.** See
**Reference · #39**.
- [ ] **#35** — **Extended play has never been played at a real table.** It was verified over the HTTP
API, which renders no dialog — and when a human first reached it in a browser it was unusable
(fixed in v0.7.9). The multiplayer vote has still never been driven through two browsers: what a
second player sees while waiting, and whether "waiting on Carol" reads once Carol has closed her
laptop, are unanswered. **Needs a `days: 1` game — the timetable does not run out in five
Days, so extended play fired in 0/10 measured games.** See **Reference · #35**.
- [x] **#42a** — **CONFIRMED at a table, 2026-09-23.** The solitaire setup screen's own fields were
clicked through and the dealt game matched what was chosen.
- [x] **#40** — **DONE, 2026-09-23.** One sentence, in the four places a player meets a save: the
Quickstart's reporting section, a Rules FAQ entry ("Will an old save still replay?"), the
replay viewer's own page, and a tooltip on the **replays** link in the game — which had no
tooltip at all before. Also in the package's `instructions.md`.
---
## The common board, and watching play happen — Gitea#20
One shared, seatless display of the public game, usable on a TV or in OBS on its own and publishable
into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`, seven steps.
**THE RELEASE SPLIT, settled with Jesse 2026-09-09. Jesse: "13 is the key. Watching on a TV is the
bonus."**
- **v0.8.0 — #13, #15 and #18: the watchable table.** The step collector, steps on the `Session`
interface, **foreign-district rendering**, the client animation queue, pacing by kind, and the
behind-counter. **The design is the plan's § v0.8.0**, which supersedes the parts of steps 2-4 it
covers. Needs no HTTP work at all.
- **v0.8.1 — the seatless board page.** `display.json`, `viewToken`, `/api/display/stream`,
`/display.html`, an all-districts layout: most of step 2 and step 3 without its canvas. Cheap once
0.8.0 lands, and nothing in it moves #13 forward.
- **v0.9.0 — the Jitsi publisher.** Steps 5-7 plus step 3's canvas pipeline. Held off deliberately:
it needs Chromium in the image (several hundred MB onto a 63 MB `.s9pk`) and measurement on
`phoenix.local`, and the only self-hosted Jitsi available needs an authenticated moderator to open
a room, so "waiting for moderator" is the ordinary path here rather than an edge case.
**The correction that set that split:** `Frame.cells` is ONE district — the viewer's own
(`view.ts:510`, from `areaOf(s, viewer)`), exactly as **Reference · #13** already said. So a step
stream alone does not answer #13; the data would arrive with nowhere to be drawn. Rendering a
district you do not own is the core of 0.8.0, not part of the seatless page. Two other decisions
taken with it: **no WebSocket and no new runtime dependency** (SSE down + POST up, the pattern
`server/http.ts` already uses), and `protocolVersion` on the wire in 0.8.0.
**Step 1 is BUILT** — v0.7.9.2 through v0.7.9.5 (#91, #92, #95, #97), with one item struck off
rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and again
on 2026-09-09, and now says which of its "current code findings" are history: it had drifted badly
enough to send the next reader fixing things twice. Steps 2-7 were never implemented; step 4's
findings were re-verified 2026-09-09 and steps 5-7's were NOT — check each before building on it.
See **Done · 103**. Items that look like screen polish live here because they need step 4's ordered
presentation mechanism and nothing cheaper.
**The design is settled — the plan's § v0.8.0 is the authority.** In outline: public steps animate
the board while the existing private Push supplies hand, menu and objective (so there is no new
redaction surface); the collector is a shared `sim/` module both `LocalSession.submit()` and
`GameSession.submit()` call, so solitaire and multiplayer run one code path; dwell is assigned **by
kind** with switching protected at 1s and bookkeeping at zero; and a `[N behind] … [Skip]` row shows
the lag, carries #15's caption, and never blocks input. Two measurements that decided it: a
switching turn runs to the engine's cap of **6 moves** (bursts of 14, 6, 6, 6 in `seed-1917398`),
and dwell-by-kind costs ~40s of animation across a 60-stage game against 3.6 minutes for a flat
700ms.
- [ ] **#13** — I cannot see what the other players did — bots included. **Settled 2026-08-29 as the
harder reading**: not log legibility but the ordered, per-action presentation of everyone else's
turns. Jesse: "It's not fun to do my turn and have magic happen in the background." This is
Gitea#20 step 4 pointed at a player's own screen. **DESIGNED 2026-09-09 — the plan's § v0.8.0.**
The answer is foreign-district rendering with focus following the actor; without it a step
stream has nowhere to draw, because `Frame.cells` is the viewer's district alone. See
**Reference · #13**.
- [ ] **#15** — A "most recent action" line under the status block. The text already exists and is
already correct — this is placement, not content. **Decide with #13**: in solitaire "most
recent" is the right unit; in multiplayer what you missed is everything that happened while you
were WAITING. **DESIGNED 2026-09-09 — it is the caption in the `[N behind] … [Skip]` row, not a
separate line.** The queue IS "everything that happened while you were waiting", which is the
unit this entry could not choose. See **Reference · #15**.
- [ ] **#18** — Give every phase a visible beat. Not a timing problem — `pump` runs every automatic
phase before the page renders once, so they are never drawn at all. **A `sleep` fixes nothing;
it needs the async stepped pump that Gitea#20 step 4 specifies**, which is why it lives here
rather than under The screen. **DESIGNED 2026-09-09 — it is a dwell setting on the shared queue,
not a feature.** Phases where nothing happened dwell at ZERO (Jesse: "if nothing happens during
a phase then we shouldn't lose time to it"); a flat second per phase is rejected on the same
arithmetic this entry already worked out. Solitaire gets it through `LocalSession`, the same
path multiplayer gets #13 through. See **Reference · #18**.
- [ ] **#75** — Let the game join a call and talk to the table — the chat, audio and nudge half of the
idea Gitea#20 took the visual half of. Long-term. See **Reference · #75**.
---
## Multiplayer, sessions and operations
Running a game with other people in it — getting back into one, seeing what the server thinks is
happening, and the turn structure that is still solitaire-shaped.
- [ ] **#8** — A lost session token locks a player out of a running game permanently. The lobby half
was fixed 2026-08-23. **Needs Jesse's call on whether a token in a URL is acceptable.** See
**Reference · #8**.
- [ ] **#7** — The StartOS "Games in Progress" action is one long unreadable run-on per game. **ON
HOLD, 2026-08-29 (Jesse)** — StartOS 0.4.0.2 is expected to improve how action results are
displayed. Re-open against it and re-read the output before designing anything. See **Reference
· #7**.
- [ ] **#76** — Multiplayer train make-up is a round, not one player's job. Unbuilt rather than wrong:
the engine has no per-player turn within the New Train phase. See **Reference · #76**.
- [ ] **#77** — Three things deliberately deferred while planning the server — bots covering for an
absent player, and two others. See **Reference · #77**.
- [ ] **#79** — D19's switching instrumentation still needs writing, once real people are playing. See
**Reference · #79**.
---
## The screen
What is drawn and where, for a player at the board. The three items settled on 2026-08-30 shipped in
v0.7.9; what is left is the history panel and the end-of-game statistics.
- [ ] **#44** — How much history the panel holds should be configurable. The cap is `slice(-60)` with
no recorded reason anywhere. **Where the setting lives is an open question and the item exists
to ask it** — a StartOS action, per game, or per browser. The replay viewer already answers the
same question differently and in a different unit. See **Reference · #44**.
- [ ] **#81** — The log's start marker only works while the whole log fits, and since #23 reversed the
panel it marks the bottom rather than the top. Unblocked now that #23 is settled. See
**Reference · #81**.
- [ ] **#33** — **The second pass on the results screen — badges, and the brainstorm Gitea#16 asks
for.** **Needs Jesse and a conversation, not code, to start.** The raw material is already kept,
and because the statistics are DERIVED from the event stream rather than recorded, a second pass
can add any of them retroactively to games already played and saved. See **Reference · #33**.
- [ ] **#36** — **There is no per-Stage "this train did not move" signal**, so "longest an engine sat
on a siding" cannot be answered. The comment on Gitea#16 said `trainStoodStill` would supply it;
that is wrong, and was found by reading `advance.ts`. **Settle with #33**, its only consumer.
See **Reference · #36**.
---
## Replays and saved games
The save format, the two replay viewers, and how a game gets shared.
- [ ] **#14** — Stamp the history with wall-clock time, so "how long did that turn take" can be
answered afterwards. Half of it already exists server-side and is read by nothing, so the first
job is to look at a real game's timings file rather than to build. **The open question is
granularity, and it is Jesse's: per intent or per turn span.** See **Reference · #14**.
- [ ] **#47** — How would a player publish a replay so other people can watch it? Today "Save replay"
downloads a file and the only route to the site is sending it to Jesse. A whole 5-Day game
compresses to ~1 KB, so it fits in a URL — which makes share-by-link the cheap first move
whatever else is built. See **Reference · #47**.
- [ ] **#48** — Decide what the standalone replay viewer is for. `node src/sim/replay.ts` writes a
self-contained HTML file nothing links to; the site reads JSON saves instead. Keep it as a
developer tool or fold its two extra panels into the JSON viewer and delete it. **Blocks #49 and
settles ten of #46's unused declarations.** See **Reference · #48**.
- [ ] **#49** — The standalone replay viewer shows no yards. The play page and the site viewer both
do. A rendering job, not a modelling one — but do #48 first, since deleting the tool settles it.
See **Reference · #49**.
- [ ] **#50** — Undo is unlimited step-back, and that is a decision to revisit. You cannot undo your
way to a better die roll, but you can see a train's departure Stage and then spend the turn
differently. Deliberately left open until it has been played with. See **Reference · #50**.
- [ ] **#51** — The 5 MB replay size limit is invented, not a browser constraint. It has earned its
place — it caught a 5.2 MB payload — but the number wants a reason. See **Reference · #51**.
- [ ] **#52** — Save/restore is not version-aware. An old save stops replaying rather than failing
loudly, which is the safe direction but says nothing about what changed. **This has bitten
once**, and #40 says it may bite again. See **Reference · #52**.
---
## Rules
Where the implementation and the rules disagree, or where the rules do not say. **Several of these
need RAR or Jesse rather than code.**
- [ ] **#12** — Partly reproduced: cars left behind when backing up over them. Half was Gitea#17 and
is fixed; **the "I can later drive right through them" half is still unexplained and needs a
board from whoever filed it.** See **Reference · #12**.
- [ ] **#80** — The 22 opponent-directed cards — 10 Action, 12 Space-use — are out of every deck until
they are built. See **Reference · #80**.
- [ ] **#82** — An unload does not check the facility's commodity. See **Reference · #82**.
- [ ] **#83** — The deck is `docs/Deck cards5.xlsx` exactly, bar ten cards that are not built.
Gitea#14 closed with those ten listed on the issue so they do not vanish with it. See
**Reference · #83**.
- [ ] **#85** — The 0.4.9 playtest line is behind on a rules ruling, and that was checked rather than
assumed. See **Reference · #85**.
- [x] **#107** — **May a Small Yard put cars on the NOSE of the engine? YES** — raised by Jesse
2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says
a train there reorders "and puts the engine at the nose", `implications.md` says "any order,
INCLUDING cars ahead of the engine". The design notes won.
`switch.sortConsist` gained an optional `engineAt` (absent = the nose, so older saves replay
unchanged). The menu did NOT multiply: the engine is a separate short list offered against the
consist as it stands, so a four-car train has eight options rather than twenty, and a player
wanting both a re-order and an engine move spends two Moves. §8.2 needed no new code —
`badlyMadeUp` already holds a broken-backed train, and is deliberately direction-free, so a
PUSHING train (whole consist ahead of the engine) is fit to run. The button warns first, by
asking that predicate rather than copying it.
Labels read WEST TO EAST with the engine drawn as the board's own ◀ / ▶ arrow, because "front
to back" depends on which way the train points and the board has reversed east-facing consists
since v0.8.0. Each says `MADE UP, ready to leave` or `HELD at the Office: <why>`.
- [x] **#108** — **RULED AND CLOSED, 2026-09-23.** It stands: further table evidence supports
it, and part of the mid-game is players deliberately adding cars to clear the Division Yard so
the Classification refresh can happen. Originally: **The coach ratchet: every coach ends up in the Classification Yard and never comes
back.** RULED 2026-09-17 — *the rule stands, the game says so loudly* — and recorded here
because the ruling was made on one game's evidence and the balance question behind it is open.
§9.2 boarding discards the emptied coach into **Classification**; detraining draws a fresh
empty **out of the Division Yard**; §2.2 returns Classification only when the Division Yard runs
bare. Coaches therefore move one way only. **Measured over `whistle-6945` (3 Days, 539
intents):** 16 coaches in the Division Yard at setup, **0 from Day 2 Stage 8 to the end**, 15
in Classification — while the Division Yard held steady at 46-47 freight cars, so the refill
could not fire. From that point no passenger can board or detrain anywhere on the board, and
four of the twelve timetabled trains (1/2 Crack Limited, 5/6 Sparrow) carry nothing but
coaches.
The two changes that would break the ratchet were put up and declined for now: sending the
emptied coach back to the **Division** Yard instead of Classification (a one-line change to the
boarding reducer), or amending §2.2 to refill when the Division Yard holds no car of a NEEDED
type rather than only when bare. **Revisit with a second game's data** — one game cannot tell a
rule from a seed.
---
## Play balance
The economies, the densities and the ceilings. **Deliberately deferred as a body of work** — the
rules want settling first, and every figure quoted here from a full-length solitaire run predates
v0.7.9's collision-floor change (#61).
- [ ] **#61** — A solitaire game can now end on the collision floor (v0.7.9). Small — 1 game in 200 —
but **every full-length solitaire figure quoted in this file predates it**. See **Reference ·
#61**.
- [ ] **#62** — The crew tray count is due a re-examination, and the district-widening change is what
triggers it. See **Reference · #62**.
- [ ] **#63** — A district can now only widen as far as its Main reaches (v0.4.8) — worth watching in
the numbers. See **Reference · #63**.
- [ ] **#64** — The rebalance itself, deliberately deferred until the rules are right. Card counts,
industry counts and the economies all want moving together rather than one at a time. See
**Reference · #64**.
- [ ] **#67** — The marginal Local Operations action is worth ~0, and that is the real ceiling. Three
separate experiments agree. See **Reference · #67**.
- [ ] **#68** — Freight is stuck at ~2.7 loads a game and three fixes have not moved it. See
**Reference · #68**.
- [ ] **#69** — Gitea#2 — passenger operations starve themselves of coaches. **Jesse ruled the
shortage stays** ("it is possible to run out, that's part of the strategy"), so the three
balance options here are declined rather than deferred; only the silence was a bug, and that is
fixed. See **Reference · #69**.
- [ ] **#70** — The rolling stock supply is a guess, marked provisional in `content.ts`. See
**Reference · #70**.
- [ ] **#71** — Office card density (Depot 4→8, Station 2→4, Terminal 1→2) was a blunt fix for an
unwinnable-opening rate. See **Reference · #71**.
- [ ] **#72** — Industry density (9 → 27, Gap 12) restored roughly the prototype ratio. See
**Reference · #72**.
- [ ] **#73** — Train density left alone by decision, but noted. See **Reference · #73**.
- [ ] **#66** — Two balance items the victory-condition redesign did NOT supersede, and why they
stand. See **Reference · #66**.
- [ ] **#65** — Two balance items superseded by the 2026-08-20 victory-condition redesign, kept for
the reasoning rather than deleted. See **Reference · #65**.
- [ ] **#74** — A third item superseded by the victory-condition redesign, kept for the reasoning. See
**Reference · #74**.
---
## The bot
The developer bot exists to measure the game, not to be a good opponent — so a bot weakness matters
when it stops a measurement being trustworthy. **Read #57 before tuning any weights.**
**Since 2026-09-14 the bot plans its whole switching turn** (`sim/switch-planner.ts`, +2.89 revenue a
game), **takes a face-up card only if it could play it** (+1.52), and **since 2026-09-15 lays track by
what the district can do afterwards** (`bestValuedLay`, +0.12 over 6400 seeds, run-arounds 9/60 → 22/60). Jesse's goal for it is better decisions in simulated runs AND at a real table, with no
non-player advantage — it reads the board, never the deck.
- [ ] **#104** — Weigh a switching turn against drawing and the Freight Agent. Letting the planned gain
gate switching on its own measured nothing (0.1, 0.25) or worse (0.5): `usefulSwitching` already
says yes exactly when a plan gains. What would matter is a VALUE for the other two options to
compare against, which the bot does not have. See **Reference · #104**.
- [ ] **#106** — The Extra trap: a full hand of Extras the A/D cap is holding back cannot be discarded,
so the next draw forces one into a full Office. All 23 train plays past the cap in 40 games were
this. Avoiding the draw measured nothing (−0.03) because it stalled development. See
**Reference · #106**.
- [ ] **#105** — Plan across more than one turn. Jesse is in favour, one turn first to see the impact —
which is now measured. Deferred for a conversation, not declined. See **Reference · #105**.
- [ ] **#41** — The bot never plays Red Flags — zero in 200 games since Gitea#19, and that is deck
luck rather than unwillingness. It takes the danger prompt unconditionally; what it never does
is plant a flag ON PURPOSE to buy a Stage for switching, which needs it to know it wants time.
See **Reference · #41**.
- [ ] **#57** — The bot's priorities are not the problem — measured across ten heuristic variations.
**Read this before tuning weights**; it is the argument that the ceiling is elsewhere. **Part of
"elsewhere" was choosing one Move at a time**: planning the whole switching turn was worth
+2.89 (t = 15.8) in the 2026-09-14 bot-tuning round. See **Reference · #57**.
- [ ] **#59** — The run-around is out of reach of any bot, and the deck is why — measured five ways.
See **Reference · #59**.
- [ ] **#54** — The bot cannot spot a car at a stub industry, and the cut-ordering rules made that
visible. See **Reference · #54**.
- [ ] **#58** — The bot cannot get a crew next to an industry, so Flying Switch never fires. See
**Reference · #58**.
- [ ] **#55** — Bot drift across this release — four measurements taken for the rebalance pass, kept
so the next change has a baseline. See **Reference · #55**.
- [ ] **#56** — The bot was partly living off an illegal placement, and barring curves from the
Running Track took it away. See **Reference · #56**.
- [ ] **#60** — Re-run the three "worth ~0" action-mix experiments against the new Revenue floor. The
old figures were taken against a target, which the floor replaced. See **Reference · #60**.
---
## Code health and housekeeping
Dead code, untrustworthy tests, and things carried but not used. Individually small; the reason they
are one section is that each one found the next.
- [ ] **#46** — 29 unused declarations across 14 files, and the build does not run the flag that finds
them. **The flag matters more than the 29** — two are Gitea#18 leftovers in one file, one found
by hand and the other missed. Do #48 first; it settles ten of them. See **Reference · #46**.
- [ ] **#84** — Five test fixtures pinned a seed and meant "a game like this". All five broke on
Gitea#14 for that reason. See **Reference · #84**.
- [ ] **#87** — Regions as the primary model — the other half of §8.2. The Division map draws regions;
the engine still does not think in them. See **Reference · #87**.
---
## Documentation and assets
What the project says about itself, and what it ships alongside the code.
- [ ] **#15a** — Build documentation FROM the implementation, starting with a card reference. The
prompt was finding train card data spread across five documents of three different vintages, one
of them superseded. See **Reference · #15a**.
- [ ] **#86** — Real audio, as committed assets. Everything the game plays is synthesised from
oscillators today. See **Reference · #86**.
- [ ] **#88** — `card-reference.md`'s industry table may still be stale beyond Grocer's Warehouse and
the Oil Refinery. See **Reference · #88**.
- [ ] **#111** — A full pass over the five player-facing documents: strip the playtest commentary and
the pointers into the code, publish the card counts, and take out every section number and
repository-file reference. Jesse's read after the 0.8.2 rendering landed — much better, still
too much of the workshop showing. See **Reference · #111**.
---
## Reference — measurements, rulings and rejected approaches
The argument behind each open item, kept out of the work sections so those stay scannable. Moved
here verbatim on 2026-08-30; nothing was rewritten or trimmed.
### Play it at a table
#### #39 — NONE OF v0.7.4 HAS BEEN PLAYED BY A HUMAN.
**NONE OF v0.7.4 HAS BEEN PLAYED BY A HUMAN.** The Yard Office offer, the Red Flag hold and its
out-of-phase prompt, and the loaded-Extra make-up rules are all tested end to end, packed, and
running on `phoenix.local` — and no person has met any of them at a board. Two are interruptions
that stop the Mainline Phase and put a question in front of somebody mid-thought, which is
exactly the kind of thing only play reveals.
#### #35 — Extended play has never been played at a real table.
**Extended play has never been played at a real table.** **Verified live on phoenix.local,
2026-08-29**, against the installed v0.7.3:0 rather than in tests: a two-seat competitive game
(one human client, one bot) was dealt over the HTTP API with `days: 1`, played to the end of its
timetable, and reached `awaitingExtension` on Day 2 with `official = { win, winner 0,
daysElapsed }` frozen at Day 1 and votes `[null, null]`. Voting yes as seat 0 was accepted, the
bot followed as designed, and the game returned to `active` with `extraDays: 1` and the official
outcome **unchanged**. Both test games were deleted afterwards.
**The save carry-over claim was checked rather than asserted**: phoenix held five saves before
the update, of which `WHISTLE-4086` resumed and three were already refused by the 0.7.2 deck
change. After updating to 0.7.3 the log is identical — same game resumed with the same 7 intents,
same three refusals at the same move with the same code.
**What is still untested is what the item is named for: humans, at a table.** The multiplayer
vote has never been driven through two browsers — what a second player sees while waiting on a
first, and whether "waiting on Carol" is legible once Carol has closed her laptop, are still
unanswered.
**A HUMAN DID REACH IT ON 2026-08-30, AND IT WAS UNUSABLE — fixed in v0.7.9.** Jesse played a
solitaire game to the end and was never offered the extra Day. Nothing was wrong with the engine
or the Frame: `renderEnding` wrote the two buttons into `#actions` and then opened `#resultsdlg`,
which is **modal**, so the question sat underneath a dialog whose only control was Close. The
dialog asks it now. **The verification recorded above is exactly why this survived** — it was
driven over the HTTP API, which renders no dialog, so what was proven was that the SERVER
supports extended play, not that a player can reach it. Read that distinction into every
"verified on phoenix.local" line in this file.
#### #42a — Nobody has clicked through the solitaire setup screen's own fields and…
**Nobody has clicked through the solitaire setup screen's own fields** and confirmed the dealt
game matches what was chosen. The screen took three attempts to become reachable at all (item 42
below); reachable is not the same as correct. Early item for the next play session, with #39.
#### #40 — A save from before v0.7.4 may not replay, and nobody has been told.
**An older save may not replay, and players are not told so anywhere they will see it.** A save is
a list of moves and reopens by being re-played through the CURRENT rules, so any change that makes a
once-legal move illegal stops it there. **A deck change is the likeliest breaker** — a history
naming a card the deck no longer deals has no legal answer at all — but any narrowing does it. This
is the design working, not a fault: it fails safe every time, declining the load, naming the move
and leaving the file untouched.
**So this item is no longer "saves before v0.7.4"** and should never be restated per version
(Jesse, 2026-09-07). The specific 0.7.4 breakages — the Red Flags intent changing shape, a make-up
that was legal being refused, a Yard Office arrival asking a question no older history answers —
are recorded here as the ORIGIN of the rule rather than as the rule, and `WHISTLE-4086` did survive
on `phoenix.local`, which is why it is "may not" rather than "will not".
**What is actually owed:** the general rule is written down in `README.md` § Design notes, and a
line belongs wherever a build is announced. Nothing in code.
**Versioned, migratable replays are a post-1.0 question, deliberately deferred** (Jesse,
2026-09-07): "once we get to a solid 1.0 release we will consider a system to version the replays so
they are not as fragile — but not worth any effort right now." The reason it would be wasted effort
now is that every migration would be written against rules that change again next release.
### The common board, and watching play happen — Gitea#20
#### #13 — I CANNOT SEE WHAT THE OTHER PLAYERS DID — BOTS INCLUDED.
**I CANNOT SEE WHAT THE OTHER PLAYERS DID — BOTS INCLUDED.** Raised by Jesse 2026-08-22 from
playing a multiplayer game on StartOS: "on my display I need to see other players' moves, even
if they are a bot."
**What the code already does**, checked rather than assumed: `game.log` is ONE shared log and
`linesSince(seat)` (`server/session.ts`) sends every seat everything in it, so a bot's turn is
not silently dropped — `driveBots` plays through `submit()`, which calls `record(game, events,
actor)`, and `record` prefixes any event carrying a `player` with "Player <name>". So the moves
*are* arriving, attributed, in the history panel. Whatever is wrong is not that they were never
sent, and that is worth knowing before anything is built.
**What is genuinely missing is the BOARD.** `snapshot(s, …, viewer)` builds `cells` from
`areaOf(s, viewer)` alone, so a Frame contains the viewer's own Office Area and nobody else's.
Another player can move a train the length of their district and the only trace on your screen
is a line of text. The Division map is the one shared picture, and it shows trains on the
Mainline, not switching inside a district.
**ANSWERED 2026-08-29, and it is the harder reading.** Jesse: "I want to be able to watch other
players and bots make their moves. It's not fun to do my turn and have magic happen in the
background and then have to figure out what others did." So the complaint is not that the
history panel is hard to read — it is that the moves are not WATCHABLE. Marking the log is a
consolation prize, not the fix.
**This is Gitea#20 step 4, pointed at a player's screen instead of the common board.** That
issue — the public common-board display published into Jitsi — already specifies the mechanism,
and `docs/plans/jitsi-common-board.md` §"Step 4 — Preserve individual human and bot actions"
has the design: a display-step collector inside `GameSession` that captures a projected frame
after EVERY successful `submit()`, human and bot alike, deltas it, and emits one step per
accepted intent (not one per `GameEvent` — an intent drains automatic work behind it, and the
event list is not a complete reducer).
**The reason it is not simply free once #20 lands** is that the plan deliberately stops short
of here: *"Keep player pushes unchanged: players still receive the final coalesced result after
all immediately due bots finish."* Extending the step stream to seated players raises questions
the common board never has to answer — a spectator can be a second behind, a player waiting to
act cannot; and a player animating three bot turns while their own move is due is a game that
feels slower, which is the opposite of the complaint. **Jesse, 2026-08-29: "this relates to
issue #20 and will require a lot more thinking."** Design it with #20; do not start it alone.
**The constraint below still binds either way**, and hardest here: the common board is seatless
and shows only public state, whereas a step stream sent to a SEATED player is a Frame, and
Frames are redacted per seat.
**The constraint on the second**, and it is the one that must not be got wrong: a district's
BOARD is public — cards on the table, cars standing on them, trains — and a player's HAND,
Revenue detail and drawn cards are not. `test/redaction.test.ts` exists precisely to catch a
Frame that leaks the wrong half, and it works by serialising a seat's whole Frame and asserting
no other seat's secrets appear anywhere in it. Any "show me their district" feature has to
extend that test in the same commit, not after it.
**A cheap first move that is right either way:** mark the log where the viewer's own last turn
ended, so "what happened while I was waiting" is a readable block rather than a scroll. That
needs no new data on the Frame — `sentLines` already knows the boundary.
#### #15 — INVESTIGATE: a "most recent action" line under the status block.
**INVESTIGATE: a "most recent action" line under the status block.** Raised by Jesse
2026-08-22: a line below the status block ("Day, Stage, phase, waiting on") and above the
Division map, carrying the same kind of text the history does — *"Jesse drew from the Home
Office deck"*, *"played right-hand turnout at (−3, 0)"*. His own note: "I'm not sure that's
what's going to make the most sense, but I think it's something that should be investigated."
**The text already exists and is already correct.** `describeIntent` and `narrate` produce
exactly those sentences, and `record()` attributes them with the player's name. Nothing new has
to be written to say what happened — this is placement, not content.
**The slot is real but crowded.** Between `#turnchart` and `<main>` in `play.html` there are
already three transient banners: `#phasenote` (a phase CHANGED, auto-hides), `#announce` (a
one-shot announcement — a train completing its run pays everyone), and `#presence` (someone is
disconnected). A permanent fourth line has to not compete with them, and the palette is already
spoken for: violet reports where you are, amber means clickable, green and red mean good and
bad (`turnchart.ts`). A "what just happened" line is none of those.
**Two placements, and they are different features.** Put it in the page and it is the play
screen's. Put it in `turnChartHtml` and it appears in **both replay viewers** too, which is
probably a feature — a replay stepping frame by frame has exactly this question — but it makes
the change three screens wide.
**The question behind it is the unit, and it is why this should be decided with item 13.** In
solitaire "most recent action" is right: you took it, you are looking straight at it. In
multiplayer the thing you actually missed is everything that happened while you were WAITING,
which is many actions and possibly a whole bot turn — and one line showing only the last of
them may be the least useful line on the page. Item 13's cheap first move (mark the log where
your own last turn ended) answers that better. They may both be right, and one may make the
other pointless; deciding them separately risks building both and needing neither.
#### #18 — INVESTIGATE: give every phase a visible beat — perhaps one second.
**INVESTIGATE: give every phase a visible beat — perhaps one second.** Raised by Jesse
2026-08-22, watching a game play: New Train, Mainline and the shift change "look like they are
being skipped entirely". His suggestion: move to the phase, take a visible beat so the second
row shows it changed, then move on.
**They are not too fast. They are never drawn.** `pump()` (`advance.ts`) loops `advance()` until
something needs input, and `drain()` renders ONCE after the whole batch. So every automatic
phase between one click and the next resolves without the page ever painting it. A minimum dwell
time on its own therefore fixes nothing — the page has to step `advance()` one call at a time
and render between, which makes this an async pump with a queue rather than a `sleep`.
**`#phasenote` already exists for exactly this feeling** — it announces that the phase CHANGED,
because "the page can change out from under a player between one click and the next" — but with
only the final phase ever drawn it can only ever announce the last transition of the batch.
Stepping the pump is what would let it announce each one.
**This is where it meets item 15.** Jesse's own example: during the New Train beat the "most
recent action" line would read *"no new trains to build out"* — which is a sentence nothing
currently produces, because a phase that does nothing emits no event to narrate. Some of these
beats would need a line written for them, and deciding which is part of the same investigation.
**The obvious risk, worth stating before anyone builds it:** a second per phase is four seconds
of enforced waiting per Stage, forty-eight per Day, and a player who has seen it a hundred times
will want it off. Whatever this becomes probably needs a speed control, or to scale with whether
anything actually happened in the phase.
#### #75 — Let the game join a call and talk to the table.
**Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom,
Teams or Jitsi call and post into its chat, it could carry the whole table's shared state
without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone
is holding the game up — "Now waiting on player Alice to complete the Cargo phase."
- Further out, audio into the same call: a crash when a collision happens, a bell as the Stage
clock turns over.
- Further out still, a nudge on a timer — if a player has not moved within some interval, the
game says so, by beep or by spoken line: "Still waiting on Alice to complete the Cargo
phase." That turns the turn chart's "waiting on" chip into something a distracted table
actually notices.
### Multiplayer, sessions and operations
#### #8 — A LOST SESSION TOKEN LOCKS A PLAYER OUT OF A RUNNING GAME PERMANENTLY.
**A LOST SESSION TOKEN LOCKS A PLAYER OUT OF A RUNNING GAME PERMANENTLY.** Raised by Jesse
2026-08-22: "if I opened a fresh browser window and wanted to resume HOPPER-4607, how would
the server know which player I am and which game I'm trying to get to?"
**PARTLY FIXED 2026-08-23, and the fixed half was the more common one.** A browser that
reloaded while SEATED IN A LOBBY used to orphan its chair outright — the token lived in a
closure and was only written to `localStorage` at `Lobby.Start`, so the player could not
return and nobody could free the seat, on a table that cannot start until every chair is
taken. The record is written at create/join now, carries `stage`, and `start()` walks the two
probes (`/api/session`, then the lobby stream) to land the browser wherever its seat actually
is. **A player may also LEAVE now** (`/api/lobby/leave`), and the host may clear a chair, so a
stranded seat is no longer permanent for the rest of the table either.
**What is left is exactly the case Jesse asked about**: a genuinely fresh browser, on a
RUNNING game. Everything below still stands, and still needs his call on whether a token in a
URL is acceptable.
**A new tab or window of the SAME browser is fine** — `localStorage` is per-origin and shared
across a profile, so `start()` finds the token and rejoins automatically. **A genuinely fresh
browser is not**: another browser, a private window, another device, or cleared site data.
The token lives only in that one browser, and nothing else will accept an identity claim.
`/api/lobby/join` resolves a code against `lobbies`, and a started game is removed from
`lobbies` at `Lobby.Start`, so typing the game code answers `no open lobby with that code` —
the same answer a typo gets.
**The server knows exactly who you are and cannot be told.** Each game's `sessions.json`
holds `{ token, gameId, player, displayName }` and survives restarts — read off the box:
`HOPPER-4607: player 0 = Jesse | token df9e04c7…`. Everything needed is on disk; there is no
door. `lobby-and-sessions.md` §1 says "presenting the token IS the rejoin", which was a fair
assumption when a game lasted an afternoon and is a much worse one now that a game survives
an update (v0.6.0) and can sit for weeks.
**A second, nearer limit: `REMOTE_KEY` is a single `localStorage` key**, so a browser
remembers exactly one multiplayer game. Join a second and the first token is overwritten and
gone, with the same lockout. D13 says one game at a time is expected but "deliberately not
enforced" — the client enforces it by forgetting.
Three ways out, and the third is the one that fits what is already built:
1. **Show the player their own rejoin link** — a URL carrying the token in the fragment, to
copy and keep. No new server state, and the fragment never reaches the server. It is still
a credential in a link, so it lands in history and in whatever they paste it into.
2. **Rejoin by game code + display name + join secret — do not do this.** Every player holds
the join secret, so any of them could claim another's seat by typing their name.
3. **An administrator action, "Get Rejoin Link"** — pick a game and a player, get a URL to
send them. Gated by the admin secret, so only whoever runs the box can issue one, and no
player can impersonate another. Fits the existing admin-action pattern exactly.
**(1) and (3) together**, most likely: the player keeps their own link, and the administrator
can reissue one when they did not. Keying remembered sessions by `gameId` — with a picker
when the browser holds more than one — fixes the single-key limit at the same time. Jesse has
not yet decided whether a token in a URL is acceptable; the alternative is a bare token
pasted into a field, which is uglier and stays out of history.
#### #7 — The StartOS "Games in Progress" action is one long unreadable run-on p…
**The StartOS "Games in Progress" action is one long unreadable run-on per game.**
**ON HOLD, 2026-08-29 (Jesse): StartOS 0.4.0.2 should make action displays better.** The
diagnosis below is that the action-result view collapses newlines — which is exactly the sort
of thing a platform release fixes. Re-read the real output on 0.4.0.2 before building anything;
the nested-group rewrite may turn out to be unnecessary, and designing around a limitation that
has just been lifted is worse than waiting.
Raised by Jesse 2026-08-22 after using it against four games. Lives in the WRAPPER repo
(`station-master-startos`, `startos/actions/gamesInProgress.ts`), whose `AGENTS.md` says work
belongs in issues on that repo rather than a `TODO.md` — recorded here because this is where
the project's list actually is; move it if that policy is meant to bind.
**What he asked for**, taking the current output field by field: a separator between the
players and the Day/Stage line; the phase in parentheses rather than after an em dash
(`Day 1, Stage 1 (Local Ops)`); a separator before "Waiting on"; one after the waiting-on
player and seat, before the start time; and one between the start time and the last-move
time.
**Why they are all missing at once, most likely.** `describe()` joins its lines with `\n`,
so the intent was one field per line. Every separator Jesse is missing is exactly where a
newline is — which says the StartOS action-result view does not render newlines in a
`single`'s value, and collapses the lot into one line. Worth confirming in the UI before
designing around it, since the whole diagnosis rests on it.
**The structural fix, better than adding separators.** `ActionResultMember` can itself be a
`group` (`osBindings/ActionResultMember.d.ts` — "a new group of nested values, experienced by
the user as an accordion dropdown"), so groups nest. Each game can be a collapsible group
whose members are individual `single` rows — Players, Position, Waiting on, Started, Last
move — instead of one string. That gives every field its own labelled row, makes the
separator question disappear rather than answering it, and collapses cleanly when there are
many games. Do this rather than punctuating the run-on.
**Sorting, also asked for**, and worth having once a box holds more than a handful: by game
name, by start time, or by last-move time, ascending or descending. An action's input spec is
built at open time, so a `Value.select` for the field and another for the direction costs
almost nothing — and sorting by last move ascending is how you find the game nobody has
touched, which is the main reason to open this action at all.
#### #76 — Multiplayer train make-up is a round, not one player's job.
**Multiplayer train make-up is a round, not one player's job.** When a new train is built,
players take turns adding cars to the consist; in solitaire one player does all of it. The
engine currently has no per-player turn within the New Train phase, so this is unbuilt rather
than wrong.
#### #77 — MULTIPLAYER — three things deliberately deferred while planning the se…
**MULTIPLAYER — three things deliberately deferred while planning the server.** Decisions and
reasoning are in `docs/architecture/multiplayer.md` §11; these are the ones left open.
- **Bots should take minimally damaging, defensive actions when a player steps away**, so a
game is not permanently halted. Deliberately NOT automatic today: a turn timer forfeiting is
different from a bot competing, and the clearance ruling is the one decision that changes
another player's score. Bots fill empty seats at lobby time only (D8).
- **Let a player resign and hand their railroad to a bot** to finish. Same care needed as
above, but it is consented rather than imposed.
- **A forcing turn timer — explicitly NOT in the design.** `lobby-and-sessions.md` §5 used to
specify one: on expiry the server took "the safest legal action", including denying a
clearance. Cut in the review, because it is the same objection as a bot playing for an absent
player — the clearance decision changes somebody else's score, so anything that answers it
automatically changes the game. Explore later if halted games turn out to be a real problem
at a real table; the reasoning worth keeping is 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.
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
v0.4.1**, and **displayed in v0.5.4**. It orders the whole chain, west to east by ascending
roll; `openingRolls` is on the `Frame` now and the play page prints the chain under the
Division map — *West to East: Alice (1) → Bot 2 (5) → Bot 1 (11)* — so the rolls that formed
it are visible rather than only their result (`lobby-and-sessions.md` §4).
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
pointed at the open internet for long. Note that one-game-at-a-time per person is expected
usage and deliberately NOT enforced — enforcing it needs cross-game state whose only job is
deciding when to release someone, and getting that wrong locks a player out.
#### #79 — D19's switching-instrumentation still needs writing, once real people…
**D19's switching-instrumentation still needs writing, once real people are playing.** "13%
for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts`
or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land
before Phase 2 and realistically not before Phase 4 (real people at a lobby, not bots). A few
lines when the time comes: log whether a legal local-only action existed for a waiting player,
and whether they took it the moment their turn arrived.
### The screen
#### #44 — The history panel's 60-line cap is hard-coded, and how much history it…
**The history panel's 60-line cap is hard-coded, and how much history it holds should be
configurable.** Raised by Jesse 2026-08-30, alongside the reversal (#23) that made the panel
worth reading in the first place. **The questions below are his, and are deliberately left
open — this item exists to ASK them, not to answer them.**
**Where the number lives, and what justifies it.** `main.ts` renders
`session.lines().slice(-60)`. There is no comment saying why 60, and nothing in `CHANGELOG.md`
records a reason — so the figure is unexplained rather than chosen, the same shape as the 5 MB
replay limit under Replay / Save Games.
**THERE ARE ALREADY TWO ANSWERS ON TWO SCREENS, IN TWO DIFFERENT UNITS**, which is worth
knowing before a third is added. The play page keeps the last 60 **lines**. The site replay
viewer keeps the last 19 **steps** — `for (let k = Math.max(0, at - 18); k <= at; k++)`
(`replays.ts`) — a window of moves rather than of text, which yields wildly different amounts
depending on how much each step narrated. Whatever is decided has to say whether those two are
the same setting or deliberately different ones.
**The questions, in Jesse's order. None of these is settled.**
1. **At the StartOS wrapper level, via an action?** Note what that would and would not reach:
a wrapper action configures the SERVER, and solitaire runs entirely in the browser with no
server involved at all. So this can only ever be the multiplayer half of an answer, never
the whole of one.
2. **Per game?** It would ride in `GameConfig` — into the URL, into every save, and identical
for everyone at the table. That makes a DISPLAY preference part of the ruleset, which cuts
against the principle already recorded under Replay / Save Games: a save must reproduce a
game from decisions alone, and two recordings of the same game should not differ because
someone liked a longer scrollback.
3. **Somewhere else?** The obvious candidate is `Settings` in `localStorage`, beside
`districtMode`, `soundOn`, `zoom` and `gameCardOpen` — per browser and per person, which is
what every other display preference on this page already is. Cheapest, and the only one of
the three that works identically in solitaire and multiplayer.
**What is not free whichever is chosen.** The panel rebuilds its whole `innerHTML` on every
render, so the cap is doing performance work whether or not that is why it was picked —
raising it means deciding what the panel does with a thousand lines. And the item below is
coupled: "— the game began —" is drawn only while the whole log fits, so this cap decides when
that marker can appear at all.
#### #81 — The log's start marker only works while the whole log fits.
**The log's start marker only works while the whole log fits.** Added 2026-08-23: a multiplayer
game marks the top of the history with "— the game began —", which is honest only while the
panel is showing every line there is. The panel caps at `slice(-60)`, so past sixty lines the
marker is suppressed rather than lying about where the top is — and "what happened while I was
waiting" (item 13) still has no marker at all. Both want the same mechanism, and item 23's
newest-at-the-top question decides what that mechanism draws.
#### #33 — The second pass on the results screen — badges, and the brainstorm Git…
**The second pass on the results screen — badges, and the brainstorm Gitea#16 asks for.**
**Needs Jesse and a conversation, not code, to start.** The first pass is in and reports
everything the Frame and the event tally know; what it deliberately does not have is the
interesting half. The raw material is already kept — `tally.trainsCompletedWithWork` is the
switching-master join, `longestStand` is the engine that sat on a siding — and because the
statistics are DERIVED from the event stream rather than recorded, a second pass can add any of
them retroactively to games already played and saved.
#### #36 — There is no per-Stage "this train did not move" signal, so "longest an…
**There is no per-Stage "this train did not move" signal, so "longest an engine sat on a siding"
cannot be answered.** The comment on Gitea#16 said `trainStoodStill` would supply it. That is
wrong, and was found by reading `advance.ts`: the event fires only for a train whose profile sets
`stopEarnsPoint` — the X18 Circus and nothing else — and `tray.stopPointClaimed` guarantees it
fires at most once per train per game, so a streak folded from it reads "1 Stage" for ever.
**What it would take:** a new event per Stage per stationary tray (cheap to emit, a lot of events
for a statistic nothing scores), or sampling live state on the Stage boundary the way `stats.ts`'s
funnel probe does — which the tally cannot do today, because it folds a batch of events AFTER
`advance` has already mutated past the moment they describe. **Settle with #33**, its only
consumer.
### Replays and saved games
#### #14 — INVESTIGATE: stamp the history with wall-clock time.
**INVESTIGATE: stamp the history with wall-clock time.** Raised by Jesse 2026-08-22: "store a
date/time stamp with the history information. Even if it's not displayed immediately, someone
could tell afterwards, or later if we decide to display it — how long things took between
different turns and actions."
**Half of this is already built and nothing reads it.** `server/session.ts` closes a
`TurnTiming { player, phase, day, stage, startedAt, endedAt }` every time the acting player,
phase, Day or Stage changes, and `persistence.appendTiming` writes each one to the game's
timings file. So every multiplayer game on the box already has per-turn wall-clock on disk.
Nothing displays it, and nothing has ever read it back. **First job is to look at that file
from a real game** — the answer to "how long do turns take" may be sitting there already.
**Solitaire has none of it.** `game.log` is `{ text, tone }[]` and a save is
`{ seed, config, history }`. There is no clock anywhere on that path.
**The constraint, and it is deliberate rather than an oversight** (stated twice in
`server/session.ts`): wall-clock is kept OUT of `history` because a replay must reproduce a game
from decisions alone. Do not put a timestamp on an `Intent`. It would ride into every save,
change the save format, and make two recordings of the same game unequal for no gain — the
engine has no clock and must stay deterministic.
**And a timestamp on a log LINE does not survive.** `game.log` is rebuilt by `fromSave` on
undo, on restore and in the replay viewer, so a time recorded on a line is gone the first time
the player takes a move back. Whatever is built has to be a SIDECAR — times keyed by intent
index, written only on the live path — and every consumer has to handle its absence, because a
replayed or imported game legitimately has no clock at all. That absence is the honest answer,
not a hole to fill with `createdAt` (the same call `lastMoveAt` already makes, and for the same
reason).
**The open question is granularity**, and it is Jesse's to settle: per INTENT (every draw,
every Move — finest, biggest sidecar, and the only thing that answers "how long between
actions") or per TURN SPAN (what already exists, one row per player-phase, and enough for "how
long between turns"). Per span is free today; per intent is new work on both the solitaire and
the multiplayer paths.
**What it would feed if built:** the "Games in Progress" admin view (item 7 above, which already
shows `lastMoveAt`), a post-game "that Day took 40 minutes" summary, and any future pacing
question about whether a 12-Stage Day is too long at a real table — which is the sort of thing
only a table can tell us and only a clock can record.
#### #47 — INVESTIGATE: how would a player publish a replay so other people can w…
**INVESTIGATE: how would a player publish a replay so other people can watch it?** Today
"Save replay" downloads a JSON file to the player's own machine, and the only way it reaches
the site is by sending it to Jesse to drop into `public/replays/` and redeploy. The question is
what a self-service version would look like.
**The constraint.** The site is fully static — `dist/` is uploaded to File Browser and Start9
Pages serves the folder — and the replay list is a build-time `manifest.json` because static
hosting cannot list a directory. So publishing needs something that accepts a write.
**The one measurement that matters:** a full 5-Day game is **451–1017 bytes** compressed
(brotli), about **600–1150 characters** base64. A save is the seed plus the intents and the
engine recomputes the board, so a whole game fits in a URL.
Four shapes, roughly costed:
1. **Share by link, no server (~1–2 hours).** Put the compressed save in the URL fragment
(`replays.html#s=…`); "Share replay" copies a link and anyone opening it watches the game.
The viewer already parses saves and already has a file-open path, so this is compression, a
hash reader and a copy button. The fragment never reaches the host. It is a link rather than
a gallery: nobody discovers a game they were not sent.
2. **A write endpoint (a day or two, and it is a service).** Accepts a POST, validates the save
by replaying it through the engine — `save-replay.ts` already does exactly that check —
writes the file and regenerates the manifest. The work is the surround: auth or rate
limiting, abuse handling for a public write, CORS, and a deploy story. It also ends "static
hosting is all this needs", which has been load-bearing.
3. **Browser writes to File Browser directly — rejected.** It needs FB credentials in a static
page, so anyone viewing source gets write access to the whole File Browser, and the manifest
would need a read-modify-write from the browser that loses a save when two people publish at
once.
4. **Curated, manual (zero code).** What happens today, and it composes with (1): players send
links, Jesse publishes the good ones.
**The question behind the question is whether a gallery of strangers' games is wanted on a
personal StartOS box at all.** If it is, (1) is the piece (2) would need anyway, so it is the
right thing to build first either way.
#### #48 — Review the standalone replay against the site's replay viewer.
**Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts
--seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON
saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored,
so it is a developer tool that happens to look like a product feature. It carries two panels
the site viewer does not — the bot's decision trace ("what it chose, why, and what it passed
over") and the timetable — which is debugging material rather than something a player wants.
Decide: fold the decision trace into the JSON viewer and delete the standalone, or keep it and
accept that it is a tool. No action for now.
#### #49 — The yards are shown on the play page and in the site replay viewer, bu…
**The yards are shown on the play page and in the site replay viewer, but not in the
standalone one.** **Corrected 2026-08-30 — this said "not in EITHER replay viewer" and that
has not been true for some time**: `replays.ts` renders `vdivyard`, `vclsyard`, `vdivtot` and
`vclstot` from `f.yards`, and `replays.html` has the markup. Only `sim/replay.ts`, the
standalone dev tool, lacks them. The Frame carries them, so it remains a rendering job rather
than a modelling one.
**Do the decision below first.** The standalone viewer is the subject of an open keep-or-delete
question three items down; adding panels to something that may be deleted is the wrong order.
#### #50 — Undo is unlimited step-back, and that is a decision to revisit.
**Undo is unlimited step-back, and that is a decision to revisit.** The save is the seed plus
the intents, so `undo()` replays without the last one and can walk all the way to the deal. The
RNG advances with the replay, so the same play re-rolls the same 1D12 — you cannot undo your
way to a better die. But you CAN see a train's departure Stage and then spend the turn
differently, which is an ordinary solitaire take-back and also a real information leak. Options
if it starts to feel like cheating: make the Stage boundary a commit point, or cap the depth at
the current Stage. Deliberately left open until it has been played with. Multiplayer gets
nothing until there is a proposal/agreement flow — undo there is a table decision, not a
button.
#### #51 — The 5 MB replay size limit is arbitrary.
**The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every
frame — but the number itself deserves a reason.
#### #52 — Save/restore is not version-aware.
**Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
failing loudly, which is the safe direction but says little about what changed. **This has now
bitten once**: both published replays were dead — one got 42 intents into 360, the other 4 of
338 — and nothing said so; they simply ended early and looked like short games. A save should
carry a ruleset stamp and the page should say "this replay was recorded under an older
ruleset and stops at Stage N" rather than presenting a truncated game as a whole one.
**THE FULL FIX IS DEFERRED PAST 1.0, ON JESSE'S CALL 2026-09-07:** "once we get to a solid 1.0
release we will consider a system to version the replays so they are not as fragile — but not worth
any effort right now." The reason is sound and worth keeping: every migration would be written
against rules that change again the next release, so the work would be redone rather than reused.
**What that ruling does NOT defer is the honesty.** A truncated replay presented as a whole game is
a wrong answer, not a missing feature, and saying "this stops at Stage N" needs no version stamp at
all — `fromSave` already knows how many intents it replayed of how many it was given
(`session.ts:454` reports exactly that for multiplayer). That half is small and stands alone. The
general rule now lives in `README.md` § Design notes; #40 is the "tell the players" half.
### Rules
#### #12 — PARTLY REPRODUCED: "when I back up to collect standing cars and, furth…
**PARTLY REPRODUCED: "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."**
Reported against v0.4.9d by a playtester (not Jesse, who forwarded it and could not add detail;
his guess was that the cars were spotted at an industry).
**HALF OF IT IS NOW REPRODUCED AND FIXED (2026-08-26), as the second half of Gitea#17.** The
one case the earlier sweep did not try is a train pulling out through a 45° LEG rather than an
east or west port. `cutTowards` answered "you meet nothing" for a north or south exit, so a
crew standing on a curve drove away and left the cut beside it standing — the reported symptom
exactly, and against §A.4's mandatory coupling. `rowEndAt` (`track.ts`) fixes it, and
`cut-ordering.test.ts` pins it.
**WHAT IS STILL UNEXPLAINED is the second sentence — "I can later drive right through them."**
Nothing found so far accounts for that. A card is swept by `carsOn` whenever a train enters it,
whichever port it enters by, so a later pass over those cars picks them up. Until that half has
a board behind it this stays open: the fix above may be the whole report, or only the part that
happened to be reachable from the code.
**What was tried, all of which works.** Cars on plain track on the way to the caboose; cars
SPOTTED AT AN INDUSTRY on the way; the train's own cut standing on the square it is pulling out
of *through an east or west port*; a stale `standingWest` on the intermediate card; the industry
locked by MEN AT WORK (which correctly blocks the whole route rather than letting the crew
past). Every one couples the lot. The first three are pinned in `apply.test.ts` — "backing up
over a cut to something beyond it takes both" — so if the remaining case is found later it is
somewhere none of them cover.
**Why it is hard to make happen.** 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` (`state.ts`) 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. (One route to one was closed anyway: `flyingSwitch`'s reducer wrote the cut straight into
`industryTrack`, which for a Passenger Facility is not where `carsOn` looks. `check` refuses a
non-freight target, so it never fired.)
**The two questions that would settle it**, for whoever has the board: was there a SECOND route
to the caboose — a parallel row, or a turnout — so the move could have gone round the cars? And
what did the move button say it would couple? The label names every car (`describeIntent`), so a
button that read "couples caboose" and one that read "couples 2 boxcars, caboose" are different
bugs: the first is route selection, the second is the sweep.
#### #80 — THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF…
**THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF EVERY DECK UNTIL THEY
ARE BUILT.** Jesse's call. They were already cut from solitaire (Q6, no legal target with one
player); they are now cut from the competitive deck too, because `checkPlay` answers both
categories `NOT_IMPLEMENTED` and dealing them would make ~9% of draws reject outright. Flip
`opponentCardsInDeck` in `setup.ts` when they land. They are played AT another player —
Watertower, Derail, Railroad Crossing and so on — so they are genuinely multiplayer work, and
**three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are
wired and read, and fire only against these cards. Until then those three are dormant by
design rather than broken.
#### #82 — An unload does not check the facility's commodity.
**An unload does not check the facility's commodity.** `laborer.beginUnload` gates on
`allows.inbound`, a loaded car, an empty of that type in the Division Yard and room in the red
box — but never on `facilityCarTypes(f)`, which `freightAgent.stockOutbound` does check. So a
Freight House (boxcars) will unload a hopper. Found reading the code for the v0.4.9e district
rule, not from play. Low impact today because the district rule now refuses the only same-Office
pairing that made it easy to hit, and because the bot spots matching cars — but it is a rule the
engine states in one direction and not the other.
#### #83 — THE DECK IS docs/Deck cards5.xlsx EXACTLY, BAR TEN CARDS THAT ARE NOT…
**THE DECK IS `docs/Deck cards5.xlsx` EXACTLY, BAR TEN CARDS THAT ARE NOT BUILT.** Gitea#14,
2026-08-26. Card for card, **84 rows agree with the sheet** and the only ones that do not are
the ten it adds that we have never implemented — Cargo Theft, Civic Improvement, Civilian
angel, Delayed Clearance, Flares 2, Robbery, Service Delays, Shipper complaints, Strike,
Union Hall 2: **12 copies**, held out on Jesse's instruction until they are built. Gitea#12
partly specifies the Inspections among them.
What landed: track halved; the Q12 office doubling and the Gap 12 industry tripling both
removed; Interlocking 2→1, Water column 2→1, ABS Signals 2→1, Red Flags 5→3. **Everything
sheet 5 does not list is dealt 0 copies rather than deleted** — the Telegraph/Telephone/Radio
dispatching ladder, Facing Point Locks (Enhancement and Mainline both), Flying Switch, Section
House, Vandalism, all confirmed by Jesse as deliberate removals from the design, plus Poling
and the sharp curves which were already there. The rows and their rules stay, so the design
stays visible and each mechanic works the moment it is dealt again. Deck 206 → **121** dealt.
Measured, 100 games, developer bot: revenue per player **−0.2 → +0.4**, trains scheduled
1.3 → 1.6, cards played 16.8 → 13.0. Freight share fell 9% → 4%, and part of that is Flying
Switch going to zero — it was a freight mechanic. Worth a look if freight is meant to carry
more.
**NOT A DISCREPANCY, though it looks like one in a card-by-card diff:** Second Section is on
neither sheet and is not a drawn card here either. It is a New Train phase intent
(`newTrain.secondSection`), and the `copies: 1` on the `SECOND_SECTION` constant is vestigial —
nothing deals it. Worth removing that field so the next diff does not flag it again.
**The deck reads 40% track against the sheet's 31%**, and the whole of that gap is the ten
held-out cards concentrating everything else. Building them moves the ratio to the sheet's on
its own, which is why the share is held to a loose band in `setup.test.ts` rather than pinned.
#### #85 — THE 0.4.9 PLAYTEST LINE IS BEHIND ON A RULES RULING, and that was chec…
**THE 0.4.9 PLAYTEST LINE IS BEHIND ON A RULES RULING, and that was checked rather than
assumed.** Recorded 2026-08-23, when Jesse asked whether any of v0.7.0 needed porting to the
`playtest` branch. Almost none of it does — that line has no lobby and no server, and the
`undo()` config fix is inert there because its `GameConfig` carries no victory dials, so
`configWith` only ever varies the house rules the save already restores.
**But one v0.5.0 ruling is a CODE difference the testers do not have.** §A.4, the Local's
coach — so on that build a coach still may not be set out at the Office.
**IT IS THREE SITES, NOT ONE.** This entry named only the first until 2026-08-25, when a full
branch diff found the other two. Porting just the `apply.ts` line would leave the build in a
WORSE state than either line is in today: the coach could be set out at the Office and the next
arriving train would then collide with it.
1. `src/engine/apply.ts` — `main` reads `if (dropRules.coachStaysOnStationTrack &&
cut.some(coach) && !atOffice)`; `playtest`'s is the same line **without `&& !atOffice`**.
This is the one that refuses the drop.
2. `src/engine/track.ts` — `canDropCarsAt(area, coord, count, coachesOnly)` takes a fourth
`coachesOnly` parameter on `main` and returns the Office square as droppable when it is set.
`playtest`'s signature has no such parameter and returns `false` for the Office outright.
3. `src/engine/advance.ts` — the §8.3 "cars fouling the Running Track" check. `main` reads
`officeCard.standing.some((c) => c.type !== 'coach')`, so a coach parked at the Office is
not a hazard to the next arrival; `playtest` reads `officeCard.standing.length > 0`, which
collides with anything standing there. **This one is behavioural and easy to miss** — it is
in a different file from the drop rules and reads as a collision fix rather than a coach one.
The other two questions the 0.4.9 README calls open are documentation-only there (Poling is
already at 0 copies; Heavy Grade behaves identically — both lines run the same
`rng.nextInt(2)`, re-verified 2026-08-25 — and it even deals the Mainline deck without
replacement, so the correction applies word for word).
**Jesse's call, 2026-08-23: do not port it now.** A settled rules change is not a playtest bug
fix, and pushing one into the build people are mid-playtest on would invalidate the feedback
that build exists to collect. Recorded so the divergence is a decision rather than a surprise —
and so the 0.4.9 README is not "corrected" to match `main`'s wording, which would then describe
behaviour that build does not have.
### Play balance
#### #61 — A SOLITAIRE GAME CAN NOW END ON THE COLLISION FLOOR (v0.7.9, 2026-08-3…
**A SOLITAIRE GAME CAN NOW END ON THE COLLISION FLOOR (v0.7.9, 2026-08-30) — small, but every
full-length figure in this file predates it.** §3.4's check was gated on
`mode === 'competitive' || mode === 'coop'`, so the two collision limits were live settings in
the solitaire dialog that did nothing; Jesse's ruling was that they should do what they say, and
the gate is gone. Measured over 200 standard developer-bot games: `loss/collisionFloor` fires in
**1 game in 200**, Days played 5.00 → 4.98 mean with a **minimum of 1**, and collisions per game
unchanged at 0.14 (max 3).
Nothing here needs acting on — the effect is smaller than the noise on every number in this
section — but it is a new way for a run to be short, so **a mean taken over games that all ran
five Days is no longer quite what is being sampled.** Worth remembering when the rebalance pass
re-measures, and worth watching if a future change makes collisions more common, because the
cost of one stops being "−1 Revenue" and starts being "the game".
Gitea#3, measured 2026-08-26 across the same 100 games: freight share of gross fell **8% → 5%**,
and completed freight loads went from something a 40-game sample caught reliably to needing
200 — on `sim.test.ts`'s seeds, 40 games now yield 0 loads, 80 yield 3, 120 yield 10, 200
yield 21.
**It runs against the obvious expectation.** The change SPEEDS crossings up, so more trains
should reach more districts, not fewer. Revenue is flat (−0.2 against 0.0) and collisions are
unchanged at 0.1 a game, so nothing is obviously eating the traffic. Candidates worth checking:
trains now clear a district before a crew can work them; the entry-time collision rule
(below) destroying trains at the Office; or simply that faster turnover means fewer trains
standing where freight can be loaded.
#### #62 — THE CREW TRAY COUNT IS DUE A RE-EXAMINATION, and this is the change th…
**THE CREW TRAY COUNT IS DUE A RE-EXAMINATION, and this is the change that triggers it.**
`players + 3` was set when a Slow train took roughly twice as long to cross as a Fast one, and
Q2's recorded consequence was that "every Slow train is still on the road when the next Day
begins, holding its Crew Tray". Gitea#3 removed the Slow penalty from every card but Hilly.
Measured on the Mainline cards alone, a 3-player Division now costs a fast train ~5.6 Stages
and a slow one ~6.0, against ~5.4 and ~9.4 before: **fast traffic is unchanged, slow traffic is
about a third quicker**, and the gap across a whole Division collapses from roughly four Stages
to less than one. RAR's own closing note on the issue: "been worried about the time it takes to
cross the division. More thunking on this is needed."
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
target is settled and freight carries its intended share; read no balance conclusion from a revenue
number until the rules stop moving.
#### #63 — A DISTRICT CAN NOW ONLY WIDEN AS FAR AS ITS MAIN REACHES (v0.4.8) — wo…
**A DISTRICT CAN NOW ONLY WIDEN AS FAR AS ITS MAIN REACHES (v0.4.8) — worth watching in the
rebalance rather than acting on now.** Track stays inside the Limits at every row, so extending
the Running Track is the only way to buy room for sidings, and a straight laid on the sign is
worth more than it was. The bot barely notices — it built outside its own Limits 5 times in 100
games — but the bot also builds close to its Office; a human building deliberately hits this on
the first wide district, which is how it was reported. If territory turns out to be the real
constraint on freight, this is one of the two places to look (the other is the track supply,
in Bot Performance).
#### #64 — REBALANCE, once the rules are right — deliberately deferred.
**REBALANCE, once the rules are right — deliberately deferred.** Card counts, industry counts
and the track mix all need a pass together, and none of them should move until the rules stop
moving. Standing distortions to account for when it happens: offices are doubled (Q12) and
industries tripled (Gap 12), both tuned when the deck held 139 cards and **no track**; it now
holds 235 of which 96 are track, so every draw is diluted by 41% — precisely the pressure
those multipliers exist to relieve. The 8 sharp curves have already been taken out on that
argument; offices and industries are the two left. Until then, read no balance conclusion from the revenue
numbers; they are a functionality signal only.
#### #67 — The marginal Local Operations action is worth 0, and that is the real…
**The marginal Local Operations action is worth ~0, and that is the real ceiling.** Three
separate attempts to spend the 60 actions better — capping the draw, pairing the two halves of
a load, restricting Enhancements — each measured within noise of zero over 400 paired seeds.
76% of the time an outbound industry has neither a stocked green box nor a spotted car, and
only 5% of Stages have a single workable facility anywhere, yet redirecting actions at that
does nothing. Something upstream limits how much work exists to do at all; find out what
before spending more effort on the option mix.
#### #68 — Freight was stuck at 2.7 loads a game and three fixes have not moved it.
**Freight was stuck at ~2.7 loads a game and three fixes have not moved it.** Sidings,
facility placement, car selection and the discarded-load leak all raised revenue (3.2 → 6.5)
without raising `loadStarted` past 2.7. The chain is not leaking and the cars are arriving
correctly (57% of drops land on a facility that wants them, 0% on one that does not). The
binding constraint is now upstream of routing: 60 Local Operations actions a game, and a load
needs a stocked green box AND a spotted car AND a free Laborer to line up in the same Stage.
Measure how many Stages have all three before changing any heuristic — the answer may be that
the economy, not the bot, is what caps freight.
#### #69 — Gitea#2 — passenger operations starve themselves of coaches, and the g…
**Gitea#2 — passenger operations starve themselves of coaches, and the game says nothing.**
Reported from v0.4.9e play: the Sparrow pulls into the Terminal with two loaded coaches, two
passengers wait on the platform, four porters are unused, and only ONE of the four intended
actions can be taken. Reproduced from the save (`docs/station-master-seed947338225-day5(1).json`,
Day 5 Stage 11): the Division Yard holds **1 empty coach and 0 loaded**, while the
Classification Yard holds **4 loaded and 4 empty** that cannot come back.
**The engine is not deviating from the rules.** Checked step by step: §9.2 discards the white
coach into the Classification Yard on boarding, draws the white coach from the Division Yard on
de-training, and §2.2 returns the Classification Yard only when the Division Yard is empty. All
three are implemented exactly. The problem is the interaction — a single global refill
condition over a pile holding six commodities with very different demand, where coaches (16 of
~60 cars) are consumed by both halves of every passenger cycle. Traced over the reported game
the coach pool goes 8+/8− to 0+/1− by Day 5.
**RULED — the shortage stays, and none of the three is being built.** Jesse: "it is possible
to run out, that's part of the strategy." For the record, the options were (a) refill when the
Division Yard is dry of the type-and-state being asked for rather than dry of everything; (b)
the same trigger but return only the cars of that type; (c) leave the rules alone and raise the
coach count in `ROLLING_STOCK_SUPPLY`. All three are declined. What shipped instead is the
EXPLANATION — the impediments panel now says the Division Yard has no white coach, how many are
stranded in Classification, and that Classification returns only when the Division Yard is
bare. Running dry is a position to play out of, not a broken game, once the screen says so.
#### #70 — The rolling stock supply is a guess.
**The rolling stock supply is a guess.** `ROLLING_STOCK_SUPPLY` (coach 8+8, boxcar 10+10,
hopper 8+8, reefer 5+5, tank 6+6, caboose 6) is marked provisional in `content.ts` and was
scaled alongside the Gap 12 industry increase. Now that the Classification Yard returns stock
only when the Division Yard empties, these numbers set the real supply pressure. Adjust from
playtesting rather than theory, and watch whether industry density feels light or heavy at the
same time.
#### #71 — Office card density (Depot 4→8, Station 2→4, Terminal 1→2).
**Office card density** (Depot 4→8, Station 2→4, Terminal 1→2). Chosen to remove a 25% chance
of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other
category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D
capacity at the Whistle Post itself.
#### #72 — Industry density (9 → 27, Gap 12).
**Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio. The "freight is
only 13–18% of gross" figure that motivated this was partly a measurement bug (see the
`stats.ts` item in Done) and partly the car-selection bug; freight now runs at 37%. Worth
re-deciding whether 27 is still the right number now that the industries are actually served.
#### #73 — Train density.
**Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often
than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density
changes.
#### #66 — Not superseded by the 2026-08-20 victory-condition redesign (Multiplay…
**Not superseded by the 2026-08-20 victory-condition redesign (Multiplayer) — these two stay
`houseRules` dials, separate from the new `GameConfig` victory dials.** Noted only so the two
redesigns aren't conflated. **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN
PLAYED — both went in provisional, and both are
now selectable rather than fixed.** Jesse's call, both implemented and measured, both flagged
in `rules-v0.2.md`. What follows is what was measured when each was the only option.
**The opening deal (3 track + 3 other, from two separately shuffled piles).** It did what it
was aimed at, modestly: run-arounds **4/60 → 7/60** and districts **17.9 → 20.3 cards**, with
revenue unmoved on its own (−0.1, inside noise). Still nowhere near the 91/100 of the
private-supply era, so the supply question is softened rather than answered. Two things to
watch at the table: whether opening with six against a limit of three is a real decision or
just bookkeeping, and whether three is the right number of each.
**~~One Revenue for every train that clears your section.~~ Now: one Revenue to EVERY player
when a train completes its run.** Jesse's revision in v0.4.2. The first version paid the Office
a train departed, which on a five-Office railroad paid five separate times for one train and
paid most to whoever it passed first. It pays once now, when the train runs off the end of the
Division, and it pays the whole table — getting a train the length of the railroad is the
shared achievement, and every Office it crossed had to clear it.
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; **in a multi-player game the shape is completely
different** and needs measuring once multiplayer exists — N players × 1 per completed run
against the old N payments per train. **The victory-target question stays live**: 20 over 5 Days
is still reachable largely on traffic, which is either the intent or an argument for raising it
— and at the new default of 0 per transit it is not reachable on traffic at all, which is the
first thing a playtest should check.
#### #65 — Superseded 2026-08-20 by the victory-condition redesign (Multiplayer)…
**Superseded 2026-08-20 by the victory-condition redesign (Multiplayer) — kept for the
measurements.** `minCombinedRevenue` replaces the fixed target these numbers were read
against; re-measure once that lands rather than off this. **RE-MEASURE THE BOT AT THE NEW
DEFAULTS.** Both provisional rules below are now **settings on
the New Game dialog** rather than fixed choices, and the defaults are not what the numbers in
this file were measured under: the opening hand defaults to **three random cards** (the
prototype rule) rather than 3+3, and **train revenue per transit defaults to 0** rather than 1.
That second one is the big move — it was worth ~5.4 of a 7.0 mean, so the bot's revenue should
fall to roughly the working freight-and-passenger economy alone, which is the number this game
has actually been trying to read all along. Every mean, floor and threshold quoted below and in
the tests predates it. The three revenue rates run 0–5, so the useful next step is a sweep
rather than a single re-run.
#### #74 — Superseded 2026-08-20 by the victory-condition redesign (Multiplayer)…
**Superseded 2026-08-20 by the victory-condition redesign (Multiplayer) — kept for the
measurements and the reasoning.** `LENGTH_PROFILES.target` (20 over 5 Days, `standard`) is
retiring in favour of `minCombinedRevenue`, defaulting to `3 × players × days` (15 for
1-player/5-day, not 20) — a different number, deliberately not tuned to match this table.
Whether the Office-ladder bottleneck below still applies at the new default is worth
re-measuring once the redesign lands, but the fixed "20" this data argues against no longer
exists as a target. **The victory target (20 over 5 Days) is out of reach by a factor of
about four, and the Office ladder is why.** Measured over 800 games with the tuned bot, which
no longer throws
revenue away on collisions (0.0 a game, down from 0.4):
| trains scheduled | games | revenue | | Office reached | games | trains | revenue |
|---|---|---|---|---|---|---|---|
| 0 | 110 | 0.67 | | Whistle Post | 297 | 0.81 | 0.62 |
| 1 | 379 | 1.69 | | Depot | 272 | 1.49 | 2.92 |
| 2 | 234 | 3.72 | | Station | 176 | 1.89 | 4.36 |
| 3 | 68 | 5.68 | | Terminal | 55 | 1.93 | 5.04 |
| 4 | 9 | 5.78 | | | | | |
Revenue is almost exactly linear in trains scheduled — 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. So the
whole economy hangs off one valve: **37% of games never leave the Whistle Post and earn 0.62;
53% of all games earn nothing at all.**
Extrapolating the line, 20 Revenue needs roughly **11 trains and therefore 11 A/D tracks**. A
Terminal has four. The target is not merely missed, it is structurally unreachable under this
deck at this Office ladder — no amount of bot skill closes it, and the best game seen in 800
was 26 against a median of 0.
The three ways out are all yours to choose between, and they are different games:
1. **Lower the target** to what a 5-Day game can produce (6–8 looks like the honest number).
2. **Open the valve** — more Office cards, or a cheaper first upgrade, or more A/D capacity at
the Whistle Post, so the ladder is climbed rather than drawn.
3. **Raise revenue per arrival.** It is 0.46 today; each arrival can in principle pay 2 for
passengers alone. That is the freight/passenger conversion problem, not the traffic problem.
Nothing here is a bot weakness any more, which is what this measurement was waiting on.
### The bot
#### #41 — THE BOT NEVER PLAYS RED FLAGS — and since Gitea#19 that is deck luck,…
**THE BOT NEVER PLAYS RED FLAGS — and since Gitea#19 that is deck luck, not unwillingness.**
**Re-measured 2026-08-29, after the card was redesigned: `redFlagsSet` fires ZERO times in 200
solitaire games.** The old measurement (600 games: OFFERED 4,212 times, PLAYED 4) described a
bot that declined a card it was constantly handed. That bot is gone.
Gitea#19 replaced the rule outright: a flag is planted on one side of your own Limits and holds
the next train from that direction, and it can be played out of phase when the engine breaks in
with "COLLISION RISK! FLAG AGAINST T2?". The bot takes that prompt **unconditionally** — the
engine only raises it when an arrival is certainly about to collide, so there is nothing left
to judge. It still never plays one, because the prompt needs two things to coincide: an arrival
that would collide (0.14 collisions per game, about one game in seven) AND the district's owner
holding a Red Flags card at that moment, from a three-card hand drawn out of 121.
**What is left to fix is the OTHER half of the card**, and it is the half a human would use:
planting a flag on purpose to buy a Stage for switching. That needs the bot to know it wants
time, which it has no notion of today. Until then the anomaly canary in `sim.test.ts` is
measuring deck luck rather than reachability, and its comment now says so.
#### #57 — THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.
**2026-09-14 — confirmed, and one ceiling found.** Reordering priorities still moves nothing; what
moved the bot was SEARCH. `sim/switch-planner.ts` plans the whole switching turn against a score of
where it ends, and measured +2.89 ± 0.18 (t = 15.79) over 1600 paired seeds — freight loads and
unloads 0.22 → 1.53, Cargo phases with a car spotted 5% → 18%. The "8% of Cargo phases" below was a
fact about how the bot switched, not only about the deck. See `CHANGELOG.md`, 0.8.0.9.
**THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.** Ten heuristic variations, each paired
over 400+ seeds. Every reordering of what the bot prefers came out inside the noise; the only
thing that moved revenue was refusing to schedule a train the Office cannot hold
(**+1.09 ± 0.16, t = 6.79** at 1600 seeds, revenue 1.19 → 2.28). Notable failures, all
instructive:
- **Refusing to bury the engine costs more than it saves** (−0.35, t = −2.98). It works —
burial falls from 8.4 decisions a game to 0.03 — and freight halves with it, because
coupling is mandatory (§A.4): the moves that bury the engine ARE the moves that pick cars
up. Burial is the price of collecting, not a mistake.
- **Reserving Moves to get home costs 0.55** (t = −2.32), though 62 of 120 trains left on the
board at game end were stranded in the district. The switching work is worth more than the
departures.
- **Granting clearance when the train ahead has one Stage left is −0.98** (t = −5.24). Trains
move in numeric order, so a follower can enter the region the leader still occupies before
the leader moves. "About to leave" is not "gone".
- Preferring coaches at make-up, stocking the platform first, playing Interlocking earlier,
hunting the Depot in the Departments: all within noise, and three of them were exact
no-ops — Interlocking sits in hand alongside a train card **0.04 decisions a game**.
The funnel says why: only **8% of Cargo phases** have a stocked green box, and the bot already
takes 42% of the turns where stocking is productive. The opportunities are not there to be
prioritised better. What is left is the economy itself, which is a deck question.
#### #59 — THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measu…
**2026-09-14 — part of "the deck is why" was the bot's own draw.** It was taking ~32 face-up trains and
industries a game it could not play and discarding them again, so the hand rarely held track. Taking
only playable cards (now the default) grew districts 14 → 24 cards and run-arounds 3/60 → 9/60. And
~13 of the ~16 track pieces a game were being laid by the draw turn's "play what is in hand" fallback
at the first legal square, not by `bestTrackLay`; holding them (−0.83) and placing them by
`bestTrackLay`'s score (+0.07, noise) both failed, so the next limit is that SCORING — it cannot tell a
piece that opens an industry site or advances a run-around from one that fills a square. The deck
measurements below were taken before any of this and should be re-read with it in mind.
**2026-09-15 — the scoring, fixed.** `bestValuedLay` scores the layout a lay leaves (reachable industry
sites, a closed run-around, ways off the main) instead of the piece: closed run-arounds in 22 of 60
districts against 9, +0.118 ± 0.029 revenue (t = 4.14, 6400 seeds). The run-around is now reachable
without changing the deck; what is left is a bot that can USE one, which needs more than one turn of
planning (#105).
**THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measured, five ways.**
"Teach the bot to plan across turns" was tried properly and does not work. Every attempt is
neutral or negative, and they fail for one reason that the numbers make plain.
| attempt | result |
|---|---|
| hold ALL track for the siding | **−0.70** (t = −3.27) |
| hold only CURVES, the closing piece | **−0.26** (t = −3.22), district 17.9 → 16.7 cards |
| finish a run before cutting another way down | 0.00 — 398/400 games identical |
| treat a second turnout as the closing piece | 0.00 — **400/400 identical** |
| spend a curve only on a square that CLOSES | −0.11, and only 15 games in 400 differ at all |
**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 in a usable
order; the bot does not get to the two-piece prerequisite.
And it is not hand pressure. 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 and discards 1.5, so it spends the pieces
as they arrive because a piece that builds anything outscores holding one that might build
more later. Holding is the only counter, and holding measures worse every way it is tried.
This is a consequence of moving track into the deck, not a bot weakness: 91 run-arounds per 100
games when track was a private 26-piece supply the player chose from, 29/100 once it was drawn,
4/60 now. **If the run-around is meant to be the central switching puzzle — and the rules
present it that way — the supply has to change, not the player.** Options: give track its own
hand or yard the way the prototype did, raise the hand limit for track specifically, or print a
siding as a single card. Nothing else reaches it.
#### #53 — THE BOT DOES NOT KNOW TO BRING AN EXPEDITED TRAIN BACK TO THE STATION…
**THE BOT DOES NOT KNOW TO BRING AN EXPEDITED TRAIN BACK TO THE STATION — new in v0.4.9.**
The `expediteFault` mechanic (§7, Q3) charges 1 Revenue every Mainline Phase an expedited train
is left off the Office square, and the bot has no heuristic that accounts for it: measured over
30 fresh games, one left Train 4 (3/4 Express) parked on Secondary Track from Day 3 Stage 10 to
the end of the game, drawing the fault **26 times**. Not an engine bug — the mechanism fires
exactly as designed — but a clear next bot heuristic: prefer ending a switching turn with any
expedited crew back on the Office square, at least once it has finished the work it went out for.
**CLOSED 2026-09-14** — see **Done · 53**.
#### #54 — THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rul…
**THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rules made that visible.**
Coupling is mandatory on your own square now (v0.4.7), so a crew that sets a car out *between
itself and the only way out* picks it straight back up. At a stub industry that is every
set-out the bot makes: its trains run engine-first with all four cars behind, so the tail cut
always lands on the exit side. The correct play is §A.5's **facing point** move — couple the car
onto the nose, shove it into the stub, set out off the nose, back away — which is the same
cross-turn planning already recorded as out of reach of any bot in "THE RUN-AROUND IS OUT OF
REACH OF ANY BOT" below.
Measured over 200 paired seeds: **-0.55 revenue** (t = -3.63) and freight revenue 1.11 → 0.56.
Filtering self-recoupling moves out of the bot's options took recoupling from **625 of 1,029
set-outs in 60 games to 101 of 677**, and all 101 that remain are this case. Nothing is broken —
the game models the difficulty correctly and the bot cannot yet play it — but **every revenue
figure in this file measured before v0.4.7 is now low by roughly half a point** and the rebalance
pass should not read the drop as a deck problem.
#### #58 — The bot cannot get a crew next to an industry, so Flying Switch never…
**2026-09-14 — the premise is now a deck fact.** Flying Switch is dealt **0 copies** (not in sheet 5;
Jesse, 2026-08-26), so no bot can fire it: across 30 standard games none was ever drawn. The switching
planner searches the card by default, so it will be used the day it is dealt again. The reachability
sweep's exemption in `sim.test.ts` stays until then.
**The bot cannot get a crew next to an industry, so Flying Switch never fires.** Industries are
now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows
deep. `flyingSwitch` is exempted by name in the reachability sweep in `sim.test.ts`; deleting
that line is the test that this is fixed. Same root cause as the item below.
#### #55 — BOT DRIFT ACROSS THIS RELEASE — four measurements, all for the rebalan…
**BOT DRIFT ACROSS THIS RELEASE — four measurements, all for the rebalance pass.** Recorded
together so the pattern is visible rather than four relaxed thresholds nobody adds up:
- **Switching work down ~16%, 1.76 → 1.48 productive acts a game** (400 games), because an
expedited train now stands at the Office for a Stage instead of passing straight through, and
a train on the A/D track and the Office square is in the crew's way. That is the change doing
its job rather than a fault — but it is drift. Collisions also went 0.05 → 0.06 and the worst
game went −3 → −9, same cause: the Office fills up. **Separately, the `work > 2` floor that
caught this had never actually been met** — it read 2.16 at 150 games and 1.76 at 400, so it
was passing on which seeds the sample happened to include. Now 400 games and a floor of 1.2.
- **Track laid badly, 7% → 15%** of pieces butting a card that cannot accept them. Forced to
shed on turn one, the bot would rather lay a piece than discard it; a player would discard
the ones with nowhere good to go. It also means the district-size gain from the new deal is
partly padding rather than useful railroad.
- **Interlocking placed, 15/60 → 7/60 games.** Departure Revenue pulls the bot toward other
work and it spends its opening on the track it was dealt.
- **Aimless shuttling in 3 games of 16** — thirteen are clean, so this is a minority behaviour
rather than the every-game waste the detector was written for.
Each floor was moved to match what is measured, with the reasoning written into the test. None
is a crisis on its own; together they say the bot spends its openings worse than it did.
#### #56 — The bot was partly living off an illegal placement.
**The bot was partly living off an illegal placement.** Barring curves from the Running Track
(they have no east-west road and dead-end the main) cost it districts 28.0 → 19.7 cards and
revenue ~2.0 → 0.8. It has no plan for where a curve should go once the easy square is gone.
Same root cause as "the bot cannot get a crew next to an industry" and "THE RUN-AROUND IS OUT
OF REACH OF ANY BOT" below; fix them together, after the rebalance.
#### #60 — Re-run the three "worth 0" action-mix experiments against the new floor.
**Re-run the three "worth ~0" action-mix experiments against the new floor.** Capping the draw,
pairing the two halves of a load, and restricting Enhancements were each measured "within noise
of zero" over 400 games — but at 400 games the standard error is ±0.33, so a real +0.5 would
have looked like nothing. They are nearly free to re-run now and at least one may have been
discarded wrongly.
#### #104 — WEIGH SWITCHING AGAINST THE OTHER TWO OPTIONS, not against a threshold.
Measured 2026-09-14, paired over 400 seeds against the planner: switching only when the planned gain
clears a threshold scored −0.33 at 0.5 (t = −5.06), −0.01 at 0.25, +0.06 at 0.1 (t = 1.79). At 0.5 it
refuses turns that only collect cars, which feed later deliveries; below that it agrees with
`usefulSwitching`. The choice that is still made by a fixed ladder is WHICH of §6's three options a
Stage goes to, and the planner can now put a number on one of them. The other two need numbers of
their own — what a draw is worth given the hand and the Departments, what stocking a box is worth given
the cars spotted — before the three can be compared. Also: planning at every Local Operations decision
costs a full search each time, so any version of this has to stay cheap.
**2026-09-15 — measured the ceiling first: there is almost none.** At 907 real Local Operations choices
(75 standard games, seeds outside the usual measurement range), every legal option was tried and the rest
of the game played out by today's bot, 4 times each with the HIDDEN parts reshuffled — the Home Office
deck order and future rolls — and the same reshuffles for every option, so the comparison is paired.
Grouped by the rule that made the choice, the value of each alternative against it:
| the ladder chose | times | switch instead | draw instead | Freight Agent instead |
| --- | --- | --- | --- | --- |
| draw — nothing urgent, develop | 494 | +0.08 ± 0.12 | — | +0.02 ± 0.04 |
| Freight Agent — feed the pipeline | 147 | +0.07 ± 0.06 | −0.01 ± 0.04 | — |
| switch — a train with work at the Office | 108 | — | −0.17 ± 0.10 | −0.25 ± 0.08 |
| draw — an Office upgrade in hand | 100 | +0.21 ± 0.16 (6) | — | −0.08 ± 0.06 |
| switch — walk the crew home | 47 | — | +0.22 ± 0.14 | −0.15 ± 0.08 |
| draw — a train card in hand | 11 | — | — | −0.45 ± 0.24 |
No rule has an alternative that is significantly better; where the table leans, the ladder is usually
the one that is right. So given how the bot plays each option once chosen, the choice itself is close to
optimal, and value functions for draw and Freight Agent have little to find. The one lean worth a look if
this is reopened is walking a stranded crew home (+0.22, t ≈ 1.6). The rollout tool is analysis only —
the bot never sees a rollout.
#### #106 — THE EXTRA TRAP — why the cap on committed trains still lets an Office overfill.
**2026-09-15 — re-measured under today's defaults, and most of it is not the Extra trap.** 20 "no free
A/D track" collisions in 60 standard games, −1.67 revenue a game. No train was held (§8.2 or clearance) in
the Stage before any of them, and only 8 of the 20 trains destroyed were Extras. **Six destroyed a train
of the same number as a Second Section run within the previous two Stages — and all 26 Second Sections
the bot ran in those games were an accident:** the New Train phase's "no car on offer" fallback takes
`options[0]`, and `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, so whenever an
Extra was waiting to start the bot doubled the train due out instead. The Office never had an A/D track
to spare for one.
**A RULES QUESTION FOR JESSE, found on the way — not a bot matter.** Q9 (`implications.md`) defines the
Second Section as a CARD "played on a train that is due out", and `content.ts` defines `SECOND_SECTION`
with 1 copy — but `buildDeck` never deals it, and `check`'s `newTrain.secondSection` asks for no card in
hand. So any player may run a Second Section for free on every train due out. Either the card should be
dealt and required, or the free action is the intended rule and Q9's wording is stale.
Also measured and removed: starting an over-cap Extra where its run never reaches the Office (+0.10,
t = 1.82) — such a start was on offer at 1 of 25 over-cap starts.
**The accident is fixed** (2026-09-15, default): the New Train fallback takes a car, a pass or the
Extra's start, never `options[0]` — +0.32 ± 0.09 (t = 3.64), collisions 0.24 → 0.19. **Still open under
this item:** the forced Extra itself (a full, undiscardable hand of Extras), and the Second Section card
question above.
`choose` removes train-card plays from the options when `trainWouldOverfillTheOffice`, but yields if
that would leave nothing legal. It does leave nothing legal in one ordinary position: the hand is over
the limit (§6.2 requires reducing it) and every card in it is an Extra, which `keepReason` forbids
discarding. Measured 2026-09-14 after the face-up take rule: 23 of 196 train plays in 40 games were past
the cap, every one "play what is in hand" with four Extras held, and the worst seeds each lost 3-4
collisions to it. Declining the draw option in that position measured −0.03 ± 0.11 — collisions fell
0.26 → 0.20 but cards played fell 29.0 → 25.6. Better answers to try: play the Extra at the least
dangerous moment rather than the first, count WHEN each committed train is due at the Office instead of
how many there are, or keep the hand from filling with Extras in the first place.
#### #105 — PLAN ACROSS TURNS — the evidence so far, for the conversation.
For: one-turn planning already reaches most switching work (Cargo phases with a car spotted 5% → 18%),
and what it cannot do is exactly what spans a Stage — leave a car on a spur for the next crew, or start
a run-around and finish it later. The planner already scores staged wanted cars (+0.2), which is a
first, crude step in that direction.
Against, for now: everything between two switching turns is not the player's — a Mainline Phase, trains
arriving, a Load/Unload phase — so a second turn cannot be searched the way the first is without either
simulating those phases (arrivals are on the public timetable, but cars on arriving trains are not
known) or scoring the position between turns more cleverly. And search cost is already what sets the
test suite's running time. Cheapest next step if taken up: a better score for "what the next turn can
still reach", not a deeper search.
**2026-09-15 — the run-around measurement that bears on this.** A candidate that lays track by what the
district can do afterwards (`valueLays`) more than doubled closed run-arounds, 9/60 → 22/60, yet moved
revenue only +0.14 ± 0.06 (t = 2.58, 1600 seeds). Traced over 40 games: the one-turn planner DOES use
the loop — 63 of 131 plans in a district with one end a move on it, 35 run it both ways — but its planned
gain per turn is the same with a run-around as without (0.28 against 0.27). A run-around is for putting a
train's cars in a different order, which pays off in the turns after; a planner that looks one turn
ahead has no way to value it.
**2026-09-15 — two-turn planning, built and measured: it does not pay.** `planTwoTurns` kept the six best
ends of a switching turn, removed the trains that would highball in the Mainline Phase in between (on the
Office square and made up — their cars leave with them), reset the Moves, planned the next turn from each,
and chose by the position after departures plus 0.8 of what the next turn adds. Nothing hidden is read.
| version | revenue, 400 paired seeds | what went wrong |
| --- | --- | --- |
| first | −0.28 ± 0.11 (t = −2.58) | an expedited train left away drew 31 faults in one game: the fault is charged in the gap, which the discounted next turn "recovered"; trains left away 5.3 a game against 3.1 |
| with the gap fault charged in full and 0.5 a Stage per train left away | −0.14 ± 0.06 (t = −2.36) | trains still left away 4.8 a game; the "crew must get back to the Office" choice 4.2 a game against 2.7 |
Why a second turn has so little to find, measured over 30 standard games:
- only **49%** of switching turns have the same train in the district at the next Local Operations choice;
- **6.1 wanted cars a game** do leave aboard departing trains — but a further switching turn from those
exact positions could have spotted only **0.7** of them: most were never deliverable;
- the one-turn planner already gains no more with a run-around than without (0.28 against 0.27).
And what a second turn COSTS is a Stage: trains left away have to be walked home, and those choices come
out of drawing and the Freight Agent (cards played 28.8 → 28.3). A multi-turn bot would have to weigh
switching against the other two options — which is #104, not a deeper search.
### Code health and housekeeping
#### #46 — tsc --noUnusedLocals finds 29 unused declarations across 14 files, and…
**`tsc --noUnusedLocals` finds 29 unused declarations across 14 files, and the build does not
run it.** Swept 2026-08-30 and deliberately NOT fixed in the same pass, at Jesse's call — it is
a wide, mechanical change and v0.7.9 had enough in it.
**RE-MEASURED 2026-09-07: 40, up from 29 in eight days.** The prediction below — "without the
flag this list simply regrows" — is now a measurement rather than a forecast. **Four of the new
ones were mine and are removed in v0.7.9.8**: `HAND_LIMIT` left unused in `apply.ts`, `view.ts`
and `web/game.ts` when the three copies of the §6.2 test were consolidated into one (#45), and
`actingPlayer` in `web/game.ts`, dead since `currentActor` began delegating (#96). Removing my
own leavings is not doing this item — the remaining 36 and the FLAG are still open, and the
`sim/replay.ts` ten still wait on #48.
**The recurrence is the point, not the 29.** `SIDE_GAP` and `CHIP_W` in `board-svg.ts` are both
Gitea#18 leftovers — constants for a wrapped layout that no longer exists. `SIDE_GAP` was
removed by hand on 2026-08-30 only because someone happened to read the file while closing the
superseded Display items; `CHIP_W` sat two lines away and was missed, and would still be there.
**A flag finds both without anybody having to be reading that file.**
Where they are: `sim/replay.ts` 10 (the standalone dev tool, which has its own keep-or-delete
question under Replay / Save Games — do that first, since deleting it settles ten of these),
`engine/apply.ts` 3, `sim/bot.ts` 2, `engine/legal.ts` 2, `engine/intents.ts` 2, and one each in
`engine/setup.ts`, `sim/board-svg.ts`, `web/game.ts` and five test files.
**Two halves, and the second is the one that lasts:** delete what is dead, then turn
`noUnusedLocals` (and probably `noUnusedParameters`) on in `tsconfig.json` so it cannot come
back. Without the flag this list simply regrows — it is regrowing now. Worth checking whether
the engine ones (`LABORER_ACTIONS_PER_LOAD`, `spaceOn`, `mainlineProfile`,
`wouldBuryTheEngine`, `strandedWantedCars`) are dead or are half-built work somebody meant to
come back to; `tsc` proves nothing references them, not that nothing should.
#### #45 — THE FRAME CARRIES FOUR THINGS NOTHING READS — audited 2026-08-30, at J…
**THE FRAME CARRIES FOUR THINGS NOTHING READS — audited 2026-08-30, at Jesse's asking.** The
prompt was that v0.7.9 found the same defect twice: `actingPlayer` existed and the Frame threw
it away (#43), and `collisionsToday`/`collisionsTotal` rode the Frame for three releases with
nothing on the board drawing them (#28). So the question became general — what ELSE is being
computed, serialised and sent to no one?
Method, so it can be repeated: every top-level `Frame` field grepped for a `.field` read across
the seven renderers (`web/main.ts`, `web/panels.ts`, `web/replays.ts`, `web/lobby.ts`,
`sim/turnchart.ts`, `sim/board-svg.ts`, `sim/replay.ts`), then the same for `Tally`'s members.
**55 of 59 top-level fields are read.** These four are not:
- **`viewerSeat`** — on the Frame, and the ONLY reference in the whole repo outside `view.ts`
is one assertion in `test/multiplayer.test.ts` proving it is the seat and not the player
index. Nothing renders it. It may well be right to keep — a remote client arguably needs to
know which seat it is looking at — but nothing needs it today, and a field kept for a future
caller should say so.
- **`overHandLimit`** — the fullest example of the shape. The engine computes it, `view.ts`
puts it on the Frame, `web/session.ts` declares it on the `Session` interface AND implements
it twice (locally by calling the engine, remotely by reading the Frame) — and the only caller
anywhere is its own test. A whole plumbed path, four layers deep, with no consumer.
- **`tally.unloadsBegun`** — and this one is a visible asymmetry rather than merely unused. The
results screen shows "Loads still in the pipeline" as `loadsStarted - loadsCompleted`
(`panels.ts`), which is the MEN | AT | WORK loading pipeline's in-flight count. The unloading
pipeline has exactly the same pair of counters and gets no such line, so the screen reports
half of a symmetric mechanism.
- **`tally.cardsDiscarded`** — counted by the engine, and the results screen lists "Cards
drawn" and "Cards played" beside it without it. Gitea#9 (v0.7.1) made discarding a Timetabled
train a legal and deliberate move, so this is a player CHOICE that the game counts and never
reports.
**The two `tally` ones are FIXED** — 2026-08-30 in v0.7.9, one line each on the results
screen. Fixing them turned up something else worth knowing: `resultsHtml` draws
`tallyHtml(report?.tally ?? f.tally)`, so a finished game reports the tally frozen in
`f.official`, not the live one. A test that overrides `f.tally` alone changes nothing on
screen, which is how the first attempt at pinning this failed for a reason unrelated to the
fix.
**The two plumbing ones are DONE — 2026-09-07 in v0.7.9.6, and the answer was different for
each.** Deferred by Jesse 2026-08-30 ("leave it for now") on the understanding that the decision
would be delete-or-document rather than a patch.
- **`overHandLimit` — WIRED, because the consumer existed all along and was guessing.** `main.ts`
draws a disabled "End Local Operations" button explaining the hand limit, and decided to draw it
from `f.option === 'draw' && !menu.options.some(i => i.type === 'draw.end')` — i.e. from the
ABSENCE of the move. That is sound only because `check('draw.end')` refuses for exactly three
reasons and the two guards beside it rule out the other two; a fourth reason would have made the
panel explain a refusal by describing something else entirely, which is #90 verbatim. It now reads
`f.overHandLimit`, which is what `web/game.ts` says the field is for: "so the page can DISABLE the
button with a reason instead of hiding a move that has simply become illegal." Behaviour-neutral
today; what changed is that the screen states its reason instead of inferring it.
- **`viewerSeat` — DOCUMENTED, with a condition.** Gitea#20's common board keys every district by
SEAT and resolves the player through `playerAtSeat`, so a client picking its own district out of a
seat-keyed board needs this and cannot derive it from `viewer`. The declaration now says so, and
says to delete it if step 2 ships without using it — a note is a reason to survive one audit, not
an exemption from the next.
**And the audit's own method found a third limb it had missed.** `game.mustPlayCard` was set from
`overHandLimit` on every submit and read by nothing at all — deleted. Chasing that turned up the
thing actually worth fixing: the §6.2 hand-limit test existed in **three** places — `check('draw.end')`
in `apply.ts`, an inline recomputation in `snapshot()`, and `web/game.ts`'s own. All three agreed,
which is exactly the state #96's disagreement started from. There is now one `overHandLimit(state,
player)` in `state.ts` and the other two ask it. `Session.overHandLimit()` — declared on the
interface and implemented twice, locally and remotely — is deleted rather than kept: the Frame
already carries the fact, so the method was a second path to it.
#### #84 — FIVE TEST FIXTURES PINNED A SEED AND MEANT "A GAME LIKE THIS".
**FIVE TEST FIXTURES PINNED A SEED AND MEANT "A GAME LIKE THIS".** All five broke on Gitea#14
and none of them was about card counts — the deck's SIZE moves the RNG stream, so changing it
re-deals every fixture that names a seed. Fixed in place: `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 one goes on the board, and the two `sim`
commodity samples were re-measured (tank is first set out at game **216** now, unload Revenue
at game **46**). The `web` fix is on BOTH lines — it broke on `main` at the next count change,
exactly as predicted. `multiplayer`'s seed search is still playtest-only; port it when
convenient.
A sixth turned up when the dropped cards went to zero: `mainline-cards`' `hand()` helper threw
if the card it wanted was not in the deck, so zeroing Flying Switch took five passing tests of
an UNCHANGED rule down with it. It mints a card that is no longer dealt now — which is the
point of keeping a row at zero, and the same will hold for the ladder if anyone tests it.
#### #87 — Regions as the primary model (the other half of §8.2).
**Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions,
deriving position from what the crossing already cost. The engine still models a crossing as a
countdown of Stages, so two things printed on the cards remain unimplemented:
- `entryPoints` is declared on every Mainline profile and read nowhere. The Heavy Grade card
has five named Start positions, and playing Brakeman is supposed to move your entry point
along the card. The engine gets the same ANSWER by taking a Stage off the crossing, which is
why the derived drawing looks right — but the mechanism is not the printed one, so a card
whose starts do not correspond to its speed would be drawn wrong.
- `implications.md` §6 calls this "the single largest mechanical gap" and asks for typed cards
with speeds and named entries, with crossing time DERIVED from the region walk.
Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the
freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the
source Start-position art for the ten card types before it can begin.
### Documentation and assets
#### #15a — Documentation generated from the implementation, not written alongside…
**Documentation generated from the implementation, not written alongside it.** Raised by Jesse
2026-08-22, immediately after Gitea#7 changed the coach counts on four train cards and the
answer to "where do we keep track of that?" turned out to be **five places of three different
vintages**: `src/engine/content.ts` (the truth), `docs/home-deck.md` (a
readable per-card table, a version-stamped snapshot), `docs/rules/implications.md` §5 (the
transcription of `Trains3.pdf`, deliberately frozen at what the design SAYS),
`docs/Trains3.pdf` (the artwork), and `docs/rules/card-reference.md` (an invented placeholder
catalogue, banner-marked SUPERSEDED, whose train table still looks authoritative if you land in
the middle of the file). Every hand-maintained one of those drifts the moment a card changes,
and this release proved it.
**The deliverable, at minimum: a reference document for every card that can be played**,
generated from `content.ts` so it cannot disagree with the game. Sections, in order:
1. **Mainline cards** — the Division's own deck, dealt at setup rather than held in hand.
2. **Home Deck Cards — Trains**
3. **Home Deck Cards — Track**
4. **Home Deck Cards — Industry**
5. **Home Deck Cards — Modifiers**
6. **Home Deck Cards — PVP**
Each card wants its name, what it does, where it may be placed, and — the part only the
implementation knows — **whether its printed effect actually resolves yet**. `content.ts`
already carries that last one for Enhancements (`EnhancementRule.effect`, live / dormantSolo /
unbuilt, each row citing the file that reads it); the same honesty is what makes a generated
reference worth more than a transcription. `enhancementText()` and `mainlineDescription()` are
the model: prose composed from the data, so a tooltip cannot drift from the rule it describes.
**Deliberately NOT including card counts per category — REVERSED 2026-09-23, see #111.** The
ruling below stood from 2026-08-22 until Jesse asked for the counts to be published; generation
answers the staleness it was guarding against. Read it as history. Jesse's call in the same breath: the
counts move with play balance, so a document that prints them is stale on the next retune. The
same rule was applied to `content.ts`'s own comments on 2026-08-22 — see the pass recorded in
CHANGELOG for what came out and what was kept.
**BUILT 2026-09-07 in v0.7.9.2, completed in v0.7.9.3, as the first of the two options** — a build step writing Markdown,
`scripts/build-card-reference.ts` → `docs/rules/as-built.md`, with `test/card-reference.test.ts`
re-running the generator and failing when the checked-in file disagrees. All six sections are
there, and so is the honesty column: every Enhancement carries its `live` / `dormantSolo` /
`unbuilt` status, and the opponent-directed cards say plainly that none of them is dealt.
**The counts are omitted as ruled** — where a count matters it is rendered as a yes/no "is this
dealt at all", which is a fact about the design rather than about the current tuning.
**WHAT REMAINS, and it is the second option rather than a gap in the first:** a page on the site,
beside the replay viewer, rendered from the same view-model the game uses. The argument for it is
unchanged — a player cannot read a file in the repo — and it is now cheap, because the projection
work is done and only the presentation is missing. **Do it with Gitea#20 step 3**, which builds a
renderer for exactly this kind of read-only public page; building a second one first would be the
waste.
#### #86 — Real audio, as committed assets.
**Real audio, as committed assets.** Everything the game plays is synthesised from oscillators
(`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a
placeholder, not the finished sound. Sound therefore defaults to OFF.
- **"All aboard" most of all.** It currently goes through the browser's `speechSynthesis`, so
it is whatever system voice the player happens to have — a robot, not a conductor. A real
clip is the single biggest improvement available here.
- `arrive` (a train pulling into an Office), `depart` (a train highballing out of one) and
`crash` (§10 — a collision) are now synthesised too, v0.4.9 — three chuffing/screeching cues
built from the same oscillator-and-filtered-noise toolkit as `stage`, wired to `trainArrived`,
`trainHighballed` (Office departures only), and `trainsDestroyed`. Good enough to keep as the
real thing rather than a placeholder — no WAV clips needed for these three.
- **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing.
**Every file added needs three things recorded alongside it: the sound file itself, its
source (where it was obtained from), and its license.** The preferred license is **CC0
("Creative Commons Zero")** — a public-domain dedication: the creator waives all copyright
and related rights, so the file may be used, modified, and redistributed for any purpose,
including commercial, with **no attribution required and no restriction**. That is the
cleanest fit for a file committed straight into the repo, since it needs no attribution to
track going forward. Only fall back to an equally-permissive alternative (e.g. a license that
explicitly permits redistribution with no ongoing obligation) if CC0 isn't available for a
given sound, and record that license's actual terms plainly rather than assuming they match
CC0. Files also need to be small enough to commit, and a check that the "fetches nothing
external" test still passes — assets must be served from the site's own folder, never
hot-linked.
- Keep the synthesised versions as the fallback for anything not sourced, so a missing file is
a quieter game rather than a broken one.
#### #88 — CLOSED — card-reference.md's industry table is no longer anybody's source.
**CLOSED 2026-09-07 in v0.7.9.3, by removing the question rather than answering it.** This asked
whether `card-reference.md`'s Mine Tipple, Produce Shed and Power Plant rows were stale — it prints
3/3/4 for two of them while `content.ts` has every industry at base 1 out / 1 in / 1 Laborer — and
deliberately did NOT rewrite them, on the grounds that changing a Laborer count is a balance
decision rather than a documentation one. **That reasoning still stands and nothing was changed in
the engine.**
What changed is that `card-reference.md` is no longer where anyone looks. `docs/rules/as-built.md`
is generated from `content.ts` and carries the industry table the game actually runs; the old file
keeps its SUPERSEDED banner, now pointing forward, and its numbers are read as what the v0.4.5
placeholder said. **The balance question the entry was really guarding is #70** (the rolling stock
supply and the uniform 1/1/1 industry model, both marked provisional in `content.ts`) — that is
where it belongs, and it is still open.
#### #111 — A full editorial pass over the player-facing documentation.
**Raised by Jesse 2026-09-23**, on reading the rendered guide and the rewritten wrapper
instructions that shipped in 0.8.2. The verdict was that both are dramatically better and that the
workshop is still visible through them: *"There is still far too much of the feedback from
playtesting, pointing directly into code, commentary about decisions made versus gaps… People
playing the game do not need reference to old, outdated source material. They just want the
rules."* Four things to fix, and they are separable.
**1. Take the section numbers out.** Measured on 2026-09-23: `rules.md` 11, `home-deck.md` 5,
`mainline-deck.md` 5, `components.md` 4, `quickstart.md` 0. They are two different problems wearing
the same notation:
- **References to a rulebook nobody has.** §8.1, §8.2, §8.3, §10, §2.2 and the §7 cited in the deck
documents do not resolve to anything published — they are the numbering of the prototype rules
document. `rules.md:456` is the worst of them: a section whose own heading is
`## §8.1 in practice`, named after a document the reader cannot open. `rules.md:203` quotes one
outright — `§6.2: *"If any of the Department decks is empty…"*`.
- **References that do resolve, but only by number.** §3.5, §4.2–§4.6 and §6 are real sections of
`rules.md`, which numbers its own headings 1–7. These are not wrong, they are brittle and
unfriendly: `home-deck.md:207` "see Rules §4.4" asks a player to go count.
**The renderer settles how to fix these.** `scripts/markdown.ts`'s `slug()` deliberately strips a
leading section number, so §4.6 has no anchor to link to — the target is
`rules.html#passenger-work`. A cross-reference therefore becomes a named link
(`[Passenger work](rules.md#passenger-work)`) and the heading numbers themselves come off. Do
that first: it is what makes the rest of the pass mechanical.
**2. Take the pointers into the repository out.** Four of the five documents cite
`src/engine/content.ts` by path — `home-deck.md:8`, `mainline-deck.md:8`, `rules.md:8`,
`components.md:10` — to vouch that a table is generated. The guarantee is worth keeping and the
path is not; say the tables are generated from the game itself. No `.ts`, `.md` or `docs/` path
belongs in a player's document. (`docs/design.md` is a developer document and out of scope.)
**3. Publish the card counts.** *"It will change as playtesting evolves and things alter, but the
current count should be listed here. That is crucial information."*
> **This reverses a standing ruling — #15a's "Deliberately NOT including card counts per
> category", Jesse's call of 2026-08-22 — and it is the same person reversing it.** Two years of
> that ruling are baked into the files and all of it has to come out: the banner at
> `home-deck.md:12` (*"Card counts are not published…"*) and the clause at `components.md:11`
> (*"per-category CARD counts are not published, because they move with play balance"*). Update
> #15a's reference entry so it does not read as still in force.
The staleness the old ruling guarded against is answered by generation, not by omission. Every
catalogue row already carries its own count — `copiesInDeck` on track and office cards, `copies` on
industries, modifiers, enhancements, Mainline cards and Second Section — so
`scripts/build-card-reference.ts` gains a **Copies** column and `test/card-reference.test.ts` keeps
it honest for free. Nothing is typed by hand, and the count cannot drift from the deck.
**The one hard part is which deck a printed count describes.** Since 0.8.2 the office cards dealt
depend on the house rule: a game opening on Depots pulls the four Depot cards, one opening on
Whistle Posts keeps them. A single number is wrong for one of those two games. Print the default
game — every district opens on a Depot — and say so where the office table gives its counts.
`deckComposition()` and `SOLITAIRE_DECK_SIZE` are the totals to reconcile against.
**4. Strip the build-status commentary, without deleting true information.** Measured lines:
`rules.md` 5, `home-deck.md` 4, `mainline-deck.md` 3, `quickstart.md` 2, `components.md` 0. The
distinction that matters:
- **Commentary about the project — goes.** `rules.md:6` "this document reports **executable
behaviour** and marks unimplemented material"; `mainline-deck.md:104` "**It is not implemented,
and never has been.**"; the `live` / `dormantSolo` / `unbuilt` vocabulary in `home-deck.md`'s
enhancement table (251, 265, 273, 276) — that is an engineering status column printed for
players.
- **The fact underneath it — stays, in the player's language.** A player does need to know that
the opponent-directed cards are not in the deck, that the Interchange does not sort cars, and
that Facing Point Locks and the Water Column do nothing to a solitaire opponent. Say what happens
at the table ("this card is not dealt", "this card has no effect in a solitaire game"), not what
the code has got round to. A pass that deletes the sentence and the fact together makes the
documents wrong rather than clean.
**Where this leaves #15a.** Nothing here reopens it — the tables are generated and that holds. This
is the editorial half that generation was never going to do.
## Done
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
each group.
### Closed in the 2026-09-14 bot-tuning round (unreleased)
53. ~~**The bot did not know to bring an expedited train back to the station.**~~ — done 2026-09-14,
not by a heuristic of its own but as a consequence of planning the switching turn: the planner's
score charges an expedited train left away from the Office a full Revenue point, which is what Q3
charges. Over 400 paired seeds, 19 games drew expedite faults under the rule ladder and **none**
under the planner, worth +1.07 a game (t = 3.83) — the two worst cases had drawn 45 and 42 faults
in a single game. See `CHANGELOG.md`, 0.8.0.9.
### Shipped through v0.7.9.8, from the queue
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
the numbers stay so cross-references above and below still resolve.
91. ~~**Narration was outside the redaction net, and so was everything else nobody had listed.**~~ —
done 2026-09-07 in v0.7.9.4. The systematic pass Gitea#20 step 1 asks for: serialise a seat's
Frame, the public board and the narration it receives, and search all three for every opponent
card id, every card NAME that is unique to one opponent's hand, the seed, and any private
decision or menu data — across a fresh game, a blind draw, mid-game, a pending decision,
Employee Rotation before and after the seating moves, a reconnect push and a played-out game.
**And the allow-list, which is the plan's actual acceptance bar:** every property of
`publicSnapshot` is written down and compared, so adding a field fails the suite until somebody
has said out loud that a spectator may see it. That is the check that would have caught both
v0.7.9.2 leaks, since both were fields nobody had asked the question about.
**Worth knowing, and it cost two false failures to learn: a card NAME is a type, not an
identity.** "right-hand curve" names a dozen cards and one is legitimately drawn on the board as
a cell label the moment anybody lays track, so searching for it fails on correct code. A name
counts as evidence only when EVERY card bearing it is in the one hand. Ids need no such care.
**And a one-digit seed makes the seed check meaningless** — seed 7 matched "Train 7". The seeds
in this file are nine digits deliberately.
Proved by mutation rather than by passing: restoring the seed line fails 6 tests, restoring the
blind-draw name fails 1, adding a private field to the public projection fails 7, and making
`players[]` carry hand contents instead of a count fails 5.
**One item on the plan's list has no test because it has no referent:** there is no secret
objective in this game. `objectiveOf` derives from `config.minCombinedRevenue` and the player's
own Revenue, both public. Said here so the next reader does not go looking for the gap.
94. ~~**A Red Flag standing at an Office's Limits was drawn nowhere.**~~ — done 2026-09-07 in
v0.7.9.4. It is a token set out ON the board that holds the next train arriving from that side;
it was announced once in the log and then existed only in the engine, so a train would stop
short with its only explanation scrolled out of the panel. `DivisionView`'s office node carries
`redFlag` now and the map draws a staff and pennant **at the end it guards** — west on the left,
east on the right — because which approach it covers is the whole of the information; a flag in
the middle would say one is out and leave the reader to hover for the half that decides whether
to run a train. **Worth knowing:** the same class as Gitea#21 and #22, and the third in a row —
when the engine gains a thing that CHANGES what a train may do, ask where it is drawn before
asking whether it works.
95. ~~**The public projection helpers — Gitea#20 step 1's foundation.**~~ — done 2026-09-07 in
v0.7.9.4. `projectDistrict(state, seat)`, `projectDivision(state)`, `projectSharedTable(state)`,
`publicSnapshot(state)` and `currentActorOfState(state)`, with `snapshot()` **rebuilt to compose
from the same helpers** rather than keeping its own copy — so a player's frame and a spectator's
cannot come to disagree about the clock, the phase or the score. Behaviour-neutral, and the
existing 897 tests are the proof of that.
**Districts are keyed by SEAT and the player resolved through `playerAtSeat`**, because Employee
Rotation moves players between districts; a public board that assumed seat and player index were
interchangeable would relabel every district the first time anybody rotated. **And the public
view is composed upward, never by calling `snapshot()` once per seat** — that shortcut builds
every private field and then has to remember to strip it, and `snapshot` defaults its viewer to
player zero, so a careless spectator call would have served seat 0's hand.
**One plan finding struck off rather than fixed:** it warns that a public display reading
`clock.currentActor` could highlight the wrong district during a decision. Measured across six
seeds and 3,600 decision points, that field and `actingPlayer` never disagreed — both are only
consulted when somebody is genuinely acting. `currentActorOfState` exists anyway, as one place
for the next reader to ask.
96. ~~**The screen named a player nobody was waiting on, all through the §3.3 vote.**~~ — done
2026-09-07 in v0.7.9.5. Extended play's vote is PARALLEL — open to every un-voted seat at once,
in any order, one refusal ending it — and `apply.ts` says where it accepts one that there is no
actor to be. The turn chart named a seat anyway: the last to move before the timetable ran out,
who has no more claim on the vote than anybody else, drawn beside a tally that correctly showed
three seats outstanding.
**The cause was two functions that agreed until they didn't.** `currentActor(game)`
(`web/game.ts`) guarded on `status !== 'active'` and returned null; `currentActorOfState(state)`
(`sim/view.ts`), added the same day in #95, had no such guard and handed back whatever
`clock.currentActor` was left holding. The frame took the second. `currentActorOfState` carries
the guard now and `currentActor` delegates to it, so there is one answer rather than two.
**Worth knowing:** the disagreement is the bug, not either answer on its own. `currentActor` is
what REFUSES an intent, so a screen answering differently tells the table to wait on a player the
server would turn away. And this is the fourth of the same class in a row after Gitea#21, #22 and
#94 — but the first found by asking the question of a state the game is not `active` in, which is
the generalisation worth keeping: a view helper needs exercising outside the happy phase.
97. ~~**Narration was sent twice by a path nobody read, and the duplicate hid a blank history
panel.**~~ — done 2026-09-07 in v0.7.9.5, the second half of Gitea#20 step 1. `Frame.lines`
carried the WHOLE log on every push to every seat, grew all game, and was thrown away on arrival:
`RemoteSession` (`web/session.ts`) accumulates `lines` from `push.lines` alone and its `lines()`
returns that accumulator. `linesSince` was sending the same text, correctly, beside it.
**The waste was masking a real fault.** `connect()` cleared `lastFrame` but not `sentLines`, so a
reconnecting seat was told "nothing new since your last push" — while the browser it was
answering had just reloaded and started from an EMPTY accumulator. The history panel came back
blank mid-game, with the server holding the whole log and shipping it in the one field nobody
reads.
**So the two halves are one change**, and doing only the half the plan asks for — "stop passing
the full game log into `frameFor()`" — would have deleted a real behaviour rather than a
duplicate. A (re)connect resets the seat's watermark, and `Push.lines` on a connect IS the
history, which is what lets the Frame stop carrying a second copy. Every remaining reader of
`Frame.lines` is the solitaire and replay path, which builds its Frames through `snapshot()`
directly and is untouched.
98. ~~**The Crew Tray pool was an "explicit mechanic" that only the engine could see.**~~ — done
2026-09-07 in v0.7.9.6. §7 scarcity is real — there are fewer trays than trains wanting one — and
the view read none of the three things the engine knows about it: how many are free, which Extras
are queued for one, which second sections are. The panel that answers "why is nothing moving?"
had a single tray rule, keyed off the train due out THIS Stage, so a player who had spent a card
on an Extra or ordered a second section got an **empty** panel while their train sat behind an
exhausted pool. Both had been announced once in the log, in a line promising a future event ("as
soon as a Crew Tray frees up") that nothing then confirmed.
`projectSharedTable` now carries `crewTrays` and `queued`, so the common board gets it too, and
the blocked panel reports all three cases with the count beside them — "no free Crew Tray" alone
reads as a permanent fact about the game rather than a state that will pass. An Extra is reported
to the player who played the card, because §7 gives the train to them; a second section is the
table's, like any Timetabled train.
**Worth knowing:** the first draft derived the pool size as `trays.size + freeTrays.length`,
which is invariant in play (`retireTrain` returns the tray) and read **"0 of 0"** the moment it
met a state where a tray was neither free nor carrying a train. `crewTrayCount` already owned
that number. A second way to know one fact is the shape of every bug in this release.
**THE METHOD, which is worth more than the three fixes** (#98, #99, #100 all came out of it, and
`test/display-gaps.test.ts` cites this entry for it). Gitea#21, Gitea#22, #94 and #96 were four
instances of one fault in a row — the engine gained something that changes what a train may do,
and nothing drew it — and every one was found by a player hitting it. So instead of waiting for
the fifth: enumerate every field of `GameState` and its nested types, check each for a reader in
`sim/view.ts`, `src/web/` and `sim/narrate.ts`, then **verify the survivors by running the engine
rather than trusting the grep**. Four fields had no reader. `movedThisPhase` is set and cleared
inside one `advance` call and is genuinely nobody's business; the other three are the items
above.
**A field is not a display gap merely because nothing renders it**, and saying so is what keeps
the sweep honest. `freightWorked`, `drawnThisTurn`, `freightAgentUsed`, `switchedSince` and
`movesUsed` were all ruled out: their EFFECT is already visible as legality, or as a complement
already on the Frame (`movesRemaining`). `dispatchUsedToday` was left as the one genuine maybe,
and was then **done as #101** — it turned out to have a second half worth more than the first.
The verification mattered twice. The blocked panel returning `[]` for the two queues is only
evidence alongside the positive control — the same state with a timetabled train due, which
correctly reports "no free Crew Tray". And #99's "drawn nowhere" was established by watching a
chip present on the Interchange node before the move to `heldAtLimits` and absent after.
99. ~~**A train held at the Limits by an Interlocking vanished off the board.**~~ — done 2026-09-07
in v0.7.9.6, and the most serious of the three. The Interlocking is the designed answer to a full
Office — instead of Gap 2d's automatic collision, "may stop an inbound train on the Limit Track",
and it takes the first A/D track that frees ahead of any newcomer.
`arriveAtOffice` removes the tray from the Mainline node's `transits` and the Interlocking branch
pushes it onto `area.heldAtLimits` **without assigning `tray.position`**. The map draws mainline
nodes from `transits` and district squares from `position.at === 'grid'`, so between the two it
was drawn in NEITHER. Measured: with the tray in `transits` the Interchange node carries its
chip; moved to `heldAtLimits` exactly as the engine moves it, the node's `trains` is `[]` and no
grid square has gained it. The train disappeared on arrival and reappeared in the Office some
Stages later, with one log line as the only account of it.
**Fixed in the VIEW, not the engine.** The engine's state is right — a held train is inside the
Limits and not on an A/D track — and `position` is left alone deliberately, so nothing may treat
it as standing on a square it could be switched from. `trainsOnCard` draws it on the Limits
square it came in by (eastbound at `limitsWest`, westbound at `limitsEast`), flagged
`heldAtLimits` so it does not read as an ordinary arrival, with the reason on the chip and in the
blocked panel.
100. ~~**The Campaign Train's speeches changed its rules and the card never said which half it was
in.**~~ — done 2026-09-07 in v0.7.9.6. X17 is "one turn at station (speeches) then expedite":
its first Office arrival is an ordinary stop, and every arrival after runs EXPEDITED — so if it
is not back on the Office square when the next Mainline Phase begins, that is a Station Master
fault costing 1 Revenue.
`trainRules()` took `{ trainNumber, trainIsExtra }` and so could not see `speechMade`, even
though both of its tray-side callers hand it a whole `CrewTray` that has it. The chip therefore
read identically before and after — and worse, the "EXPEDITED … costs 1 Revenue" warning is
printed only under `rules.expedite`, so X17 became subject to a fault whose warning the game
shows to every other expedited train and never to it. It now says which half it is in, and
borrows `isExpedited` from `advance.ts` rather than restating the test: a card describing a rule
the engine does not apply is the failure this sits inside.
101. ~~**A dispatch device never said it had been spent — or that it was somebody else's to
spend.**~~ — done 2026-09-07 in v0.7.9.7, the last item off #98's sweep and the only one that
had been parked rather than fixed.
Telegraph (+4), Telephone (+8) and Radio (+12) are "once a day, when dispatching facing trains,
add +N to the other train's number". `enhancementText(key)` takes only the KEY, so the tooltip
could not vary with anything: a spent Radio read "Once a day, add +12…" for the rest of the Day,
advertising a bonus that was not there. That is `trainRules` before #100, in another corner of
the same view.
**THE SECOND HALF IS WORTH MORE THAN THE FIRST, and is why this stopped being a small item.**
`spendDispatchBonus` reads `areaOf(s, s.clock.superintendent)` — the SUPERINTENDENT's own
devices, not the train owner's — and the Fedora moves every `STAGES_PER_SHIFT` (3) Stages, four
times a Day. So a player's Radio does nothing at all for three-quarters of the Day, and is spent
automatically, without its owner being asked, during the quarter it is theirs. Neither half was
anywhere on the board. The card now says which of the three states it is in, and names the shift
length, because "not now" without "for how long" is half an answer.
**Shown on EVERY district** (Jesse, 2026-09-07), not only the viewer's: it is public, and a
rival's spent Radio is what you want to know before forcing a meet. One change covers both, as
`projectDistrict` serves the player's own cells and the common board's `districts` alike — note
that a player's Frame carries only their own district, so in practice "every district" is the
common board, which is pre-existing and not touched here.
What counts as a device is `enhancementRule(key)?.dispatchBonus`, not a list of three keys
written out in the view: the ladder lives in `ENHANCEMENT_RULES`, and a fourth rung added there
would otherwise be silently exempt from the whole of this.
**A stale comment corrected, and pinned.** `advance.ts` warned that indexing a SEAT-keyed area
with the PLAYER holding the Fedora "is right only while seating is the identity map". It read as
a live Employee Rotation bug and was not one — `areaOf(s, p)` IS `areaAtSeat(s, seatOf(s, p))`.
A comment that sends the next reader chasing a bug that does not exist costs about as much as
the bug would, so it is rewritten, and the claim is now a test: seating set to a real
permutation, and the Superintendent's own district — not the seat with the same index — is the
one that reads as dispatching.
**And a test that passed for the wrong reason, caught by mutation.** "Leaves a non-dispatch
enhancement alone" originally asserted the ABSENCE of /spent|Fedora/ with the Fedora held —
and a mutant with the `dispatchBonus` guard deleted passed it, because the leaked text in that
case says "Available today, and this district is dispatching", which contains neither word. A
test that something was left alone has to compare it against what it should be; it asserts
equality with `enhancementText` now, in both Fedora states.
**The replay wire format needed the field too.** Cells are packed positionally, so the new flag
is index 9 and reads `?? []` — the same tolerance `standingWest` uses. Recordings made before
it existed report no device spent, which is exactly what they drew at the time, so every
published replay is unchanged.
102. ~~**`npm test` did not typecheck, and passed green on a type error in the server.**~~ — done
2026-09-07 in v0.7.9.8. `pretest` ran `scripts/build-web.ts`, which invokes
`tsc --ignoreConfig` against three web entry points — so it saw only what those three
transitively import, under a WEAKER configuration than `tsconfig.json` (no
`noUncheckedIndexedAccess`, no `exactOptionalPropertyTypes`, `--types ''`). `src/server/` and
every file under `test/` were never checked by the test command at all.
Demonstrated rather than argued: `const DELIBERATE_TYPE_ERROR: number = 'not a number';` in
`src/server/session.ts` gives `npm run typecheck` a TS2322 and `npm test` a clean
`# fail 0`. `pretest` is `tsc --noEmit && node scripts/build-web.ts` now, and the same planted
error exits 1 with the tests never running.
**Why it mattered THIS week rather than generally.** The next release is steps 2-4 of the
common board — a display stream, credentials, persistence and the display-step collector, which
is almost entirely `src/server/` and is exactly the half the test command could not see. (The
Chromium supervisor was in this list when the entry was written; steps 5-7 became v0.9.0 on
2026-09-09, and the point stands without it.) Found while answering "anything else before
0.8.0", which is the only reason it was found at all: nothing about a green suite would ever
have said so.
103. ~~**The common-board plan had drifted from the code it is the source for.**~~ — done 2026-09-07
in v0.7.9.8. `docs/plans/jitsi-common-board.md` was written 2026-08-27, still said "No
implementation has been performed", and is what steps 2-7 will be built from. Step 1 has since
shipped across four releases, so every "current code finding" under it described a fault that
is now fixed — a document that reads as present tense and is nine days stale sends the next
reader to fix things twice.
Measured: the plan's `PublicFrame` sketch lists four properties that were never built
(`protocolVersion`, `config`, `scoring`, `deckCounts`) and omits **28** that exist. The shape
is the real difference — the implementation is FLAT where the plan grouped things into `clock`,
`config`, `scoring` and `deckCounts` objects. A renderer written from the sketch would not
compile against the projection.
The plan now says so at the top and at step 1, names `src/sim/view.ts` and
`test/redaction.test.ts`'s allow-list as the authority, keeps the original sketch for its
reasoning, and lists what has been gained since (`crewTrays`, `queued`, `heldAtLimits`,
`enhancementsSpent`). **`protocolVersion` is called out as unbuilt** rather than quietly
dropped: step 2 is the reconnecting display stream and is the first thing that would want one.
**One step-1 item is STRUCK OFF rather than deferred:** "add the Red Flag holder to the public
player projection." The premise does not hold here. `decks.redFlags` is written once, in
`setup.ts`, from `config.optionalRules.emergencyToolbox`, and never again — `redFlag.play`
emits `phaseEnded` and does not spend it — so every player holds one or none does, decided
before the deal. A per-player `redFlagHeld` would be `optionalRules.emergencyToolbox` copied N
times, already public, while telling every reader of the common board that it varies by player
and might change mid-game. That is worse than the absence. Pinned by test so it is not
re-raised from the plan text: if the rule ever becomes per-player, the test fails.
32. ~~**Tell the 0.4.9 playtesters their saves are dead, before they find out.**~~ — done
2026-09-07. `PLAYTEST-0.7.4.md` was written for exactly this and did its job; Jesse, 2026-09-07:
"a temporary document to help some of the playtesters out on making the big jump, but that is no
longer needed." The jump is made, so the note is retired rather than committed. **No per-version
save-compatibility fact is tracked from here**, on Jesse's call 2026-09-07: a save replays
through the current rules, so an older one breaking is the design working rather than an event
to log each time. The general rule lives in `README.md` § Design notes; #40 is the same shape and
has been generalised to match.
92. ~~**Two multiplayer information leaks in the shared narration log.**~~ — done 2026-09-07 in
v0.7.9.2, found while planning Gitea#20 step 1. The **seed** was announced in the opening line of
every multiplayer game and a **blind Home Office draw named the card** — and `linesSince(seat)`
slices one shared `game.log` with no per-seat filter, so both went to every player. **Ruling:
solitaire keeps both**, because a one-seat table has nobody to leak to, the seed is what a bug
report quotes, and a solo player's history naming their own draw is the record. A Department
slot is face up and stays named for the same reason. **Worth knowing:** these were not found by
a test, they were found by reading the plan — every redaction test passes `[]` for the log, so
the whole of narration was unchecked. #91 is what remains.
93. ~~**`docs/rules/` had no current description of the game, and the code pointed at a superseded
one.**~~ — done 2026-09-07 in v0.7.9.2. `content.ts` named `card-reference.md` as "the place
that now carries what the cards say" while that file's own banner said not to use its numbers;
it describes the v0.4.5 deck, where 3/4 is a Mail-Express with three coaches against a `content.ts`
whose train 3 is the Express with two freight cars. **The fix is not a rewritten table** — every
file in `docs/rules/` is a deliberate historical record and worth more intact than patched. A new
`as-built.md` is GENERATED from the same catalogues the engine instantiates from, by
`scripts/build-card-reference.ts` (`npm run build:cards`), and `test/card-reference.test.ts`
re-runs the generator and fails if the checked-in file disagrees. **Worth knowing:** a
hand-written replacement would have drifted the same way and for the same reason — nothing fails
when a table falls behind a constant. Generate it or check it; do not retype it.
89. ~~**The map drew westbound trains in the wrong half of a Mainline card.**~~ — done 2026-09-07 in
v0.7.9.1, Gitea#22. `regionOfTransit` counts from the end a train ENTERED, which is what the
collision rules want; the map wanted "which printed box, left to right" and used the same number,
so every westbound card came out mirrored. It cost a collision: the Superintendent cleared Train 3
to follow T5 and it ran into TX17, which the picture had drawn ahead of T5 instead of behind it.
**Worth knowing:** the engine was right and only the picture lied, so the fix is one mirror in
`view.ts` at the drawing boundary — and every existing region test ran eastbound, where the mirror
is the identity, which is exactly why it survived them. The same shape as the mirrored consist row
at the Whistle Post (seed 270861860): a number meaning "distance run" used where the screen means
"place". **Ask of any new Frame field whether it is a distance or a position.**
90. ~~**The Blocked panel explained a refusal by describing something else entirely.**~~ — done
2026-09-07 in v0.7.9.1, Gitea#21. A second tank car would not come off at a refinery; the panel
said the refinery's green box was empty. The real answer was Train 3's printed rule — the Express
may work one freight car per location — so the refusal was correct and the panel sent the player
to spend a Freight Agent action that could not have helped. **The ruling: no rule changed, only
what the screen says about it.** The panel asks the reducer's own `freightBudgetLeft` through a
new exported `freightRuleSpentHere`, so its words cannot drift from the rule. **Worth knowing:**
a panel that answers the wrong question is worse than one that stays silent, because it looks
like an answer — and a rule that only lives on a card tooltip is invisible at the moment a player
notices a button is missing.
43. ~~**"Waiting on" said nobody while the game was stopped on the Superintendent.**~~ — done
2026-08-30 in v0.7.9. `Frame.actor` carried `clock.currentActor`, null for the whole Mainline
Phase, so all three interruptions (clearance, Yard Office, Red Flag) reported that the Division
was running itself. It carries `actingPlayer` now and a new `awaiting` field says what the
question is and which train it is about. **Worth knowing for the next Frame field:** the engine
had the right answer in `actingPlayer` since the Gitea#5 refactor, and the Frame simply did not
carry it. A view that reads one field of `clock` directly is a place this can happen again.
34. ~~**`replay.ts` printed a raw outcome enum, exactly as the results screen used to.**~~ — done
2026-08-30 in v0.7.9. Its summary line rendered "loss — revenueFloor", the same defect Gitea#16
was filed about, alive in the dev-side viewer a release after the playable page was fixed.
`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; the drift test maps `win`/`loss`
to `won`/`lost` so it still checks agreement rather than spelling.
29. ~~**Put the Fedora at the right-hand end of the phase row.**~~ — done 2026-08-30 in v0.7.9.
Details, and why the name does NOT go inside the Supervisor Shift pill, in Display below.
42. ~~**Solitaire must ask before it deals, the same way multiplayer's lobby already does.**~~ — done
2026-08-29 in v0.7.5, and then fixed three more times. Jesse: "let the user choose their options
like the start of a multiplayer game"; "asking first is the only path." A `#solitairesetup` screen
asks the full shared block before a genuinely fresh visit deals anything; a saved game, an
explicit `?seed=`, or a URL a Deal already wrote all skip past it. The in-game dialog, the lobby
and this screen share one `wireGameTypeBlock()`/`commitNewGame()` pair.
**THE LESSON, AND IT IS THE MOST EXPENSIVE ONE IN THIS FILE.** The same report came back three
times, and each of the first two "fixes" corrected something real that was not the reported fault.
- **v0.7.6** — the splash's "Play solitaire" door landed in a leftover Co-op lobby: `start()`
checked a remembered multiplayer session before looking at solitaire's own state. Real bug.
Not the one reported.
- **v0.7.7** — every packaged build published the same cache-bust key (`?v=nogit`, because the
`.s9pk` build has no `.git` for `git rev-parse`) and the server sent no `Cache-Control`, so
neither release ever reached the browser that asked for it. Real bug. Still not the one
reported. **When a fix appears to have had no effect, check that it ARRIVED before
re-diagnosing it** — a hard reload would have answered it on the first report.
- **v0.7.8** — the actual fault: v0.7.5 skipped the setup screen whenever a save existed ("a
saved game is a game to resume"), so any browser that had ever played solitaire could never
reach it again. The private window that seemed to vindicate v0.7.7 simply had no save.
**Each of the three was reported as verified, and each verification read what the SERVER served
rather than exercising the path with the state a returning player actually has.** The thing that
worked was a failing test written before the fix. Next time a report repeats: reproduce the
user's state first, and treat "I verified it" as unearned until something failed the way they
described.
38. ~~**Gitea#13, #5 and #19 — three rules corrections.**~~ — done 2026-08-29 in v0.7.4 (`5e34c73`,
`2280276`, `19a6a47`), wrapper `085b88b` as `0.7.4:0`, installed on `phoenix.local`. Each issue
carries a comment naming its commit and what was ruled. What they left behind is #39, #40 and #41
above.
31. ~~**Bump the StartOS wrapper to 0.7.2.**~~ — done 2026-08-26 (`1bfea8d`). Folded into the
standing practice at #37 above, which is where the sequence now lives.
25-27. ~~**Three Division-map drawing reports from the v0.7.0 build**~~ — no track geometry, the
buffer stop pointing the wrong way at two players, and east not always being to the right. All
answered by **Gitea#18**; the last was FIXED OUTRIGHT by it and is the reason a single row won.
Collapsed into one entry in Display below.
19-22, 24. ~~**Five drawing items against the wrapped Division map**~~ — vertical track art, the
inter-row connector, overflowing captions, filling the dead centre, and seating the viewer at the
bottom. All **SUPERSEDED by Gitea#18**, which replaced the layout rather than fixing the drawing.
Two ideas survive them and are recorded in Display below: "SHARED in the middle, YOURS on the
right" (now Gitea#20's territory), and why rotating the map to seat a viewer was refused.
9-11, 12a-12g. ~~**The v0.4.9d and v0.4.9e gameplay-testing reports**~~ — twelve bugs, all shipped in
v0.4.9e / v0.7.1. Two trains in one station answering to one button; a Freight House unloading the
boxcar it just loaded; the Grocer's Warehouse shipping and the Refinery receiving; Gitea#4 extra
train starts; #7 coach counts on four train cards; #6 then #9 on discarding train cards (#9
superseded #6 three days later — a Timetabled train may be tossed face-up to a Department slot,
an Extra may not); #8 the per-diem train and the caboose; and #10 the Day-rollover dialog.
Reasoning for each is in `docs/rules/implications.md` and `CHANGELOG.md`.
**Gitea#2 carries a RULING worth keeping.** Four porters, two passengers on the platform, and
only one may be worked — RULED AND FIXED, but not the way the report implied. The engine is
faithful to the written rules at every step; what bites is that BOTH directions of porter work
move coaches one-way into a Classification Yard that comes back only when the Division Yard is
bare of all ~60 cars. Sixteen coaches in the game, and the reported save runs dry on Day 5 with
eight stranded. **Jesse's ruling: the shortage stays** — "it is possible to run out, that's part
of the strategy" — so the three balance options in Play Balance below are DECLINED, not deferred.
What was actually wrong is that the game said NOTHING: a Porter action that cannot be taken is
simply absent from the menu, and the "why is nothing moving?" panel covered freight facilities
only. That half is fixed.
1-6. ~~**The 2026-08-20 multiplayer planning queue and the first StartOS play session**~~ — the New
Train phase car-placement round, unified victory conditions, Phase 2 server core, the lobby
offering every game parameter, deciding what the four `optionalRules` are (`sisterTrains`
deleted, `employeeRotation` implemented, the other two already live), and stopping every release
from destroying every game in progress — done by replaying the save rather than comparing version
strings. All shipped in v0.6.0; reasoning in Multiplayer below.
### Closed items, by the area they were in
- [x] **~~`fromSave`'s replayed narration loses "Player X" attribution~~** — fixed 2026-08-30 in
v0.7.9, one argument: `record(game, result.events, actor)`, with `actor` already computed on
the line above.
**The reason it survived is the lesson.** This entry says nothing ever compares a
`fromSave`-built log against a LIVE-played one, and that was exactly right — the whole existing
test suite stayed green with the bug in place, because the one log-comparing test compares two
`fromSave`-built logs and the gap cancelled out on both sides. The new test plays a game,
saves it, restores it, and asserts the two logs are identical: **the missing direction, not a
new requirement.** Confirmed to go red with the fix reverted.
Original note below.
**`fromSave`'s replayed narration lost "Player X" attribution — found 2026-08-20 building
multiplayer Phase 3, not fixed there.** `fromSave`'s loop (`game.ts`) calls `record(game,
result.events)` without the `actor` argument `submit()` always passes it (`game.ts`'s own
`record(game, events, actor)` — `actor` is what turns "Chose to draw a card" into "Player X
chose to draw a card"). So a restored save, an undone game (`undo` rebuilds via `fromSave`
internally), or a replayed one all lose attribution on every line — invisible in solitaire
because nothing ever compares a `fromSave`-built log against a live-played one (the one test
that compares logs, `test/web.test.ts`'s "leaves nothing in the log describing a move that was
taken back", compares `undo`'s `fromSave`-built log against ANOTHER `fromSave`-built log, so
the missing attribution cancels out both sides), but it would read as broken the moment more
than one seat's history is on screen at once — exactly what the replay viewer and any
multiplayer post-game replay (D20) need to get right. Fixed in `fromMultiplayerSave`
(multiplayer's version of this function, added for Phase 3) by passing `actor` through; not
touched in `fromSave` itself since it's used far more widely (undo, save/restore, the replay
viewer) and deserves its own careful look rather than a fix bundled into an unrelated change.
- [x] **A blocked PASSENGER facility produces no impediment at all — FIXED.** `impediments()`
(`src/sim/narrate.ts`) opened with `if (!f || f.kind !== 'freight') continue`, so the "why
nothing is moving" panel had never had anything to say about a platform. That was the second
half of Gitea#2 and the half that was unambiguously a bug: the player above was not merely
blocked, he was given no reason — the button simply was not there. A platform now reports
passengers with no train, a train the card bars Porters from working, full coaches, full red
slots, the same-district rule, and the coach shortage itself — the last naming how many coaches
are stranded in Classification and what brings them back. The reason comes from
`passengerRefusal`, the engine's own predicate, so the panel cannot drift from the rule that
actually refused. Fixing the label found a second defect: a Passenger Facility rides on the
`office` card, so every passenger row would have read `facility 0,0` next to `mineTipple 1,-3`;
it is named by its tier now.
- [x] **~~The log lowercases the first letter of every narration it attributes to a player~~** —
fixed 2026-08-30 in v0.7.9. `EXTRA X18 started…` rendered as `Player Solitaire eXTRA X18
started…`, and the same happened to `TRAIN 1 MADE UP` and `COLLISION`. A new `uncapitalise`
folds the opening word **only when it is sentence-cased** — the test is `^[A-Z][a-z]`, a
capital followed by a lower-case letter, which is an ordinary word capitalised because it began
a sentence and nothing else is. That also leaves `X22 Pee-Dee` alone, which a naive "is it
uppercase?" check gets wrong because `'2'.toUpperCase() === '2'`.
**Misfiled here, and worth saying so.** This sat under Play Balance and is a text bug with no
bearing on balance at all — which is why it survived a session that had explicitly ruled
balance work out of scope. Found again 2026-08-30 only by reading the section it did not
belong to.
- [x] **~~The lobby, the setup form and the start of a game~~ — done 2026-08-23 (Jesse's cleanup
pass).** Kept for the reasoning, since several of these were decisions rather than fixes.
**The game types.** Co-op, Competitive, Cutthroat, Solitaire and Custom (`src/web/presets.ts`),
replacing the old two-mode radio. A type is a set of DEFAULTS, not a ruleset: every rule stays
editable, and editing one selects Custom, which keeps the scoring of the type it came from.
Jesse's numbers — Co-op pays 1 per transit and asks 3 combined Revenue per player per Day;
Competitive asks 2 and pays nothing for transits; Cutthroat asks nothing at all and lifts the
whole-game collision cap, leaving three-in-one-Day as the only shared way to lose; every type
deals six cards. **The Revenue floor is a formula**, so table size and Day count re-derive it
rather than making a game Custom — which is why those two, and the seed, sit above the type
radios as parameters. The type is DERIVED from the numbers, never stored, so no saved game
carries a label that can disagree with itself (`test/presets.test.ts`).
**Where an Extra may start was missing from the lobby entirely**, so every multiplayer game
ever played used the most permissive setting (`anyOffice` — an Extra may be planted in another
player's district) and no host was ever asked. It is a Cutthroat-only default now. The reverse
hole existed too: the solitaire dialog had none of the three optional rules. Both screens ask
the same eleven questions through one shared module (`settings-form.ts`), and a test asserts
the markup carries every field on both — the drift is what motivated the shared block.
**The dead PvP checkbox is gone from both screens.** `buildDeck` ANDs `pvpCardsAllowed` with a
hard-coded `cardsImplemented = false`, so the control could not do anything whatever it was
set to. The 22 opponent-directed cards (and the 7 defences held out with them) are now a
property of the game type, and the fact is stated in words where the checkbox was.
**The lobby itself:** joining is a door of its own rather than a heading below fifteen fields
the joiner has no use for; a stored join secret collapses to one line and re-opens on a 403; a
display name is remembered and may not duplicate another at the same table (`NAME_TAKEN`); the
seating list numbers bots as the game will; the code copies as a code AND as an invite link;
server codes are translated into sentences in a red block instead of `.dim` grey; the lobby
stream has an `onerror` that tells a blip from a dead lobby (and finds a game that started
while the connection was down); and **a player may read the whole rule set before taking a
seat** (`/api/lobby/preview`, which never carries the seed).
**The start of a game**, which nobody had ever drawn: a handoff curtain with a deliberate beat
instead of a "connecting" line written into the DISCONNECT banner, an announcement naming the
game and its type, a marker at the top of the log so the bots' opening turns are visibly after
the start, the game code and the type in the header for the rest of the game, and a Start
button that cannot be pressed twice.
**The four transient signals reach a remote client at last.** `createRemoteSession` answered
all four with empty values, so multiplayer had no sound, no timetable flash, no announcement
when a completed run paid the table, and no badge on the card you had just drawn. `justDrawn`
is the redaction-sensitive one — `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.
**Two things found by RUNNING it rather than reading it.** A bot seat was being reported as a
disconnected player, which would have put "waiting on Bot 1 — not here yet" on every screen
for a whole game. And 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.
- [x] **~~WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTER ~16 STAGES OF A POSSIBLE 60?~~ Answered
2026-08-20: the collision floor, not the revenue floor.** Traced `checkVictory`
(`advance.ts:1086-1133`): in competitive mode the revenue floor can only fire at the exact
Day-5 boundary (Stage 60), so it structurally cannot explain a 16-Stage ending. Only the
collision floor can (`advance.ts:1076-1080`, 3 collisions in one Day, checked at every Stage
boundary). `collisionsToday` is one counter every seat feeds, so a 4-player table burns a
fixed shared budget roughly 4x faster than one player would. Also: `multiplayer.md` §3's
8-game sample predates `DEFAULT_HOUSE_RULES` (v0.4.2) and most likely ran under what is now
`LEGACY_HOUSE_RULES` — that sizing data is stale on top of the collision-floor explanation.
Jesse's call, 2026-08-20: keep the collision caps flat rather than player-scaled (below), so
16-Stage games under default settings are an accepted, deliberate outcome, not something to
re-tune away — re-measure `multiplayer.md` §3's sizing table once the redesign lands, but
expect similar early endings by design.
- [x] **~~Victory conditions unified across solitaire, competitive and coop~~ — designed and
implemented 2026-08-20.** One shared, fully-configurable set of `GameConfig` dials replaces
`LENGTH_PROFILES.target`, `VictoryCondition: 'firstToTarget'` (confirmed dead — grepped, never
selected anywhere in the codebase today) and the flat `COLLISION_FLOOR_PER_DAY` constant:
| dial | meaning | default |
| --- | --- | --- |
| `days` | how many Days the game runs | 5, all modes |
| `minCombinedRevenue` | everyone loses if the table's total Revenue is below this when Days run out | `3 × players × days` — reuses `collectiveRevenueFloor()` (`content.ts:1020`), now also applied to solitaire (1 player) rather than competitive-only |
| `maxCollisionsPerDay` | everyone loses immediately, mid-game, once collisions in one Day reach this | 3, **flat — not scaled by players.** 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 |
| `maxCollisionsTotal` | same, summed across the whole game | 5, flat, same reasoning |
| `pvpCardsAllowed` | whether the 22 opponent-directed cards (still unbuilt, see below) are in the deck | forced off in solitaire and coop — no valid target for them in either — on by default in competitive |
`0` means "off" for every dial. Win/lose shape is otherwise unchanged from what solitaire
already does: most Revenue when Days run out wins, unless `minCombinedRevenue` was missed, in
which case everyone loses — just made configurable per game instead of a fixed `length`
lookup. Coop keeps its existing "score is the table's total" model, now against a
configurable floor instead of `profile.target * players.length`.
**New Game dialog:** one shared dialog for all three modes, per Jesse — a mode radio button
at the top, the same field set underneath for all three, greyed out wherever a mode forces a
value (the PvP checkbox in solitaire/coop). Solitaire gains the four new dials alongside the
starting-hand and revenue-rate fields it already has; picking a mode only changes the
defaults, never the field set. **Deal stays disabled for Competitive/Co-op** with a "needs a
server" note, since Phase 2 didn't yet expose a way to actually start one from the browser
(see below) — only Solitaire's Deal path is wired to a real game today.
- [x] **~~New Train phase car-placement is one player's job even in competitive mode~~ — fixed
2026-08-20.** §7 (`rules-v0.2.md:346-363`) is explicit: "starting with the Superintendent and
working left, each player may place ONE car... the round repeats... until the consist is
full," with a worked 2-player example. `newTrainPhase` (`advance.ts:187-282`) never
implemented the round: `enterPhase` resets `actorOffset = 0` on entering the phase
(`advance.ts:172`) and `newTrainPhase` never incremented it the way `playerPhase` does for
Local Ops (`advance.ts:140`), so the actor was always the Superintendent alone, for every car
of every train made up that Stage. Fixed by reading the round position off
`tray.consist.length` instead — it already counts placements toward that tray and resets per
train with no new state needed. Test in `multiplayer.test.ts`, "the New Train phase
car-placement round rotates."
**Found in the process, not fixed, logged separately:** `newTrain.passCar`'s `check()`
(`apply.ts:959-967`) tests whether the *entire* Division Yard is empty rather than whether a
car suitable for *this* tray exists, and `reduce()` has no case for `carPassed` at all
(`apply.ts:2246-2247`, falls to `default: break` — applying a pass currently mutates nothing).
Unreachable in practice today: `trainNeedingCars` only ever flags a tray that already has a
suitable car waiting, so a legal `passCar` for the flagged tray can't occur. Only matters if a
future change lets the New Train phase address more than one tray at a time. Not fixed here —
nothing to verify against an intent that can't legally fire.
- [x] **~~The lobby's seat controls could not express "nobody in this chair"~~ — done in v0.5.3.**
Raised by Jesse 2026-08-21. The seats array grew as people joined, so the four rows on screen
were partly fictional: a 2-player game simply started with a 2-long array, and a host who
added a bot to a later chair padded the array with a `null` that silently disabled Start
behind a one-line note. **The host now picks the table size (2-4) when creating the game**
and the array is built at that length once, so a gap cannot be expressed rather than merely
being rejected. That also removed the need to compact seats at `Lobby.Start` — which would
have shifted the `player` index every `PlayerSession` records at join time and that
`/api/stream` and `/api/intent` route by, quietly handing a player somebody else's railroad.
Tested in `test/server/lobby.test.ts` ("seat index is player index, with no compaction to
shift it", "never grows the table, whoever asks", "refuses a chair that is not at the
table").
- [x] **~~EVERY RELEASE DESTROYS EVERY GAME IN PROGRESS~~ — fixed in v0.6.0, by option 3.**
Raised 2026-08-21 after v0.5.2, v0.5.3 and v0.5.4 each killed the games on the StartOS box in
turn — v0.5.4's changes were *rendering only*, and it still refused two saved games.
**Why it happens, and why the design is right as far as it goes.** A save is a seed plus a
list of intents (D5), so loading one means replaying those intents through the current engine.
A move that was legal under the old rules may be rejected under the new ones, and a
half-replayed game is worse than no game — so `loadGame` refuses on any `engineVersion`
mismatch and `index.ts` logs it and carries on (D7). Nothing is deleted; rolling the version
back makes the games loadable again. That is all correct. The problem is only that the test is
**exact equality against the package version**, which moves for reasons that have nothing to
do with the rules.
**Why it is getting worse rather than better.** It was harmless while Jesse was the only
player. It stops being acceptable the moment other people are seated: their game is destroyed
because somebody shipped a CSS fix. It also interacts badly with the stranded-session bug
fixed in v0.5.5 — the refusal is precisely what stranded a browser on a blank page.
Three ways out, cheapest first:
1. **A separate rules version, bumped by hand.** `RULES_VERSION` in `content.ts`, stamped into
the save instead of `package.json`'s version, and raised only when a change can alter
whether an intent is legal. v0.5.4 would not have touched it and both games would have
survived. Cheapest and the least clever, but it is a judgement call on every release, and
getting it wrong silently corrupts a game rather than refusing it — the failure is worse
than the one it replaces.
2. **A declared compatibility floor.** The save records the version that wrote it; the engine
declares the oldest save it will accept. Loading checks `saved >= floor` rather than
`saved === current`. Same judgement call as (1), but expressed as a range, which makes
"this release breaks saves" an explicit act rather than the default.
3. **Verify rather than assume — replay and see.** Load the save, replay it, and refuse only
if an intent actually rejects. This is the honest test and needs no judgement at all: it
answers the real question ("does this game still replay?") instead of a proxy for it. It
costs a full replay per game on boot, which is ~100 ms per finished game (measured
2026-08-21) and only unfinished games are loaded — so at any realistic table count it is
free. The work is in reporting a partial failure well: the game is intact up to the
rejected intent, and a player would probably rather resume there than lose it entirely.
**(3) was done.** `loadGame` no longer looks at the version; `tryResumeSession` replays the
save and reports the first intent the engine refuses, and `index.ts` resumes or refuses on
that. A save stamped with a version the server has never run now resumes, provided its moves
replay — verified against a file hand-stamped `0.4.9-ancient`. A save that genuinely does not
replay is refused as before, but the log now names the move: *"move 3 of 8
(localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN"*.
One thing deliberately NOT done: resuming a partially-replayable game at the last good move.
The note above suggested a player would rather have that than nothing, and on reflection it
is worse — the game would silently rewind to a position nobody played to, and the browsers
holding a later Frame would have no idea. Refusing keeps the file intact, so putting the
previous version back still recovers the game. Revisit only with a way to tell the table what
happened.
- [x] **~~THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING~~ — resolved
in v0.6.0.** `sisterTrains` is deleted: Q9 records that the Second Section card supersedes it,
and that card is built. `employeeRotation` is implemented — the rotation is four lines in
`advance.ts` because the seat/player split (D9) exists precisely for it, so Revenue, hands and
the Fedora travel with the player and the district stays with the chair. All three survivors
are now settable from the lobby. Original reasoning kept below.
**Original note:** Split out
at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby
item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`,
`sisterTrains`, `employeeRotation` and `emergencyToolbox`. Neither the solitaire New Game
dialog nor the lobby exposes any of them, and every construction site in the codebase
hardcodes all four to `false` (`web/game.ts`, `sim/harness.ts`, `sim/replay.ts`,
`sim/compare.ts`), so no game has ever been played with one on.
**Check what is real before building a form for it.** Only two are wired:
| rule | status |
| --- | --- |
| `reducedVisibility` | **live** — read at `advance.ts:53`, gates on `NIGHT_STAGES` |
| `emergencyToolbox` | **live** — read at `setup.ts:374`, seeds each player's Red Flags |
| `sisterTrains` | **nothing reads it.** Declared, defaulted, never consulted — and §9a Q9 records that the Second Section card *supersedes* the Sister Trains optional rule, so this flag is most likely dead rather than unbuilt. Decide whether to implement or delete it |
| `employeeRotation` | **nothing reads it.** Declared, defaulted, never consulted. Note the seat/player split (Phase 0, D9) was built specifically so this rule *could* exist — the groundwork is there, the rule is not |
So a dialog listing all four would offer two working toggles beside two that silently do
nothing — the exact failure `checkPlay`'s `NOT_IMPLEMENTED` and `enhancementText`'s
live/dormant/unbuilt table exist to prevent. Either implement the two dead ones, delete
them, or label them on screen the way an unbuilt Enhancement already labels itself. Doing
that is what decides whether this is a UI job or a rules job.
- [x] **~~THE LOBBY OFFERS NO GAME PARAMETERS AT ALL~~ — done in v0.6.0.** A "Game settings" block
on the create form carries the same dials the solitaire dialog has — seed, starting hand, the
three revenue rates, days, the combined-Revenue floor, both collision caps, the PvP toggle —
plus the three surviving optional rules. Mode and table size set the defaults and every field
stays editable, matching the solitaire dialog's own behaviour. Original note below.
**Original note:** Raised by
Jesse 2026-08-21 after playing the StartOS build. Creating a multiplayer game asks for a
display name and a mode, and nothing else — every other dial comes from
`defaultMultiplayerConfig(mode)` (`web/game.ts`), hardcoded, with no way to change it.
Solitaire's New Game dialog (`play.html`, `#ng-*`) asks for all of it: seed, starting hand
(`ng-hand` — three random / six random / three track + three other), the three revenue rates
(`ng-passenger` / `ng-freight` / `ng-transit`), `days`, `minCombinedRevenue`,
`maxCollisionsPerDay`, `maxCollisionsTotal` and `pvpCardsAllowed`. Multiplayer should ask for
the same set. Note that `GameConfig.optionalRules` (reduced visibility, sister trains,
employee rotation, emergency toolbox) is exposed by NEITHER dialog and is hardcoded false in
both — worth deciding on separately rather than folding in silently.
**~~The bug this hid~~ — fixed in v0.5.3.** `defaultMultiplayerConfig` defaults to
`players = 4` and `lobby.ts` called it without the argument, so `minCombinedRevenue` was
always `collectiveRevenueFloor(4, 5)` = 60 whatever the table's real size — a 2-player game
played against a floor meant for four (60 rather than 3x2x5 = 30), and missing that floor
means *everyone loses*. It fell out of the seat-control change: the host now picks the table
size when creating the game, so the real count reaches `defaultMultiplayerConfig` and the
ordering problem that caused this (config fixed at CREATE, seat count unknown until START)
no longer exists. **The form itself is still missing** — that is what this item is now.
- [x] **Multiplayer proper — Phases 0-4 done (v0.4.0 through v0.5.1), Phases 5-6 to go.** The
full plan is `docs/architecture/multiplayer.md` §12. Phase 2 (server core) landed in one pass:
- `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a
smaller, purpose-written replacement for reusing `replay.ts`'s `compress()` — that function
interns strings across a whole recorded array, which a live single-frame push has nothing to
intern against; only its one-step-back "null if unchanged" idea carried over.
- **Found and fixed a real bug tracing this**: `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 in `game.ts` with a guard;
tested in `multiplayer.test.ts`.
- The redaction test (§7) is built — `test/redaction.test.ts` — and passed on the first run
against the existing `snapshot()`, confirming it was already correct, not just apparently so.
- `src/server/session.ts` — the game session host (pure logic, no sockets, reuses `game.ts`'s
`Game`/`submit`/`currentActor`/`actionMenu` wholesale rather than re-deriving intent
application/narration). **Found while building it**: `submit()` derives the acting player from
`currentActor(game)` itself and does not check who is calling it — safe for `LocalSession`
(one possible caller) but not for a server, so the session host verifies `seat ===
currentActor(game)` itself before ever calling `submit`, rejecting with `NOT_YOUR_TURN`
otherwise. Idempotent resend (a repeat `seq`) and the illegal-intent path (checked via
`check()` directly, so a rejection never pollutes the shared narration log with "not allowed"
text meant only for the submitter) are both handled here too.
- `src/server/http.ts` / `src/server/index.ts` — plain `node:http`, no framework (confirmed
nothing to reuse and nothing else warranted — zero runtime dependencies anywhere else in the
project). `POST /api/game`, `GET /api/stream` (SSE, per-seat, with a 20s heartbeat and an
`id:` line per push), `POST /api/intent`, and static serving of `dist/` so the server can be
same-origin with itself (D16). No `gameId`/multi-game concept yet — one game per process,
matching "Phase 2 has no lobby."
- `src/web/session.ts` gained `createRemoteSession`; `main.ts`'s `start()` switches on `?seat=`
presence (D4 — one bundle, unchanged). Every `LocalSession`-only call site in `main.ts`
(`seed()`, `save()`, `undo()`, the New Game dialog) now goes through an `isLocal()` type guard
rather than assuming, since `session` can now be either.
- **Found and fixed a real infrastructure bug**: adding `test/server/` broke `npm test`'s glob.
`"test": "node --test test/**/*.test.ts"` relied on bash's non-globstar behaviour of passing
the *literal, unexpanded* pattern through to Node (which then globs it correctly itself) —
that only happens when the pattern matches *no* files at the shell level. The moment a
subdirectory existed, bash expanded it to just that one file, and `npm test` silently ran only
the new suite. Fixed by listing both depths explicitly:
`"test": "node --test test/*.test.ts test/**/*.test.ts"`.
- Verified two ways: `test/server/session.test.ts` exercises the session host directly (no
sockets); a live end-to-end curl 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, and the board correctly nulled on
the second push) — see the session transcript. **Not verified**: an actual browser — no
browser binary exists in this environment, so `RemoteSession`'s DOM-facing code
(`EventSource`/`fetch` wiring) compiled and typechecks but was not clicked through visually.
**Phase 3 (persistence/resumption) done, same session, 2026-08-21.** Per §12 steps 14-16 and
`lobby-and-sessions.md` §5-6 (unusually concrete — the exact storage shape was specified, not
designed here):
- `src/server/persistence.ts` — `game.json` (`{engineVersion, seed, config, playerNames,
history, status, createdAt}`) and `turn-timings.json`, both atomic-rewrite-then-rename, no
`gameId`/index yet (one game per process, same deferral as Phase 2's `gameId`).
- `game.ts` gained `fromMultiplayerSave` — `fromSave`'s multi-player sibling, built on
`newMultiplayerGame`. **Found while testing it**: `fromSave`'s replay loop calls
`record(game, result.events)` without the `actor` argument `submit()` itself always passes,
so every replayed line loses its "Player X" attribution — invisible for solitaire (nothing
ever compares a `fromSave` replay against a live-played log; `undo`'s rebuilt game is itself
`fromSave`-built, so the one test that compares logs only ever compares two unattributed
replays against each other) but 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** — not touched here, since it is used far more widely
(undo, save/restore, the replay viewer) and deserves its own careful pass rather than a
touch-in-passing. Worth its own TODO item if picked up.
- `session.ts` gained `exportSave()`, `resumeSession()`, and turn-timing tracking — a `TurnTiming`
span (player, phase, day, stage, start/end wall-clock) closes and reopens whenever the acting
player, phase, Day or Stage changes; recorded entirely in the session host, never touching the
engine (which must stay clock-free and deterministic) and never stored inside `history` (a
replay must reproduce a game from decisions alone). No reporting/aggregation/UI on this data
yet — §5 calls that "optional... if unobtrusive," and the Phase 3 deliverable is the data
being recorded, not a view of it.
- `index.ts` loads `game.json` on boot before starting the HTTP listener: version match →
`resumeSession`, replayed straight through; mismatch → refused explicitly and loudly (the
file is left untouched, so rolling the running version back recovers it), server starts with
no active game rather than replaying under the wrong rules.
- Verified live, matching this phase's own "done when": 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-it-is, correct narration attribution.
Separately confirmed the version-mismatch path: hand-edited `engineVersion` to a bogus value,
restarted, server logged the refusal and started with no active game (confirmed via `POST
/api/game` succeeding rather than 409ing).
**Phase 4 (lobby, sessions, reconnection) done, 2026-08-21 — v0.5.1.** Per §12 steps 17-20 and
`lobby-and-sessions.md` in full:
- `src/server/lobby.ts` — pure logic, no sockets, no filesystem, same split `session.ts`
already draws. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, a
speakable game code (`RAIL-4471` style, from a small railroad-word list rather than a
dictionary — read aloud across a table, not typed from memory), and the 2-4 player cap
(`playerCountAllowed`) — see the doc-fix note below.
- **The server now holds more than one game.** `persistence.ts` gained one directory per
`gameId` (`games/<gameId>/`) plus a top-level `index.json` naming every game, so `index.ts`
can resume all of them on boot rather than the one `game.json` Phase 3 assumed.
`writeGame`/`loadGame`/`appendTiming` needed no signature change — they already took a
directory directly.
- **Session tokens replace `?seat=&secret=` on the running-game routes.**
`lobby-and-sessions.md` §1: the token alone proves identity, so `/api/stream` and
`/api/intent` now read `?token=` and the join secret's job ends at the lobby door
(`/api/lobby/create`/`/api/lobby/join`). `web/session.ts`'s `createRemoteSession` takes
`(token, seat)` — `seat` still passed in rather than learned from a push, since it has to
answer before any push necessarily arrives, and the caller already has it from the
join/create/start response.
- **Bots fill empty seats at `Lobby.Start` only (D8)**, never mid-game. `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, no new bot logic.
`SavedGame` gained `botSeats: PlayerIndex[]` so a bot seat survives a restart.
- **Host rights pass to the earliest-joined remaining player** if the host's LOBBY connection
closes before start (`lobby-and-sessions.md` §2) — tracked via `Lobby.joinOrder`, a token
list rather than seat order, since a bot-filled seat has no join time of its own.
- **Disconnect/reconnect** (§5): `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, since a disconnect is transport news about a
connection, not a `GameEvent`. The page shows a banner naming who has dropped
(`renderPresence`, `main.ts`) and clears it the moment they reconnect. Reconnect itself needed
no new engine-side work: `session.connect(seat)` already sent a full un-delta'd `Frame`.
- **The client lobby** (`src/web/lobby.ts`, wired from `main.ts`'s `start()`): create-or-join
forms, a live seating screen (host-only bot toggles and Start button, updated over a new
`/api/lobby/stream` SSE), and `localStorage` in place of `?seat=` for "was I already in a
game" — found on load, reconnects straight to `createRemoteSession` and skips the lobby
entirely. A `Multiplayer` button beside `New game` is the entry point; the New Game dialog
itself is untouched, still solitaire-only, its old "needs a server" note repointed at the
new button.
- **Found and fixed while running the live smoke test, not by typechecking:** `/api/intent`
read its token from the JSON body, but `web/session.ts`'s `submit()` — unchanged from Phase
2 — sends it in the query string, same as `/api/stream`. Every request failed `no such
game`. Both sides independently typecheck fine (an HTTP body is `unknown` on the wire), which
is exactly why the curl-level smoke test exists rather than stopping at `tsc --noEmit`.
- **Doc fix:** `multiplayer.md`'s D18 said "player cap 6", citing `lobby-and-sessions.md` §2 —
which actually specifies 2-4 and gives the reasoning (what `test/multiplayer.test.ts` exercises).
The two had drifted apart; "6" was never implemented or tested anywhere. D18 now says 2-4.
- Verified: `test/server/lobby.test.ts` (pure logic — creating, joining, capacity, bot seats,
host transfer, starting) plus new coverage in `session.test.ts` (bot-driving, including two
bots in one game) and `web.test.ts`. A live smoke test through `curl`: create a lobby, join a
second player, start, submit intents from both (including the wrong-actor rejection and an
idempotent resend), reconnect after a real server kill-and-restart, a bot-filled coop lobby
starting and never stalling on the bot's seat, and a disconnect/reconnect presence notice
observed on an open stream. **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.
- [x] **~~THE BOARD STILL DOES NOT SAY WHICH WAY A HEAVY GRADE CLIMBS.~~** — done 2026-08-30 in
v0.7.9 (`cf018b4`). A brown wedge in the card's lower right rising toward the climb, with an
arrow lying along its slope. **It is Gitea#18 that made this drawable**: east is always to the
right on a single-row map, so a wedge can be read without a compass — under the wrapped layout
the same wedge would have pointed a different way at every seat. Orientation was measured
rather than assumed (400 seeds x 4 player counts, 535 grades, 0 without one, 276 east / 259
west). Original note below.
**The board did not say which way a Heavy Grade climbs.** RAR, twice: "grade should
tell you which way is up." `gradeUp` is dealt at setup and drives which of Helpers or
Brakeman/Airbrakes can ever pay, and Gitea#3 made it matter more — the modifiers now move a
train's STARTING REGION, so playing the wrong one is three Stages of climb instead of two. The
tooltip says it (`mainlineDescription`); the map did not. Untouched by Gitea#3, which was
about the rules rather than the drawing.
What is on the screen and where. Split out of Other 2026-08-22; the rules are elsewhere.
- [x] **~~INVESTIGATE: three explicit display options for the Office map — always hidden, always on,
auto-hide.~~** — done 2026-08-30 in v0.7.9 (`Next` #16). A segmented control, one button per
mode, `aria-pressed` on the lit one. The investigation below was right about the cause and
right about the fix; what it did not foresee is the testing trap.
**THE BUTTONS ARE ADDRESSED BY ID (`#dm-auto` / `#dm-open` / `#dm-closed`), NOT QUERIED OFF
THE CONTAINER — and that is worth knowing before the next control like this.** The web suite
runs against a stub DOM whose `querySelectorAll` reads the element's own `innerHTML`, so it
only ever sees markup THE PAGE WROTE. This control lives in `play.html`, so a child query
returns nothing there, the wiring loop does nothing, and the whole control ships green and
completely unexercised. Addressing by id also puts every button under the "asks the page for
no element its page lacks" check.
**A second gap closed on the way:** none of the five element factories in `test/web.test.ts`
had `setAttribute`, so the first render threw. Any control reporting its state through ARIA
was untestable until they got an attribute bag. Original note below.
**All three modes already exist.** `districtMode` is `'auto' | 'open' | 'closed'`, persisted to
`localStorage` with the sound and zoom settings (`main.ts`). Nothing needs adding to the model.
**What is wrong is that the button is a CYCLE, and it cannot reach every state.** The handler is
`districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto'` — so from `auto`
you land on whichever pin is the OPPOSITE of what auto is doing right now, which depends on the
phase, and every second press goes back to `auto`. You can never get from `open` to `closed`
without passing through `auto`, and which of the two you can reach at all changes as the game
moves between phases. That is why it does not feel like a setting.
**Likely three buttons or a three-way segmented control**, one per mode, showing which is
current — the label work is already done and is worth keeping: it says what pressing it DOES
("always showing — click for auto-hide") rather than what the panel is currently doing, which
was a deliberate fix and should survive whatever replaces the cycle.
- [x] **~~INVESTIGATE: the same three options for the Division map, where `auto` means something
different.~~** — **DECLINED 2026-08-30.** Jesse: "we are not going to hide the division map
anymore."
**Gitea#18 answered this one too, and it was missed.** It was raised 2026-08-22, four days
before that issue closed, and it is really a sixth member of the drawing pass settled below —
it just reads as a control question rather than a drawing one, so it stayed open when the other
five were closed. **The reason to fold the map away was that it grew.** A horseshoe of three or
a square of four was tall enough to push the board off the screen, which is what made "hide it
while I switch" worth a control. A single row is `boardH = PAD * 2 + CH + 30` — **150px, fixed,
at every seat count** (`board-svg.ts`) — and a 150px strip is not worth a control, three states
and a persisted preference. It also scrolls and zooms rather than reflowing, so it costs the
same at one seat as at four.
**What it would have cost, recorded because the design was worked out before it was declined:**
the Division's `auto` is not a third MODE at all. It is two sticky pins plus a transient — "hide
for now", expiring when `f.phaseKey` moves — so it needed `divisionMode: 'open' | 'closed'`
persisted beside `districtMode`, plus a `hiddenDuringPhase` that clears itself. Deliberately not
shared with `districtMode`, whose `auto` is a standing rule keyed on `FOCUS_PHASES`. **And a
folded Division needed a summary line written for it** (Jesse's call, 2026-08-30, before the
decline): `.folded` on `#district` hides `#grid` and `.districtrule` but keeps
`#districtsummary`, so the house idiom is that a folded panel still says something, and the
Division has no such line today.
**If it ever comes back, this is what to check first:** whether the map has started growing
again. That, not the control, is the thing that would justify it.
Original note below.
**Today it cannot be hidden at all.** `#division` is a plain `<div>` in an unnamed `<section>`
in `play.html` with no toggle and no fold rule — `#district` has `.folded` styling and a button,
the Division has neither.
**`auto` here is not the Office's `auto`, and that is the point.** The Office folds by PHASE
(`FOCUS_PHASES` — open during Local Operations and Cargo, folded otherwise). Jesse's Division
rule is "hide it NOW, and bring it back at the end of this phase": you fold the map away to get
room while switching or working cargo, and it returns of its own accord when you are done. So
it is a one-shot with an expiry, not a standing rule — the state has to remember WHICH phase it
was hidden during, and clear itself when `f.phaseKey` moves off that one. Different enough from
`districtMode` that sharing an implementation with it would probably be a mistake.
**Hidden and shown stay put** until pressed again, exactly as the Office's pins do.
- [x] **~~INVESTIGATE: the game's settings belong in a card, not along the top line.~~** — done
2026-08-30 in v0.7.9 (`Next` #28). A **This Game** card at the foot of the right-hand column,
folded by default and persisted with the other display preferences.
**The open questions below were answered by Jesse, 2026-08-30.** Which of the six stay on the
top line: Revenue, the objective and the game code — *plus the collision counts*, which were
not on it at all. Whether the card folds: yes, like `#district`, with a summary line that
survives folding. Where the collision counts belong: the top line, because a limit is a setting
agreed to once and "2 of 3 today" is a number that changes how you play the next Stage.
**Cheaper than this entry estimated.** It says the renderer already exists; what it misses is
that `configFromFrame` also already exists, is exported, and `main.ts` already called it three
times — so `rulesListHtml(configFromFrame(f), f.players.length, f.days)` needed no refactor
whatsoever. The card is markup plus one call.
**The collision counts were the real find.** The Frame has carried `collisionsToday` and
`collisionsTotal` since v0.7.0 and NOTHING ON THE BOARD DREW THEM, so the one victory condition
that ends a game early ran invisibly — the same shape as #43's `actingPlayer`, and the second
time in one release that the Frame had the answer and the view never asked. Original note
below.
**The original note.** Raised by Jesse
2026-08-23, playing the v0.7.0 build: "the game-specific information in the very top line should
probably be a card like Facilities, timetable or blocked. Off on the side, we can give complete
information about all the game options and not take up valuable real estate at the top of the
screen."
**What the top line carries today**, in order: Revenue, the objective (`#objective`), the seed
or seat (`#seed` — the seed in solitaire, `Seat 2` in a multiplayer game), the game code
(`#gamecode`, added 2026-08-23), the game type (`#gametype`, e.g. "Custom — scored as
Competitive"), and an abbreviation of the house rules (`#houserules`, "3 cards · 1/1/0"). The
last four were each added because the information was missing entirely, and the header is now
carrying them because it was the only place they had ever been put.
**What a card could say that the header cannot.** Jesse's list, plus what the Frame already
carries: seed and seat, the game code, the game type and what it is scored as, the opening hand,
all three revenue rates, the Day count, the combined-Revenue floor, BOTH collision limits (with
the running counts, which the Frame has as `collisionsToday`/`collisionsTotal`), where an Extra
may start, and every optional rule that is on. **Nothing new has to be sent** — `Frame` gained
`mode` and `optionalRules` in v0.7.0, and everything else on that list was already on it. The
read-only renderer already exists too: `rulesListHtml` (`settings-form.ts`) draws exactly this
list for the join preview and the seating screen, so the card is largely a matter of calling it.
**His own framing of the value**, worth keeping because it names when it is read: "To go, 'Oh
wait, what did we set that to?' They should be able to look that up, but it does not need to be
at the top every moment because it is not something that they're likely to need all the time."
**The open questions.** Which of the six stay on the top line — Revenue and the objective are
glanced at constantly and clearly belong there, the seed and the code almost never are. Whether
the card folds like `#district` does or is always open. Whether the collision counts belong in
it or beside the objective, since they are a live score rather than a setting. And it inherits
the one principle that outlived the wrapped-map items (settled below): **SHARED in the middle,
YOURS on the right.** A settings card is not yours — it is the table's — so if the common board
of Gitea#20 ever comes back onto this page, the settings card belongs beside it rather than in
the right-hand column.
- [x] **~~INVESTIGATE: the Fedora belongs at the right-hand end of the phase row.~~** — done
2026-08-30 in v0.7.9 (`Next` #29). Jesse's fallback was the one taken: `.tc-super` moved to the
end of the row, after the pills, and wrapping under them rather than squeezing the chips on a
narrow screen.
**The appealing version was tried and rejected.** Putting the name INSIDE the Supervisor Shift
pill reads as "this phase belongs to that player", which is not what the Fedora means — the
office holds the clearance ruling and starts every round, in every phase. Worth knowing if
anyone proposes it again.
**The row was looked at as a whole**, per the note left here at the time, because item #15's
"most recent action" line still wants space in the same strip. Nothing was taken; the row has
room for one more block at the widths tested.
- [x] **~~Five drawing items against the wrapped Division map~~ — ALL ANSWERED BY Gitea#18**
(closed 2026-08-26), which replaced the layout rather than fixing the drawing. They were:
the map drawing no track geometry; vertical track art on a card laid down a side lane; the
inter-row connector routed round the outside with angled corners; the Division Point captions
overflowing and the buffer stops pointing the wrong way once the route wrapped; and seating the
viewer at the bottom with the table wrapped around them. All five were the same pass, and
Jesse's instruction was to do them together — which is what made replacing the layout the
cheaper answer than drawing it five ways.
**What the single row settled.** The route is one line, west on the left and east on the right,
at every seat count (`board-svg.ts`). Both buffer stops face outward, so the two "wrong way"
reports cannot recur. The captions sit under their own Division Point rather than hanging off
the ends, so nothing clips. And **east is always to the right** — the report that decided it,
and the reason the horseshoe lost to a row that is 1,580px wide at four players against 842.
It is also what made the Heavy Grade wedge drawable at all (item above).
**The Division map no longer draws office-area detail**, so there is no Running Track on it to
draw turnout geometry for. That belongs to the Office map, which reads `cell.links` and already
draws it properly.
**Two things survive their items and are NOT closed with them:**
- **"SHARED in the middle, YOURS on the right"** — Jesse's principle from the dead-centre item,
and worth keeping as the rule that decides where anything new on the screen belongs, even
though a row has no centre to fill. It is now **Gitea#20**'s territory: the common board is
the shared thing, and it got its own display rather than a hole in the map.
- **Why seating the viewer at the bottom was refused**, since it will be proposed again: the
Division is a LINE, and the open gap between the two buffer stops is the only thing that says
so. Rotate the map to seat a viewer and that gap moves with them — sometimes behind them,
out of the eye's path, which is exactly where the one feature saying "this is not a circle"
must not go. Jesse called it definitively superseded, 2026-08-26.
- [x] **~~INVESTIGATE: history newest-at-the-top~~, and timestamps on it.** — the ORDER is done
2026-08-30 in v0.7.9 (`Next` #23); **the timestamps half is still open and still #14.**
Flat reversal, `scrollTop = 0`, the start marker last. Jesse ruled directly on the phase
headings, which the note below calls the thing that is not free: they now trail the lines they
announce, and "stage changes will be beneath (prior to / older than) the following events.
That is OK." Reading down the panel is reading backwards in time. Grouping by phase and
reversing the groups was offered and declined as more machinery than the complaint needs.
**The `slice(-60)` cap is untouched and is the other half of this note** — scrollback still
ends at sixty lines whichever way the panel runs. Worth reopening on its own if anyone ever
tries to scroll back and cannot.
`replays.ts` deliberately keeps its oldest-first log: it is paired with a frame stepper, where
"what just happened" is the step you have this moment clicked, so newest-first would fight the
stepping. Original note below.
**Raised by Jesse 2026-08-22.**
**Timestamps** are item 14 above — the same question, and it should be answered once. Whether
the history DISPLAYS a time is downstream of whether one is recorded at all, and of the sidecar
constraint written up there.
**Order.** Today `main.ts` renders `session.lines().slice(-60)` oldest-first and then sets
`log.scrollTop = log.scrollHeight`, so the newest line is at the bottom and the panel scrolls
itself down to it. Reversing is nearly free — reverse the slice, drop the auto-scroll — and it
does what Jesse wants: a glance at the top is always the most recent thing.
**Two things that are not free.** The cap is `slice(-60)`, so "scroll back through the history"
reaches sixty lines and stops however it is ordered; a real scrollback means raising or removing
that cap and deciding what the panel does with a thousand lines. And the log carries PHASE
HEADINGS (`t-phase`) that read forwards — a heading introduces the lines under it — so reversing
the list puts each heading below the lines it announces. That has to be handled or the panel
reads as nonsense at exactly the boundaries it exists to mark.
**Worth deciding together with item 15**, which proposes pulling the single most recent line out
of this panel entirely. If that lands, the argument for reversing the panel is weaker.
- [x] **Put rolling stock back into circulation.** The Classification Yard was write-only — seven
writers, no readers — so 37% of all rolling stock left the game by Day 5. Returning it at the
Day boundary is **+2.32 ± 0.52 (t = 8.79)**, the largest single change measured on this bot,
and it was ranked THIRD and predicted not to matter because the Division Yard never runs dry.
The aggregate was the wrong measure; having the right commodity at the right moment is what
counts.
- [x] **Make Enhancements reachable at all.** The bot never laid a straight on the Running Track
(0.00 in 100 games) because two-arc run-arounds do not need one — so 13 of the 18 Enhancement
cards had nowhere to go, including Interlocking, the only cure for the only penalty in the
game (`no free A/D track`, 27% of gross). One scored straight fixed it: enhancements placed
0.64 → 3.01, collision cost 2.70 → 1.91, worst game −47 → −24. Revenue +0.70 ± 0.74 paired
over 400 seeds — real but not significant alone; the variance reduction is the clearer win.
- [x] **Stop the bot discarding its own freight.** `canStockProductively` did not check the Division
Yard while the engine's `stockOutbound` does, so Freight Agent was chosen when nothing could be
stocked and the follow-through fell through to an unjam that threw a waiting load out of the
green box — 3.10 a game against 2.71 started. Now 0.00. Revenue 6.0 → 6.5, wins 5 → 8 in 100.
Also confirmed **routing was never the problem**: 0% of drops land on a facility that does not
want the car.
- [x] **Why switching work did not become Revenue.** Answered: it was the freight the crew shuffled,
not the shuffling. The chain never leaked — 95% of started loads finished — it was barely
entered, because a load needs a matching empty car spotted and half the industries never asked
for one. Three fixes later (sidings, facility placement, car selection) revenue is 3.2 → 6.0
and freight 26% → 37% of gross.
- [x] **Fix car selection.** Three of six industries were invisible to `wantedCars` — a hand-written
industry→car map naming two industries that do not exist and omitting three that do — so tank
cars were dropped **0 times in 100 games**. Derived from `INDUSTRY_PROFILES` now, and the
second commodity of the two-commodity industries is reachable. Revenue 5.0 → 6.0, freight
share 25% → 37%, wins 1 → 5 in 100.
- [x] **Put the industries on the run-around.** Facility placement was unscored — the first legal
square — so 0.00 facilities a game sat on a loop; now 1.08. The instructive part was the
second bug: scoring facilities onto the siding row dropped run-arounds 91→36, because the
anchor test asked a card's KIND rather than its PORTS and an industry in the line read as a
dead end. Revenue 4.1 → 5.0. Freight did **not** follow, which is the item above.
- [x] **Make the bot build sidings that are sidings.** 0 run-arounds in 100 games → 91. Three bugs,
all scoring on local shape without checking it reached anything; the decisive one was that
`bestTrackLay` never declined a piece, so it spent the track supply on whatever was legal.
- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction
decides which car is droppable; the bot runs around rather than setting out, when the drop can
follow. Switching activity transformed, revenue unchanged.
- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing
reached north, so a district could only be a vertical column. Now two-port rotatable arcs.
- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a
card when §6.2 was written; a 26-piece supply has no hand to bound it.
- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player
sets orientation", so it is a property of the placed card, not a compass constant.
- [x] **§6.2's reshuffle.** Implemented, and the `deckReshuffled` event it had already declared and
narrated — but never emitted or reduced — is now real. Not yet reached in play: solitaire
Campaign ends with 168.8 of 243 in the deck and four-player Campaign with 86.6, and no run of
any length has emptied it. It is a safety net rather than a live mechanic today, which is worth
knowing before tuning draw rates.
- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled
instead.
- [x] **~~CLEARING AN INBOUND BOX MINTS A CAR — measured at 1.29 a game against a supply of 80.~~
Re-audited in v0.4.3: rolling stock is EXACTLY CONSERVED, 100 games out of 100, range 0..0.**
The old audit's premise was right — the two directions were not symmetrical — but the asymmetry
has since been closed from the other end. `unloadBegan` and `passengersDetrained` now take their
replacement empty OUT of the Division Yard rather than conjuring it, so a load is a car that
moved rather than a car that appeared: one leaves the yard, one arrives in Classification.
Jesse's description of the tabletop procedure confirms this is the intended model — the token
you push along the MEN|AT|WORK sign IS the car, fetched from the yard by the Freight Agent and
swapped onto the industry track at the end.
Both conjuring fallbacks now **throw** rather than minting, so the leak cannot silently return;
neither fired across the suite or 100 audited games.
**`ROLLING_STOCK_SUPPLY` is therefore unblocked** — it was waiting on this and can now be tuned
in the rebalance pass. (The first audit's own arithmetic was off in the same way mine was on the
first attempt: cars set out on a card, in `card.standing`, are easy to leave out of the count and
make a conserved game look like a leaking one.)
- [x] **`state = fold(events)` was not true, and the docs said it was. Settled: the INTENTS are
canonical.** Measured before deciding — `advance.ts` never calls `reduce`, so **14 of the 46
event types are never reduced**: the clock, and the entire Mainline phase, which is every train
movement in the game. Folding the log rebuilds a district and not a railroad. Jesse's call, and
the cheap one: the plan never needed fold — persistence is `{ engineVersion, seed, config,
history }` (`multiplayer.md` §10) and the wire carries `Frame`s, not events (D2/D3), so
reconnection is a fresh Frame rather than an event tail. Making the phase driver reduce would
have been a rewrite of the most rule-dense code in the project to buy something nothing uses.
Corrected in the README, four architecture documents and six source comments; `test/events.test.ts`
pins the unreduced set so that closing the gap later is a deliberate act, and asserts the
property that does hold. **If you ever do make the phase driver reduce, that test fails and
tells you which docs now understate the engine.**
- [x] **Measure with error bars from now on.** Built: `node src/sim/compare.ts 1600 <tweak>=<n>`
runs the current bot and one variant over the same deals and reports the paired difference.
Pairing drops σ from ~9 on the level to **5.3 on the difference**, so 1600 seeds gives ±0.13 in
about 1m45s — the noise floor is now ~±0.15 rather than ±1.0. Threshold to keep a heuristic is
**t ≥ 3**, and the report prints the better/worse/identical split beside the mean, because a
mean carried by a skewed tail is a different claim from broad improvement.
- [x] **Confirm the Classification Yard rule against the source.** Confirmed, and the guess was
wrong. The rule is: used Rolling Stock to the Classification Yard, used engines and cabooses
straight back to the Division Yard, and the Classification Yard empties ONLY when the Division
Yard is bare — then all at once. The Day-boundary version I had invented was far more generous
and worth **+2.42 revenue a game the game does not actually grant**. Corrected; revenue 9.67
→ 7.25.
- [x] **Enhancements are placed but mostly do nothing.** Measured: forbidding every Enhancement
except Interlocking is worth **-0.01 ± 0.41 (t = -0.04)** over 400 paired seeds. They neither
pay nor cost. Left alone. Unlocking the Running Track straight put
nine kinds on the board (telegraph 0.73, waterColumn 0.57 …), but only Interlocking has a
measured effect. The Telegraph/Telephone/Radio chain adds to the other train's number when
dispatching facing trains, which may be worth nothing in solitaire; Water Column removes a
Watertower; Facing Point Locks prevents Derail, which is multiplayer-only. Worth measuring
what each is actually worth before the bot spends actions on them.
- [x] **`stats.ts` undercounts freight.** Fixed: both halves counted, freight share 39% → 49%.
Worth revisiting the **industry density** decision in Play Balance, which was taken on the old
number.
- [x] **LEFT AND RIGHT ARE ON THE WRONG DIAGONAL — for turnouts and for curves, the same way.** The
engine's `left` turnout is `{stem:'w', through:'e', diverge:'s'}`: a train entering at the
points from the west heads east and the diverging route leaves to its **right**. The engine's
`left` curve is arc `sw`, which turns an eastbound train **right** as well. One consistent sign
error in the hand↔diagonal mapping, and it mislabels every track card a player ever holds.
**The artwork is right and does not change** — `board-svg.ts:462` draws rails from
`connectionsFor()` geometry alone, so only words are wrong. **Decision: flip the `hand` value on
the `TRACK_CARDS` rows AND the two variant tables in the same commit**, so the code keeps
speaking left/right like the physical supply and now means it. Keep the **row order** in
`TRACK_CARDS` untouched: `setup.ts:95` builds the deck by iterating that array, so flipping only
the labels leaves pre-shuffle slot 32 holding a `ne_sw` curve before and after, and
`variantsFor(…)[0]` still `'sw'` — same seed, same board, and every published replay still
plays. Also: `track.ts:126` arc fallback, `track.ts:191-212` doc block, `view.ts:1041` and
`view.ts:1208` diagonal phrases, `bot.ts:837-852` `arcInHand`, seven test files, and the prose
plus ~14 `data-tip="Turnout · left"` strings in `docs/design/track-geometry.html` — which has no
generator and must be hand-edited. Verify by fingerprinting a fixed seed's board before and
after: identical geometry, different words.
- [x] **A turnout should be playable as an UPGRADE, on top of a card already down.** On a straight,
or on a curve whose arc matches the turnout's diverging leg. Nothing like this exists — the only
"upgrade" in the game is the Office tier change, which is explicitly not a card swap
(`apply.ts:1290`), and `canPlaceAt` hard-stops at `if (existing && !isMovableSign) return false`
(`track.ts:518`). **Decision: cars AND enhancements both block it** — `standing.length > 0` →
`UPGRADE_OCCUPIED`, `enhancements.length > 0` → `UPGRADE_ENHANCED`, so an Interlocked straight
stays a straight. Express the curve rule on **arcs, not hands**, so it survives the flip above.
No extra connection requirement: all four turnout orientations are port supersets of a straight
and of any same-arc curve, so an upgrade can never sever an existing join, and the new leg is
allowed to dangle — that is what it is for. The lifted card leaves play, which is already how
board cards behave (`apply.ts:1249` salvages only cards that were *not* placed). Reuse
`card.play` with a placement on an occupied square; `legal.ts:252` must offer those squares for
turnouts, and the `attachments` set at `legal.ts:137` is already exactly that list.
**The other half of this report needs no work:** a turnout carries the through route
(`carriesThroughTrack`), so it is already legal at a Limit, along the Running Track and on
Secondary Track. Confirmed, not re-investigated.
- [x] **A Depot shows three MEN AT WORK boxes it can never work.** Offices are Passenger Facilities —
no freight — but `setup.ts:176` gives every one a three-slot `menAtWork` array, and the three
renderers loop it with no guard while the green and red rows beside them *are* guarded
(`board-svg.ts:548`, `panels.ts:218`, `replay.ts:522`). **Decision: all three tiers** — Depot,
Station and Terminal are all Passenger Facilities and all get `laborers: 0`, so the boxes are
inert on every one. **Fix it in the model, not the renderers**, so it cannot reappear in a
fourth place: make `menAtWork` nullable and null for passenger facilities, which is what the
"Freight only" comment at `state.ts:153` has claimed all along. Freight handling must be neither
allowed nor displayed there. Two more artifacts of the same "an Office is a Facility" modelling
go with it: `board-svg.ts:562` calls a Depot **"SHIPS + RECEIVES"**, an industry's flow word,
and `board-svg.ts:599`'s `Math.max(1, trackCap)` draws it a siding slot although its industry
track has length 0. `FacilityView` needs a `kind` field; it has no freight/passenger flag today.
- [x] **Two Ice Houses can be built in one district.** Industries already ban duplicates per Office
Area — `isLockedOut` (`apply.ts:1774`) covers the Mine Tipple half of the report — but **Ice
House is a Modifier, not an industry**, and `checkPlay`'s modifier branch (`apply.ts:662`) has
no duplicate check at all. **Decision: extend the ban to modifier kinds, leave enhancements
alone.** An Interlocking is a plant at one junction, so a second on another straight is a
different installation, and the Telegraph → Telephone → Radio chain is already gated per card.
Expect modifiers with `copies > 1` to go partly dead in solitaire, where there is one Office
Area — correct, since the spare copies exist for other players' districts.
- [x] **A drawn card lands at the far end of the hand with nothing to mark it.** `apply.ts:1219`
pushes, the hand renders in state order (`game.ts:355`), and with `flex-wrap` the newest card is
exactly where the eye is least likely to be. **Decision: reverse in the display layer, not the
engine** — `view.ts:898-899` (reversing `hand` and `handWhat` identically, or they desync)
covers the replay viewer and the standalone replay together, and `game.ts:355` covers the play
page. Keeping `hand.push` means the bot's option-iteration order does not move, so every revenue
figure in this file stays comparable; an engine `unshift` would invalidate the lot. The marker
is play-page only — a `Frame` carries card names, not ids, so a replay cannot say which card
arrived that step. Follow the `game.scheduled` precedent for a `justDrawn` field, but make the
badge **persist** rather than flash: it says *which card is new*, not *something just happened*.
A static `::before` badge as `.handcard.target` does it (`panels.ts:262`), no keyframe — the
innerHTML rebuild would restart an animation on every render. Clear it in `renderUndo()` and
after `fromSave()`, or a fresh page load badges last session's draw.
- [x] **The inbound boxes render green in the side panel.** Green is outbound and red is inbound
everywhere the colour carries direction — `board-svg.ts:528-545`, `replay.ts:452`, rules §9.1 —
except `panels.ts`, whose shared `boxes()` helper (`panels.ts:162`) emits class `f` for every
filled box regardless of direction, so the red row at `panels.ts:222` comes out green. The board
SVG on the same page draws it correctly, which makes the panel actively contradict the board.
Give `boxes()` its class from the caller as `replay.ts` already does, add `.box.r` in
`board-svg.ts:820`'s palette, and take the siding off green in both panels and `replay.ts:525`.
- [x] **An Interlocking on the board is a bare label, and it does nothing.** The enhancement text is
drawn with no tooltip (`board-svg.ts:651`) while the copy already exists as data in
`ENHANCEMENT_CARDS` (`content.ts:589`). Done: every card prints its effect, and the one nothing
reads says so. **CORRECTED — the first pass had five of the ten statuses wrong.** It claimed
only Telegraph/Telephone/Radio were live, because the survey grepped for four helper function
names and read "no match" as "no implementation". In fact **seven are live**: those three plus
Interlocking (`advance.ts:770`), Yard Office (`advance.ts:750`), Small Yard (`apply.ts:405`) and
ABS Signals (`advance.ts:599`, stored on the Mainline node). Facing Point Locks and Water Column
are wired but dormant in solitaire; **Overpass alone has no code path at all**. The shipped
tooltip briefly told players four working cards did nothing, which is worse than the bare label
it replaced — `enhancements.test.ts` had passing tests for all four the whole time.
- [x] **A modifier's grant can land on a direction its host cannot use, and nothing says so —
corrected in v0.4.7, and the correction was itself half wrong.** *(v0.4.9e: the ORIGINAL verdict
was right about the Grocer's.* A Grocer's Warehouse **is** inbound-only — gameplay testing said
so and Jesse confirmed it — so the Ice House's outbound grant beside one is genuinely dead, the
way the Truck Dock's inbound grant beside the outbound-only Packing Sheds is. What survives from
v0.4.7 is the machinery and the decision behind it: an industry's printed flow is absolute, the
grant is dropped rather than the direction opened, and `suppressedGrants` says so on the page.
What does not survive is opening the two facilities up. Original v0.4.7 note follows.)*
The earlier "no bug" verdict below was wrong. The reasoning had been that
a Grocer's Warehouse is inbound-only. It is not — the card reference says "Both" — so the grant
was being dropped on a direction the facility should have had. Reported again in play as
"grocer's warehouse didn't get extra outbound slot for truck dock". The suppression machinery
itself was right and is kept: it still fires for a passenger Modifier beside a Whistle Post,
which is not a Passenger Facility — and v0.4.7 gives that grant BACK when the Office is
upgraded, which it never used to. Original note follows.
Reported as "Ice House added the laborer but not the outbound slot" — **checked, and there is no
bug**: Ice House prints +1 *outbound*, a Grocer's Warehouse is `flow: 'inbound'` so
`allows.outbound` is false, and the capacity was raised on a direction that can never render or
be stocked. The laborer arrived because laborers have no direction gate. **Decision: an
industry's printed flow is absolute** — drop the grant on hosts that cannot use it rather than
opening the direction up. So `applyModifier` (`apply.ts:1788`) gates each capacity grant on
`allows` and grows `industryTrack.length` only by what was actually applied. **Audit all 17
profiles for the same trap:** `iceHouse`, `truckDock` and `forklifts` all print `addOut: 1` and
list `grocersWarehouse`; `waitingArea`, `restaurant` and `hotel` print `addOut: 1` for
`hosts: ['office']`, which includes a Whistle Post. Then say it in both directions — the hand
tooltip naming which printed hosts cannot use which half (computable from the profiles, no host
on the board needed), and the panel's "prints N, Modifiers add M" line showing a suppressed
grant as suppressed instead of quietly omitting it. That delta display already cites the Ice
House as the bug that motivated it.
- [x] **Q13 — rear-end collisions on a Mainline card.** Answered: collide on catching up.
Implemented, and not on cards that print "trains may pass". Invisible to a bot that always
denies clearance; a bot that always allows drops from 7.34 revenue to **-5.13**.
- [x] **Nine of the twelve special-train rules are declared and read by nothing.** Done — all
nine enforced, and one of them deleted instead. `copiesNextScheduled` was never carried by any
train card: a Second Section is a Maneuver with its own working intent, so the flag was an
unreachable second description of an existing mechanic. Cost 0.8 revenue and half the wins
(8.0 → 7.2, 14/200 → 6/200), which is what enforcing restrictions does.
- [x] **Carry `links` forward in replay frames.** Done, and the premise was wrong in an instructive
way: measured, `links` was 5% of the `cells` payload. What actually cost was the `what` prose
(32%), the facility object stored a second time inside its own cell (24%) and the rest of the
static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test
runs the page's own unpacking function.
- [x] **Every published replay was dead.** All three replayed **2 intents of roughly 400** and
presented as short games, exactly as the item below it (now in Replay / Save Games) predicted.
Re-recorded from bot games with `node src/sim/save-replay.ts`, which verifies each save
round-trips before writing it, and `harness.test.ts` now fails if a published replay stops
short. The version-stamp item in Replay / Save Games is still worth doing — this catches the
breakage, it does not explain it to a player.
- [x] **Coordinate labels read Y,X on the board — v0.4.9.** Now X,Y everywhere a coordinate is shown
to a player: the on-card label (`board-svg.ts`), the switching-crew tooltip and button
(`main.ts`), the rejected-option and switching-group text (`game.ts`, `view.ts`), and the
blocked-move text (`narrate.ts`). Display-only — `GridCoord{row,col}` already had the right
geometry (row increases north, col increases east), and internal `Map` keys are untouched.
- [x] **"No switching" blocked moving a train clear of the mainline — v0.4.9.** The six no-switching
cards (both expresses, Light Engine, Campaign, Circus, Military) mean may not add or drop cars,
not may never be touched. `switch.move` now refuses only a move that would couple a fresh car —
the same way `dropOnly` was already handled — so these trains can still be shunted onto
Secondary Track to clear the mainline. `switch.dropCars` and `switch.sortConsist` stay blocked.
- [x] **Q3 corrected: Expedite governs WHERE a train may stand, not WHEN it leaves — v0.4.9.** The
forced same-Stage departure (`departsThisStage`, the `shiftChange` expedite pass) is gone; an
expedited train now arrives and is released like any other train, switchable in between. New
fault instead: left off the Office square when a Mainline Phase begins, it costs 1 Revenue
(`expediteFault`, `EXPEDITE_FAULT_PENALTY`), every Phase it is still caught there. Resolved
"3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE" as a side effect — it can now reach the Local
Operations turn its printed freight rule needs. Revealed a bot gap instead: logged above under
Bot Performance.
- [x] **`evaluateClearance` checked only the first occupant it found — v0.4.9.** Found while
explaining a playtest report: the Superintendent was asked to rule on a same-direction train
instead of being held against an opposite-direction one also on the card, because the loop
returned on whichever occupant it examined first rather than checking all of them — invisible
until the Telegraph/Telephone/Radio exception made it possible for a card to hold two trains at
once. Now checks every occupant for an absolute bar before offering any judgment call. Pinned
with a test that fails against the old single-pass code.
- [x] **The splash page now shows the box art — v0.4.9.** `docs/StationMasterSplashScreen.png`
(3.0 MB) resized to a 145 KB JPEG (`public/images/`, copied into the build by `build-web.ts`)
and placed beside the title, tagline, blurb and both buttons in a side-by-side hero, stacking
to image-above-text on mobile.
- [x] **A coach may now be set out at the Office — v0.5.0, Jesse's call.** §A.4's blanket "no Rolling
Stock may be left at the Office" now carries one exception: any train (not just 7/8) may drop
one or more coaches there; freight and cabooses stay banned. Unlocks the `ENGINE boxcar coach`
arrangement that used to lock completely — measured at 1,181 refused set-outs over 60 games,
every one of them this case. `canDropCarsAt` (`track.ts`) takes a `coachesOnly` flag instead of
refusing the Office outright; trains 7/8's `coachStaysOnStationTrack` rule now forbids the coach
everywhere EXCEPT the Office, rather than everywhere. The Office's "cars fouling the Running
Track" collision (`advance.ts`) is exempted for coach-only standing cars, so a legally parked
coach is not a hazard to the next arrival.
- [x] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — closed, v0.5.0.** Confirmed already resolved as
a side effect of the v0.4.9 Expedite fix (see that entry above); removed from Rules Questions.
- [x] **THE INDUSTRY TABLE VS. THE CARD REFERENCE — v0.5.0, Jesse's call: the engine was right, the
docs were stale.** `card-reference.md` printed Grocer's Warehouse and Oil Refinery at 2/2 with
2–3 Laborers, and claimed "'Freight House' is not a card"; the engine already had both
industries at 1 out/1 in/1 Laborer and already dealt Freight House as a real sixth industry, 6
copies. Corrected the docs (`card-reference.md`, `glossary.md`, `rules-v0.2.md`,
`open-questions.md`) to match the engine; no engine change. Turned up that Mine Tipple, Produce
Shed and Power Plant may be similarly stale — flagged as a new item above rather than assumed.
- [x] **Poling — closed, v0.5.0.** Confirmed already at 0 copies, the same treatment as Sharp Curves,
pinned by `mainline-cards.test.ts`. No code change; removed from Rules Questions.
- [x] **Heavy Grade orientation — asked again 2026-08-23, and the answer did not change.** Raised as
"did we ever fix Heavy Grade to allow user placement of direction?", with the option of giving
the choice to the **Superintendent** considered and rejected. Jesse's call: v0.5.0 stands.
Three things came out of the re-examination and are recorded in `implications.md` §10 Q11 so it
is not asked a third time — the advantage is permanent while the office rotates, so a rotating
chooser moves the fairness problem rather than solving it; there is no setup phase to ask in
(`createGame` is pure, and `pendingDecision` is typed for clearance alone across 19 readers);
and since v0.6.2 deals the Mainline deck without replacement, only **20%** of solitaire games
contain a Heavy Grade at all. **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; no code
change, and none wanted.
- [x] **Heavy Grade orientation stays rolled, permanently — v0.5.0, Jesse's call.** The card prints
"Player sets orientation", but a Heavy Grade sits on the shared Division chain between two
players (or beyond an end Division Point, next to one) — never inside one player's own district
— so there is no single player with a fair claim to the choice. Settled as random from the
seed, identically for solitaire and multiplayer, overriding the card's print. No code change
(the roll in `setup.ts` was already doing this); the comments and `implications.md` §10 Q11
previously framed it as a placeholder awaiting an interactive setup phase — corrected.
- [x] **Engines are not a separate supply from Crew Trays — confirmed, v0.5.0.** `rules-v0.2.md`:339
(Gap 4b) ties Crew Trays and engine pieces together as one combined resource, `player count +
3` — not two independently-tracked supplies. The engine already enforces exactly that via
`crewTrayCount`/`freeTrays`; `NO_FREE_TRAY` already fires exactly when engine supply would run
out too. No code change; corrected the comments that called this provisional or unsourced.
- [x] **Player settings, saved — v0.5.0.** A `localStorage` settings object (`SETTINGS_KEY`, separate
from the game save) now persists district auto-focus mode, sound on/off, and board zoom level
across reloads — all three previously reset every time. Falls back to today's defaults on a
missing, corrupt, or disabled `localStorage`, the same guard the save already had.
- [x] **The test suite's flakiness under `npm test` — fixed, v0.5.0.** Two changes: (1) a `pretest`
npm script now builds the shared `dist/` once, before `node --test` runs, so every test reading
`dist/` no longer depends on another test in the file having built it first; (2) the one test
that actually exercises the build COMMAND now builds into its own `dist-test/` directory
(`BUILD_DIST_DIR` env var, `scripts/build-web.ts`) instead of rebuilding the shared `dist/` out
from under the tests reading it. `dist/` is now single-writer.
- [x] **Curves and turnout diverging legs now draw as smooth curves, not two straight segments
meeting at a corner — v0.5.0.** `curvedRail` in `board-svg.ts` replaces the old hard-cornered
"run to the frog, then a straight 45° leg" with a sampled cubic-Bezier easement: tangent to
horizontal at the east/west edge (so an abutting straight card's rail still reads as one
unbroken line) and tangent to exactly 45° at the north/south edge (so two stacked curves still
read as one continuous diagonal). Both plain curve cards and a turnout's diverging leg go
through this same code path, so both are fixed by the one change.
- [x] **Wide boards can now be zoomed — v0.5.0.** Discrete zoom presets (75/100/125/150%) for both
the district grid and the Division map, applied by resizing the rendered SVG's own pixel
dimensions (not a CSS `transform`), so the existing `overflow-x:auto` scrollbars keep doing the
panning with no new gesture code. Persisted in the new settings object above.