The static site on the File Browser host was retired with the 0.4.9 line on 2026-09-29. README, TODO and the structure plan no longer describe a site deploy as the release step or the zero-dependency rule as what keeps the site a static upload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rwKTmug58sEsJ72AuWsEi
3782 lines
267 KiB
Markdown
3782 lines
267 KiB
Markdown
# 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*.
|
||
- **There is one line and one place the game is served.** The 0.4.9 playtest line was retired on
|
||
2026-09-29 (Jesse: "no longer being maintained"), and the static site on the File Browser host
|
||
with it: "the game is served on the StartOS service for station master." Releasing is the wrapper
|
||
bump and `make install`; there is no site deploy. `npm run deploy:web` stays in the repo only in
|
||
case a static host ever returns, and nothing depends on it.
|
||
- **The save check is the LAST thing before the tag, not a thing done during the work.** 0.8.2
|
||
replayed every server save, wrote "three resume, ten refuse" into its notes, and then put a card
|
||
into the deck — and shipped with zero of thirteen resuming. Replay the server's saves against the
|
||
exact tree being tagged (`tryResumeSession`, not `fromSave`).
|
||
- **A failing test written before the fix is the only thing that proves a fix.** Three releases
|
||
(v0.7.5 through v0.7.8) each reported the same bug fixed, and each fixed something real that was
|
||
not the reported fault, because every verification read what the SERVER served rather than
|
||
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** — CLOSED 2026-09-29: #39 #35 #42a #40 all confirmed at a table
|
||
2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #75
|
||
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
|
||
4. **The screen** — #44 #81 #33 #36
|
||
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
|
||
6. **Rules** — #12 #80 #82 #83 #85 #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
|
||
11. **The 2026-09-29 audit — what it found and did not fix** — #112 #113 #114 #115 #116 #117
|
||
|
||
Then, at the back: **Reference** (the measurements, rulings and rejected approaches behind the
|
||
items above) and **Done** (everything closed, kept because several of them are the only record of a
|
||
ruling or a lesson).
|
||
|
||
---
|
||
|
||
## Play it at a table
|
||
|
||
**CLOSED 2026-09-29.** Jesse: "The table test was completed." Every item and every checklist line
|
||
below was met at a table; the section is kept because the measurement in *Preparing the session*
|
||
(which interruptions fire by themselves and which have to be set up) is the only record of it.
|
||
|
||
The largest gap in the project, and none of it is a coding gap. Features are shipped, packed,
|
||
running on `phoenix.local` — and the items below name the ones no person has met at a board.
|
||
Everything else in this file waits behind a release; this waits behind an afternoon.
|
||
|
||
**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.
|
||
|
||
- [x] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is
|
||
the exact shape of the bug v0.7.9 fixed).
|
||
- [x] While one player has not voted, the other's turn chart says **who** it is waiting on (#35).
|
||
- [x] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and
|
||
does "waiting on Carol" still read once Carol is gone? (#35 — never tested.)
|
||
- [x] Reopen it. The history panel comes back **populated**, not empty, and the board is current
|
||
(the v0.7.9.5 reconnect fix, never seen by a person).
|
||
- [x] Vote yes. The extra Day begins and the official result is **unchanged** from when the
|
||
timetable ran out (#35).
|
||
|
||
**Game B — ordinary length, two humans, bots to fill.** Everything else.
|
||
|
||
- [x] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer
|
||
interrupts the Mainline Phase and asks a question mid-thought — is it legible, and does it say
|
||
which train? (#39)
|
||
- [x] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would
|
||
collide. The hold is offered out of phase (#39).
|
||
- [x] A **loaded Extra** is made up and run (#39 — the third of its three).
|
||
- [x] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye,
|
||
does the caption say who and what? (v0.8.0)
|
||
- [x] Find the speed that suits you and say what it is — it becomes the committed default.
|
||
- [x] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all.
|
||
- [x] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot
|
||
contradict itself (v0.8.0.2).
|
||
|
||
**Solitaire, five minutes, alone.**
|
||
|
||
- [x] Click through **every field** on the setup screen and confirm the dealt game matches what was
|
||
chosen (#42a).
|
||
|
||
**Whatever else happens.** The two bugs that came out of the 0.7.4-0.7.9 runs were both things
|
||
nobody set out to test. Write down anything that reads wrong, even where the rule underneath is
|
||
right — most of this release's defects were legible-but-wrong rather than broken.
|
||
|
||
- [x] **#39** — **CONFIRMED at a table, 2026-09-29.** Originally: **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
|
||
hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end,
|
||
packed, and running on `phoenix.local` — and nobody has met any of them at a board. **Two are
|
||
interruptions that stop the Mainline Phase and put a question in front of somebody
|
||
mid-thought**, which is exactly the kind of thing only play reveals. **Neither of those two
|
||
happens by itself — 0/10 games. See Preparing the session above for what to set up.** See
|
||
**Reference · #39**.
|
||
|
||
- [x] **#35** — **CONFIRMED at a table, 2026-09-29.** Originally: **Extended play has never been played at a real table.** It was verified over the HTTP
|
||
API, which renders no dialog — and when a human first reached it in a browser it was unusable
|
||
(fixed in v0.7.9). The multiplayer vote has still never been driven through two browsers: what a
|
||
second player sees while waiting, and whether "waiting on Carol" reads once Carol has closed her
|
||
laptop, are unanswered. **Needs a `days: 1` game — the timetable does not run out in five
|
||
Days, so extended play fired in 0/10 measured games.** See **Reference · #35**.
|
||
|
||
- [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**.
|
||
|
||
- [x] **#85** — **MOOT 2026-09-29** — the 0.4.9 playtest line is retired, so it is behind on every ruling
|
||
since and that no longer matters. Originally: behind on a rules ruling, checked rather than assumed.
|
||
See **Reference · #85**.
|
||
|
||
- [x] **#107** — **May a Small Yard put cars on the NOSE of the engine? YES** — raised by Jesse
|
||
2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says
|
||
a train there reorders "and puts the engine at the nose", `implications.md` says "any order,
|
||
INCLUDING cars ahead of the engine". The design notes won.
|
||
|
||
`switch.sortConsist` gained an optional `engineAt` (absent = the nose, so older saves replay
|
||
unchanged). The menu did NOT multiply: the engine is a separate short list offered against the
|
||
consist as it stands, so a four-car train has eight options rather than twenty, and a player
|
||
wanting both a re-order and an engine move spends two Moves. §8.2 needed no new code —
|
||
`badlyMadeUp` already holds a broken-backed train, and is deliberately direction-free, so a
|
||
PUSHING train (whole consist ahead of the engine) is fit to run. The button warns first, by
|
||
asking that predicate rather than copying it.
|
||
|
||
Labels read WEST TO EAST with the engine drawn as the board's own ◀ / ▶ arrow, because "front
|
||
to back" depends on which way the train points and the board has reversed east-facing consists
|
||
since v0.8.0. Each says `MADE UP, ready to leave` or `HELD at the Office: <why>`.
|
||
|
||
- [x] **#108** — **RULED AND CLOSED, 2026-09-23.** It stands: further table evidence supports
|
||
it, and part of the mid-game is players deliberately adding cars to clear the Division Yard so
|
||
the Classification refresh can happen. Originally: **The coach ratchet: every coach ends up in the Classification Yard and never comes
|
||
back.** RULED 2026-09-17 — *the rule stands, the game says so loudly* — and recorded here
|
||
because the ruling was made on one game's evidence and the balance question behind it is open.
|
||
|
||
§9.2 boarding discards the emptied coach into **Classification**; detraining draws a fresh
|
||
empty **out of the Division Yard**; §2.2 returns Classification only when the Division Yard runs
|
||
bare. Coaches therefore move one way only. **Measured over `whistle-6945` (3 Days, 539
|
||
intents):** 16 coaches in the Division Yard at setup, **0 from Day 2 Stage 8 to the end**, 15
|
||
in Classification — while the Division Yard held steady at 46-47 freight cars, so the refill
|
||
could not fire. From that point no passenger can board or detrain anywhere on the board, and
|
||
four of the twelve timetabled trains (1/2 Crack Limited, 5/6 Sparrow) carry nothing but
|
||
coaches.
|
||
|
||
The two changes that would break the ratchet were put up and declined for now: sending the
|
||
emptied coach back to the **Division** Yard instead of Classification (a one-line change to the
|
||
boarding reducer), or amending §2.2 to refill when the Division Yard holds no car of a NEEDED
|
||
type rather than only when bare. **Revisit with a second game's data** — one game cannot tell a
|
||
rule from a seed.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
- [x] **#46** — **DONE 2026-09-29 (v0.8.5).** The 36 it had regrown to are gone and
|
||
`noUnusedLocals` + `noUnusedParameters` are on in `tsconfig.json`, so the list cannot regrow.
|
||
The ten in `sim/replay.ts` were unused IMPORTS, removed without deciding #48 — that question is
|
||
untouched. Two dead bot functions (`wouldBuryTheEngine`, `strandedWantedCars`) were rejected
|
||
candidates left behind; `isLegal` and `restoreRng` had no callers. See **Reference · #46**.
|
||
|
||
- [ ] **#84** — Five test fixtures pinned a seed and meant "a game like this". All five broke on
|
||
Gitea#14 for that reason. See **Reference · #84**.
|
||
|
||
- [ ] **#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**.
|
||
|
||
---
|
||
|
||
## The 2026-09-29 audit — what it found and did not fix
|
||
|
||
Four reviewers read the engine, the server, the browser client and the sim/tests/hygiene, and every
|
||
finding was re-verified against the code before anything was acted on. v0.8.3 (engine), v0.8.4
|
||
(transport) and v0.8.5 (housekeeping) took the faults; these are the findings that were real and
|
||
were NOT fixed, each with the reason, so nothing quietly evaporates.
|
||
|
||
- [ ] **#112** — **The structure proposal.** `docs/plans/structure.md`: a route table with auth
|
||
wrappers for `http.ts`; five extractions and a `Selection` value for `main.ts`; `check` and
|
||
`reduce` split per phase; one `carCategory`; one `Push` type. Each names the test it makes
|
||
possible. Ordered by payoff; the first two are afternoons. Do the `http.ts` table before the
|
||
next route (#20's display stream).
|
||
|
||
- [ ] **#113** — **Server faults left as found.** (a) A second SSE connection from the same seat
|
||
shadows the first without ending it, and the old socket's close then broadcasts "disconnected"
|
||
for a seat that is still there — end the old response on replace, and only announce a close
|
||
when the closing response is the live one. (b) Seat tokens travel in URLs on `/api/intent`,
|
||
`/api/save`, `/api/session`, and the JOIN SECRET on `/api/lobby/preview?secret=` — every
|
||
reverse proxy's access log holds them; `lobby-and-sessions.md` §1 says keep them out. Move to a
|
||
header or the body (EventSource forces the two stream routes). (c) `gameCodes` is not seeded
|
||
from running games on boot, so a game that survived a restart finishes with `gameCode: ''` in
|
||
the index and the admin listing, and `freshGameCode` can reissue its code. (d) `/api/lobby/start`
|
||
mutates memory and tells every lobby watcher the game began BEFORE the writes; a failed write
|
||
resurrects the lobby on restart with a fresh seed. (e) Secret comparisons are `!==`;
|
||
`timingSafeEqual` costs nothing. (f) An admin can mint a claim for a lobby seat that `/api/claim`
|
||
then cannot redeem. (g) `everConnected` is never pruned on delete. (h) `body.config` from the
|
||
host is never shape-checked — a bad one wedges the lobby at Start with a 500 each time.
|
||
|
||
- [ ] **#114** — **Client faults left as found.** (a) Rules refusals and transport failures are
|
||
invisible: every `void session.submit(...)` discards the `false`, and `lobby.ts`'s `postJson`
|
||
has no catch, so a host who presses Start while the server restarts sits on "Starting…" until
|
||
a reload. (b) `lobby.ts` reads `localStorage` bare (four sites) where `main.ts` guards every
|
||
access — a browser with site storage blocked throws before any button is wired. (c) `claimSeat`
|
||
awaits with no try: a 502 leaves the lobby doors drawn and dead. (d) `build-web.ts` stamps
|
||
`sha-dirty` for every dirty build of one commit, so two dirty deploys publish byte-identical
|
||
module URLs and a returning browser serves stale modules against new HTML. (e) "New game" on
|
||
the results screen does `location.search = ''`, which the code elsewhere asserts is a no-op
|
||
when the search is already empty — the save is wiped and the player stays on the finished
|
||
board; `commitNewGame` has the `reload()` fallback, this button does not. (f) The animation
|
||
loop outlives the session: `leavegame` does not reset the step queue, so rejoining another game
|
||
runs the old game's steps against the new session until the first push. (g) Seven independent
|
||
HTML-escape helpers with differing coverage, none escaping `'`; no test feeds a display name
|
||
containing `<` or `"`. No XSS was found; the risk is the next helper.
|
||
|
||
- [ ] **#115** — **Engine drift left as found.** (a) `maneuver.flyingSwitch` is a weaker copy of
|
||
`switch.dropCars` — no `switchingRefusal`, no `engineAt` clamp, no `standingWest` handling — latent
|
||
at 0 copies, wrong the day the card is dealt. (b) Car category is spelled three times
|
||
(`acceptsCar`, `newTrainPhase`, `isFreight`/`carriesLoad`). (c) `redFlag.play` emits a
|
||
`phaseEnded` the reducer ignores — a no-op intent offered whenever the Emergency Toolbox is on;
|
||
either the toolbox or the intent is vestigial. **Needs Jesse.** (d) The hand limit is enforced
|
||
only on `draw.end`; `switch.end` and `freightAgent.end` let a `sixRandom` hand stay at six all
|
||
game, and `card.discard` is ungated by option. (e) `freightAgent.unjam` from an inbound box
|
||
returns the car `pooled()` but loaded — the same "coach that can never unload again" 0.8.1.0
|
||
fixed for `clearInbound`. (f) `mainlinePhase` iterates a snapshot of trays after `collide`
|
||
deletes some, so later per-train logic in that loop reads a dead tray; benign today. (g) A
|
||
`Map`-order dependence in candidate ordering that would not survive deserialising state from
|
||
JSON with a different key order — worth one comment in `legal.ts`.
|
||
|
||
- [ ] **#116** — **Test-suite faults left as found.** (a) `test/card-reference.test.ts` REWRITES
|
||
`docs/home-deck.md` and `mainline-deck.md` and then compares — when they are stale the test is
|
||
red AND the diff to inspect is already gone. Generate to a string and compare. (b)
|
||
`test/track.test.ts` asserts on wall-clock elapsed time (`< 5000 ms`) in the default suite.
|
||
(c) `test/sim.test.ts` pins 300 games to `seed: 1000 + i*7919` to reach the one where a tank
|
||
car is first dropped (#84's shape; it is the seven-minute suite that breaks). (d)
|
||
`test/web.test.ts` slices `src/server/http.ts`'s source text between two constant names. (e)
|
||
`multiplayer.test.ts` still hard-codes seed 4242 where the playtest line had a seed search.
|
||
(f) `package.json`'s `test/**/*.test.ts` only works because dash has no globstar and there is
|
||
exactly one nesting level; spell it `test/*/*.test.ts`.
|
||
|
||
- [x] **#117** — **RULED 2026-09-29 (Jesse): "accept the leak in coop. otherwise save only at end of
|
||
game."** Built the same day: `/api/save` answers `403 SAVE_AFTER_FINISH` to a Competitive seat
|
||
while the game runs and serves a Co-op or one-seat game at any time; the page's Save replay
|
||
button says so and stays disabled until the end. Originally: **`/api/save` hands every seat the seed mid-game — NEEDS JESSE.** The seat's own
|
||
save download returns `seed` while the game is active; in Competitive that is every rival's
|
||
hand and the deck order, the exact leak `game.ts` strips from the log. But a save without the
|
||
seed cannot replay, which is the whole point of a save. Jesse (2026-09-29): "We should discuss
|
||
before making changes on this one." Options on the table: serve the seat's save only once the
|
||
game is finished; serve it mid-game without the seed (a receipt, not a replay) and with the seed
|
||
once finished; or accept the leak in Co-op only. See **Reference · #117**.
|
||
|
||
---
|
||
|
||
## Reference — measurements, rulings and rejected approaches
|
||
|
||
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` (since 0.8.2 the tables are written
|
||
into `docs/home-deck.md` and `docs/mainline-deck.md` instead), with `test/card-reference.test.ts`
|
||
re-running the generator and failing when the checked-in file disagrees. All six sections are
|
||
there, and so is the honesty column: every Enhancement carries its `live` / `dormantSolo` /
|
||
`unbuilt` status, and the opponent-directed cards say plainly that none of them is dealt.
|
||
**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. The card tables (in
|
||
`docs/home-deck.md` and `docs/mainline-deck.md` since 0.8.2; `docs/rules/as-built.md` before) are
|
||
generated from `content.ts` and carries the industry table the game actually runs; the old file
|
||
keeps its SUPERSEDED banner, now pointing forward, and its numbers are read as what the v0.4.5
|
||
placeholder said. **The balance question the entry was really guarding is #70** (the rolling stock
|
||
supply and the uniform 1/1/1 industry model, both marked provisional in `content.ts`) — that is
|
||
where it belongs, and it is still open.
|
||
|
||
#### #111 — A full editorial pass over the player-facing documentation.
|
||
|
||
**Raised by Jesse 2026-09-23**, on reading the rendered guide and the rewritten wrapper
|
||
instructions that shipped in 0.8.2. The verdict was that both are dramatically better and that the
|
||
workshop is still visible through them: *"There is still far too much of the feedback from
|
||
playtesting, pointing directly into code, commentary about decisions made versus gaps… People
|
||
playing the game do not need reference to old, outdated source material. They just want the
|
||
rules."* Four things to fix, and they are separable.
|
||
|
||
**1. Take the section numbers out.** Measured on 2026-09-23: `rules.md` 11, `home-deck.md` 5,
|
||
`mainline-deck.md` 5, `components.md` 4, `quickstart.md` 0. They are two different problems wearing
|
||
the same notation:
|
||
|
||
- **References to a rulebook nobody has.** §8.1, §8.2, §8.3, §10, §2.2 and the §7 cited in the deck
|
||
documents do not resolve to anything published — they are the numbering of the prototype rules
|
||
document. `rules.md:456` is the worst of them: a section whose own heading is
|
||
`## §8.1 in practice`, named after a document the reader cannot open. `rules.md:203` quotes one
|
||
outright — `§6.2: *"If any of the Department decks is empty…"*`.
|
||
- **References that do resolve, but only by number.** §3.5, §4.2–§4.6 and §6 are real sections of
|
||
`rules.md`, which numbers its own headings 1–7. These are not wrong, they are brittle and
|
||
unfriendly: `home-deck.md:207` "see Rules §4.4" asks a player to go count.
|
||
|
||
**The renderer settles how to fix these.** `scripts/markdown.ts`'s `slug()` deliberately strips a
|
||
leading section number, so §4.6 has no anchor to link to — the target is
|
||
`rules.html#passenger-work`. A cross-reference therefore becomes a named link
|
||
(`[Passenger work](rules.md#passenger-work)`) and the heading numbers themselves come off. Do
|
||
that first: it is what makes the rest of the pass mechanical.
|
||
|
||
**2. Take the pointers into the repository out.** Four of the five documents cite
|
||
`src/engine/content.ts` by path — `home-deck.md:8`, `mainline-deck.md:8`, `rules.md:8`,
|
||
`components.md:10` — to vouch that a table is generated. The guarantee is worth keeping and the
|
||
path is not; say the tables are generated from the game itself. No `.ts`, `.md` or `docs/` path
|
||
belongs in a player's document. (`docs/design.md` is a developer document and out of scope.)
|
||
|
||
**3. Publish the card counts.** *"It will change as playtesting evolves and things alter, but the
|
||
current count should be listed here. That is crucial information."*
|
||
|
||
> **This reverses a standing ruling — #15a's "Deliberately NOT including card counts per
|
||
> category", Jesse's call of 2026-08-22 — and it is the same person reversing it.** Two years of
|
||
> that ruling are baked into the files and all of it has to come out: the banner at
|
||
> `home-deck.md:12` (*"Card counts are not published…"*) and the clause at `components.md:11`
|
||
> (*"per-category CARD counts are not published, because they move with play balance"*). Update
|
||
> #15a's reference entry so it does not read as still in force.
|
||
|
||
The staleness the old ruling guarded against is answered by generation, not by omission. Every
|
||
catalogue row already carries its own count — `copiesInDeck` on track and office cards, `copies` on
|
||
industries, modifiers, enhancements, Mainline cards and Second Section — so
|
||
`scripts/build-card-reference.ts` gains a **Copies** column and `test/card-reference.test.ts` keeps
|
||
it honest for free. Nothing is typed by hand, and the count cannot drift from the deck.
|
||
|
||
**The one hard part is which deck a printed count describes.** Since 0.8.2 the office cards dealt
|
||
depend on the house rule: a game opening on Depots pulls the four Depot cards, one opening on
|
||
Whistle Posts keeps them. A single number is wrong for one of those two games. Print the default
|
||
game — every district opens on a Depot — and say so where the office table gives its counts.
|
||
`deckComposition()` and `SOLITAIRE_DECK_SIZE` are the totals to reconcile against.
|
||
|
||
**4. Strip the build-status commentary, without deleting true information.** Measured lines:
|
||
`rules.md` 5, `home-deck.md` 4, `mainline-deck.md` 3, `quickstart.md` 2, `components.md` 0. The
|
||
distinction that matters:
|
||
|
||
- **Commentary about the project — goes.** `rules.md:6` "this document reports **executable
|
||
behaviour** and marks unimplemented material"; `mainline-deck.md:104` "**It is not implemented,
|
||
and never has been.**"; the `live` / `dormantSolo` / `unbuilt` vocabulary in `home-deck.md`'s
|
||
enhancement table (251, 265, 273, 276) — that is an engineering status column printed for
|
||
players.
|
||
- **The fact underneath it — stays, in the player's language.** A player does need to know that
|
||
the opponent-directed cards are not in the deck, that the Interchange does not sort cars, and
|
||
that Facing Point Locks and the Water Column do nothing to a solitaire opponent. Say what happens
|
||
at the table ("this card is not dealt", "this card has no effect in a solitaire game"), not what
|
||
the code has got round to. A pass that deletes the sentence and the fact together makes the
|
||
documents wrong rather than clean.
|
||
|
||
**Where this leaves #15a.** Nothing here reopens it — the tables are generated and that holds. This
|
||
is the editorial half that generation was never going to do.
|
||
|
||
|
||
### The 2026-09-29 audit
|
||
|
||
#### #117 — `/api/save` and the seed
|
||
|
||
Found by the server reviewer, verified: `http.ts`'s `/api/save` returns `session.exportSave()` —
|
||
seed, config, names, full history — to any valid seat token while `status === 'active'`. The comment
|
||
above the route says "every one of those moves is already on this player's screen", which is true
|
||
of the moves and false of the seed. `game.ts` strips the seed from the shared log for exactly this
|
||
reason ("handed each of them the whole future of the deal"), `test/redaction.test.ts` pins that a
|
||
Frame never carries it, and `/api/lobby/preview` refuses to send it. Jesse's question, which is the
|
||
right one: a save without the seed is not a save. The finished-game-only answer keeps the download
|
||
button honest (it works; it just waits for the end); the receipt answer keeps a mid-game download
|
||
possible but replays nothing; the Co-op answer accepts that a co-operative table has nothing to hide.
|
||
|
||
## Done
|
||
|
||
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
|
||
each group.
|
||
|
||
### Closed in the 2026-09-14 bot-tuning round (unreleased)
|
||
|
||
53. ~~**The bot did not know to bring an expedited train back to the station.**~~ — done 2026-09-14,
|
||
not by a heuristic of its own but as a consequence of planning the switching turn: the planner's
|
||
score charges an expedited train left away from the Office a full Revenue point, which is what Q3
|
||
charges. Over 400 paired seeds, 19 games drew expedite faults under the rule ladder and **none**
|
||
under the planner, worth +1.07 a game (t = 3.83) — the two worst cases had drawn 45 and 42 faults
|
||
in a single game. See `CHANGELOG.md`, 0.8.0.9.
|
||
|
||
### Shipped through v0.7.9.8, from the queue
|
||
|
||
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
|
||
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` (folded into `docs/home-deck.md` and `docs/mainline-deck.md` in 0.8.2) is
|
||
GENERATED from the same catalogues the engine instantiates from, by
|
||
`scripts/build-card-reference.ts` (`npm run build:cards`), and `test/card-reference.test.ts`
|
||
re-runs the generator and fails if the checked-in file disagrees. **Worth knowing:** a
|
||
hand-written replacement would have drifted the same way and for the same reason — nothing fails
|
||
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.
|