Found by Jesse playing v0.7.5 on phoenix.local: a browser that had ever held a multiplayer seat could not reach the new solitaire setup screen at all. start() checked a browser-remembered multiplayer session before ever looking at solitaire's own state, and a bare ./play.html load could not tell "clicked Play solitaire" apart from "reloaded mid multiplayer game" — the same problem ?lobby already solved for the door on the other side, never applied to this one. The door now links to ./play.html?solitaire, and start() treats that, an explicit ?seed=, or the setup screen's own ?hand= (written by every Deal) as proof this navigation means solitaire — checked ahead of the remembered-session lookup rather than only below it. 862 tests pass, three new. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
184 KiB
To do
Things worth coming back to. Anything noted here should either get done or get an explicit decision not to — the point is that nothing quietly evaporates.
Grouped by what kind of work it is — Next, Replay/Save Games, Bot Performance, Play Balance, Multiplayer, Display, Rules Questions, Other — and ordered within each by how much it is currently costing us. Display was split out of Other on 2026-08-22, when a session at the board produced seven items about the screen rather than the rules; the "most recent action" entry moved with it. Reorganized 2026-08-20 from a flat list; nothing below changed, only where it lives. Two duplicate entries (Heavy Grade orientation, the Local's coach) were merged into one each, and the industry-table item that had been sitting in a "these are all done" section without actually being done was moved out to Rules Questions.
Next
Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplayer below), in order:
Fix the New Train phase car-placement round— done, see Multiplayer below.Unify victory conditions across solitaire, competitive and coop— done, see Multiplayer below.Phase 2 of— done, see Multiplayer below.docs/architecture/multiplayer.md— server core
Queued 2026-08-21, from playing the StartOS build:
The lobby must offer every game parameter the solitaire New Game dialog does— done in v0.6.0.Decide what the four— done in v0.6.0:optionalRulesaresisterTrainsdeleted,employeeRotationimplemented, the other two were already live.Stop every release destroying every game in progress— done in v0.6.0, by replaying the save rather than comparing version strings.
Queued 2026-08-22, from playing on StartOS:
- Make "Games in Progress" readable — ON HOLD, 2026-08-29 (Jesse). StartOS 0.4.0.2 is expected to improve how action results are displayed, which is most of what makes this unreadable — so wait and see what the platform fixes before rewriting the action around a limitation that may be gone. Re-open it against 0.4.0.2 and re-read the output before designing anything. Reasoning in Multiplayer below.
- Give a player a way back into a game after losing their browser — a fresh browser is still locked out of a RUNNING game, even though the server knows who they are. Reasoning in Multiplayer below; needs Jesse's call on whether a token in a URL is acceptable. The lobby half of this was fixed 2026-08-23 — a reload while seated no longer orphans the chair, and a seat can now be given up rather than wedging the table.
Queued 2026-08-22, from a v0.4.9d gameplay-testing report (six bugs, forwarded by Jesse):
Two trains in one station answer to one button— done in v0.4.9e / the release below.A Freight House can unload the boxcar it just loaded; a platform can detrain the passengers it just boarded— done in v0.4.9e / the release below.The Grocer's Warehouse ships and the Refinery receives— done in v0.4.9e / the release below: both are one-way again.- PARTLY REPRODUCED: cars left behind when backing up over them — see Rules Questions below. Half of it turned out to be the second half of Gitea#17 and is fixed (2026-08-26): a train pulling out through a 45° leg left its own cut standing. The "I can later drive right through them" half is still unexplained and still needs a board from whoever filed it.
Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issues.
12a. Gitea#4 — confirm that extra trains start properly — done in the release below. Either
Division Point, the Interchange with a direction chosen there, a Control Point gated by a new
extraStart house rule, and the Superintendent's hold on the way out of the Interchange yard.
Reasoning in docs/rules/implications.md §7.
12d. Gitea#7 — coach counts on four train cards — done in the release below. 1/2 Crack
Limited 3 coaches → 2, 5/6 The Sparrow 2 → 3. A change to the cards, so Trains3.pdf and the
transcription in docs/rules/implications.md §5 keep the original numbers with a footnote;
src/engine/content.ts and docs/StationMaster-Home-Deck-v0.4.5.md carry what the game plays.
12c. Gitea#6 — players may not discard train cards — done in the release below. Timetabled
and Extra alike; the forced play falls out of the hand limit rather than needing a mechanism of
its own. Reasoning in docs/rules/implications.md §6.2.
12b. Gitea#2 — four porters, two passengers on the platform, and only one may be worked —
RULED AND FIXED in the release below, though not the way the report implies. 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 of them stranded in Classification. 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.
12e. Gitea#8 — the per-diem train could not couple a caboose — done in the release below.
ROLLING_STOCK_SUPPLY mints all six cabooses loaded: true because §2.2's "coloured is loaded,
white is empty" doubles as a PIECE COUNT there and there is no white caboose. X22 Pee-Dee, whose
whole card is "may only pick up MTs", read that literally and refused every caboose including the
one it was made up with — set it out and the train was stranded, which made it unplayable rather
than merely restricted. A caboose carries the crew, not freight, so it is never a load.
12g. Gitea#9 — a Timetabled train may be discarded — done in the release below, and it
SUPERSEDES Gitea#6 (item 12c above), shipped three days earlier. A Timetabled train may be tossed
face-up to a Department slot, where a rival may pick it up — which needed no machinery, since
that is where every discard already goes. An Extra still may not: it never joins the timetable,
so it can never be what jams it. On this line the Timetabled half is a New Game setting
(discardTimetabled, on by default), because Jesse's reasoning is about games run longer than
five Days; the 0.4.9 line takes the plain rule. Reasoning in docs/rules/implications.md §6.2.
12f. Gitea#10 — a dialog when the Day rolls over — done in the release below. "Hard to keep
track of time." Nothing on screen was wrong — the clock, the turn chart and the timetable all
said which Day it was — but a Day turns over inside the phases that run themselves, so it passes
between one click and the next, and the two transient signals the page had (the phase banner at
2.6s, the announcement flash at 4.2s) are both gone before a player reading the board notices.
A modal stops and waits, and carries the standings, the Days left and the combined target.
Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game
ends — the outcome panel is the thing to read then.
-
Watch the other players and the bots actually make their moves — raised by Jesse 2026-08-22, and settled 2026-08-29 as the harder of the two readings: "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 this is not the log-legibility fix. It is the ordered, per-action presentation of everyone else's turns — the same mechanism Gitea#20 step 4 specifies for the common board, routed to a player's own screen as well. Jesse: "this relates to issue #20 and will require a lot more thinking." Do not start it as a standalone piece; it wants designing with #20. Reasoning in Multiplayer below.
-
INVESTIGATE: stamp the history with wall-clock time — even if nothing displays it yet, so "how long did that turn take" can be answered afterwards. Reasoning in Replay / Save Games below. Half of it already exists server-side and is read by nothing.
-
INVESTIGATE: a "most recent action" line under the status block — above the Division map, saying what just happened in the same words the history uses. Reasoning in Display below; overlaps item 13 and should be decided with it.
15a. Build documentation FROM the implementation, starting with a card reference — raised by Jesse 2026-08-22. Reasoning in Other below. The prompt for it was finding train card data spread across five documents of three different vintages, one of them superseded.
Queued 2026-08-22, from a session looking at the screen rather than the rules. All Display below.
- Three explicit display options for the Office map — always hidden, always on, auto-hide. All three modes already exist; only the BUTTON is a cycle, and it cannot reach every one of them.
- The same three options for the Division map, which today cannot be hidden at all. Auto there means something different and useful: hide it now, bring it back at the end of the phase.
- Give every phase a visible beat. The automatic phases are not too fast — they are never
drawn at all, because
pumpruns them all before the page renders once. Turn the track art vertical on a Division card laid vertically— SUPERSEDED by Gitea#18 (Jesse, 2026-08-26). There are no vertical lanes any more: the Division draws as a single row, west to east.Run the inter-row connector round the OUTSIDE— SUPERSEDED by Gitea#18. No second row, so nothing to connect.The Division Point captions overflow the map, and the buffer stops point the wrong way once the route wraps— SUPERSEDED by Gitea#18. Nothing wraps; both ends face outward.Fill the dead centre of the Division map with the common board— SUPERSEDED by Gitea#18. A row has no centre to fill.- History: newest at the top? Plus the timestamps question from item 14, which lands here.
Put the viewer's own district at the BOTTOM of the Division map and wrap the table around them— DEFINITIVELY SUPERSEDED by Gitea#18 (Jesse's word, 2026-08-26). A single row and a table wrapped around the viewer cannot both hold, and the row wins: being able to rely on east meaning right is worth more than being seated at the table.
Queued 2026-08-23, from Jesse playing the v0.7.0 build on StartOS. All three were the same drawing pass as 19-21 and 24 above, and all three are answered by Gitea#18 rather than fixed:
-
The Division map does not draw track geometry at all— SUPERSEDED by Gitea#18. The Division map stops drawing office-area detail altogether, so there is no Running Track on it to draw geometry for. The geometry belongs to the Office map, which already draws it. -
CONFIRMED IN PLAY: the buffer stop points the wrong way at two players— SUPERSEDED by Gitea#18, with item 21. -
CONFIRMED IN PLAY: east is not always to the right.— FIXED OUTRIGHT by Gitea#18, and the reason it wins over item 24. One row means east is always to the right. -
INVESTIGATE: move the game's settings off the top line and into a card of their own — and show ALL of them, not the four that fit. Reasoning in Display below.
-
Put the Fedora at the right-hand end of the phase row, with (or in) the Supervisor Shift pill. Reasoning in Display below.
Queued 2026-08-25, from releasing 0.7.1 / 0.4.9g. Both are blocked on the two commits being made and pushed — they were still uncommitted when the session ended.
-
Close the Gitea issues by hand, each with a comment naming the commit that fixed it.— done, and done again for v0.7.2 / v0.4.9h (2026-08-26). #2, #6, #8, #9 and #10 carry their comments from the v0.7.1 release, including the pointer on #6 saying #9 superseded it. #3, #14, #15, #17 and #18 now carry theirs, each naming2ab25e3ande9683ccand the version each shipped in — and, where the fix was not what the report implied, the ruling that decided it: #15 was REVERSED on review (the placement is legal; what was confirmed is that no train crosses the gap), and #14's comment lists the ten unbuilt cards that are held out, so they do not vanish along with the issue.Keep doing this. Auto-closing leaves an issue with no record of which commit or which release answered it. The token and the API calls are in the workspace's
AGENTS.local.md. -
Bump the StartOS wrapper to 0.7.2.— done 2026-08-26 (1bfea8d). Submodule pinned tov0.7.2,current.tsat0.7.2:0, release notes rewritten in all five locales,README.mdandinstructions.mdupdated. No new version file and no migration. Verified:npm run checkclean andmake x86packs asv0.7.2:0.This is the first release that does NOT carry games in progress, and the release notes lead with it in every locale. A save is a seed plus the moves played; the deck going from 206 cards to 121 means a card id recorded under 0.7.1 refers to a different card or to none, so a save stops replaying at its first
card.play. There is nothing to migrate — those moves were made against a deck that no longer exists — and it fails safe:src/server/index.tsrefuses to resume a save the rules reject, names the move it stopped at, and leaves the file untouched, so an operator can put 0.7.1 back on to finish a game that matters. -
Tell the 0.4.9 playtesters their saves are dead, before they find out. The same deck change shipped as v0.4.9h, so every save filed before it — including the ones attached to Gitea#15 and #17 — stops replaying at its first
card.play, and any game a tester has in progress will be declined on restart. They fail safe and the files are kept, but nobody has been told. Worth a line wherever the tester build is announced, and worth knowing when the next bug report arrives with a save that will not load.
Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main only):
-
The second pass on the results screen — badges, and the brainstorm Gitea#16 asks for. 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: "maybe create badges for anything interesting that happened… there should be a whole conversation brainstorming session on what are the things that might be interesting for people to be aware of at the end of the game." The raw material is already being kept —
tally.trainsCompletedWithWorkis the switching-master join,longestStandis 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. Needs Jesse and a conversation, not code, to start. -
replay.tsprints a raw outcome enum, exactly as the results screen used to. Its summary line is`${o.result} — ${o.reason}`, which renders "loss — revenueFloor" — the same defect Gitea#16 was filed about, in the dev-side replay viewer rather than the playable page. The sentences now exist (panels.ts'sreasonSentence), but they are written against aFrameand the replay recorder has aGameState, so it is a small refactor rather than a one-line swap. Not done in v0.7.3 because nothing about it is player-facing and the change earns its own look. -
Extended play has never been played at a real table — but it now works on a real server. 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 reachedawaitingExtensionon Day 2 withofficial = { win, winner 0, daysElapsed }frozen at Day 1 (config.days) and votes[null, null]. Voting yes as seat 0 was accepted, the bot followed as designed, and the game returned toactivewithextraDays: 1and the official outcome unchanged. The tally rode the Frame to the client. Both test games were deleted afterwards; the box is back to Jesse's ownWHISTLE-4086and theFREIGHT-3230lobby.The save carry-over claim was also checked rather than asserted: phoenix held five saves before the update, of which
WHISTLE-4086resumed 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 the part the item is named for: humans, at a table. Nobody has sat down and played a game off the end of its timetable, and 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. Worth being the first thing the next play session does.
-
There is no per-Stage "this train did not move" signal, so "longest an engine sat on a siding" cannot be answered. Gitea#16 asks for it and the comment on that issue said
trainStoodStillwould supply it, "emitted per Stage, so a run of them is exactly the streak you describe". That is wrong, and was found only by readingadvance.tswhile building the tally: the event fires for a train whose profile setsstopEarnsPoint— the X18 Circus and nothing else — andtray.stopPointClaimedguarantees it fires at most once per train per game. A streak folded from it reads "1 Stage" for ever, which is what v0.7.3 built and then removed. What it would take: either a new event emitted per Stage per stationary tray (cheap to emit, but it is a lot of events for a statistic nothing scores), or sampling live state on the Stage boundary the waystats.ts's funnel probe does — which the tally cannot do today, because it folds a batch of events AFTERadvancehas already mutated past the moment they describe. Worth settling with the badge pass (#33) rather than on its own, since that is the only consumer. -
Bump the StartOS wrapper to 0.7.3, then 0.7.4.— 0.7.3 done 2026-08-29 (74aea24instation-master-startos). Submodule pinned tov0.7.3(45580d8),current.tsat0.7.3:0, release notes in all five locales,README.mdandinstructions.mdupdated. No new version file and no migration — the outgoing0.7.2:0'supwas empty,versions.md's common case, socurrent.tsbumped in place. Verified:npm run checkclean, prettier clean,make x86packs asv0.7.3:0. NOT installed on a box and not played — see #35.Unlike 0.7.2, games in progress survive this one, and both docs lead with it. No card data changed and the engine changes are additive, so every intent in a 0.7.2 save is still legal; proven rather than assumed by
test/harness.test.ts, which replays the three files inpublic/replays(all recorded under an older ruleset) and asserts every intent still applies.Bumped again to
0.7.4:0the same day (085b88b), pinned tov0.7.4, and installed onphoenix.local— verified there, not merely packed: the resume log showsWHISTLE-4086coming back with its 7 intents and all five new engine code paths present in the served build. Both tags are signed and pushed.Keep doing the whole sequence. Tag the app, fetch the tag into the wrapper's submodule, bump
current.tsin place (the outgoinguphas been empty every time,versions.md's common case), rewrite the notes in all five locales, updateREADME.mdandinstructions.md, thennpm run check/ prettier /make x86/make install.UPDATING.mdin the wrapper is the authority and has not needed changing. -
Gitea#13, #5 and #19 — three rules corrections.— done 2026-08-29 in v0.7.4 (5e34c73,2280276,19a6a47), wrapper085b88bas0.7.4:0, installed onphoenix.local. Each issue carries a comment naming its commit and what was ruled, per #30. Reasoning is inCHANGELOG.md; what matters here is what they left behind, below. -
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. Together with #35 this is now the biggest gap in the project: four features shipped without a table. -
A save from before v0.7.4 may not replay, and nobody has been told. The same shape as #32 but for the main line: the Red Flags intent changed shape, a make-up that was legal may now be refused, and a Yard Office arrival asks a question no older history has an answer for. It fails safe — the server declines the save, names the move and leaves the file untouched — and
WHISTLE-4086did survive onphoenix.local, so it is "may not" rather than "will not". Worth a line wherever the build is announced, and worth knowing when a bug report arrives with a save that will not load. -
The bot cannot use the half of Red Flags a human would. It takes the danger prompt unconditionally and still plays zero flags in 200 games, because the prompt needs a colliding arrival to coincide with holding the card. What it never does is plant a flag ON PURPOSE to buy a Stage for switching, which needs it to know it wants time — a notion it does not have. Reasoning and the measurement are under Bot Performance.
-
Solitaire must ask before it deals, the same way multiplayer's lobby already does.— done 2026-08-29 in v0.7.5. Jesse: "let the user choose their options like the start of a multiplayer game"; "asking first is the only path." A new#solitairesetupscreen inplay.htmlasks the full shared block — game type, starting hand, Extra start, revenue, victory conditions, optional rules — 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 now share onewireGameTypeBlock()/commitNewGame()pair instead of the dialog carrying its own copy. Reasoning inCHANGELOG.md.Played in a browser on
phoenix.local2026-08-29, and it found a real bug — fixed same day in v0.7.6. The splash's "Play solitaire" door landed straight in a leftover Co-op four-seat lobby instead of the new setup screen:start()checked a browser-remembered multiplayer session before ever looking at solitaire's own state, and a bare./play.htmlload could not tell "I clicked Play solitaire" apart from "I reloaded mid multiplayer game" — the same class of problem?lobbyalready solved for the door on the other side (D11), just never applied to this one. The door now marks its intent (?solitaire), checked ahead of the remembered-session lookup. Still not verified past that: nobody has clicked all the way through the setup screen's own fields and confirmed the dealt game matches what was chosen. Worth being an early item in the next play session, alongside #39's four unplayed v0.7.4 features.
Replay / Save Games
The replay viewer, the save format, and how a game gets shared.
-
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. -
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. -
fromSave's replayed narration loses "Player X" attribution — found 2026-08-20 building multiplayer Phase 3, not fixed there.fromSave's loop (game.ts) callsrecord(game, result.events)without theactorargumentsubmit()always passes it (game.ts's ownrecord(game, events, actor)—actoris what turns "Chose to draw a card" into "Player X chose to draw a card"). So a restored save, an undone game (undorebuilds viafromSaveinternally), or a replayed one all lose attribution on every line — invisible in solitaire because nothing ever compares afromSave-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", comparesundo'sfromSave-built log against ANOTHERfromSave-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 infromMultiplayerSave(multiplayer's version of this function, added for Phase 3) by passingactorthrough; not touched infromSaveitself 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. -
Review the standalone replay against the site's replay viewer.
node src/sim/replay.ts --seed 1234 --out replay.htmlwrites a self-contained HTML file; the site instead reads JSON saves frompublic/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. -
The yards are shown on the play page but not in either replay viewer. The Frame carries them, so it is a rendering job, not a modelling one.
-
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. -
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.
-
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.
Bot Performance
-
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:
redFlagsSetfires 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. -
THE BOT DOES NOT KNOW TO BRING AN EXPEDITED TRAIN BACK TO THE STATION — new in v0.4.9. The
expediteFaultmechanic (§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. -
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. -
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 > 2floor 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. -
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.
-
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.
-
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.
flyingSwitchis exempted by name in the reachability sweep insim.test.ts; deleting that line is the test that this is fixed. Same root cause as the item below. -
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. -
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.
Play Balance
-
FREIGHT GOT SCARCER WHEN THE MAINLINE WENT ONTO REGIONS, and nobody knows why yet. 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. -
THE CREW TRAY COUNT IS DUE A RE-EXAMINATION, and this is the change that triggers it.
players + 3was 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.
-
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).
-
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.
-
Superseded 2026-08-20 by the victory-condition redesign (Multiplayer) — kept for the measurements.
minCombinedRevenuereplaces 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. -
Not superseded by the 2026-08-20 victory-condition redesign (Multiplayer) — these two stay
houseRulesdials, separate from the newGameConfigvictory 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 inrules-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. -
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.
-
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
loadStartedpast 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. -
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. -
A blocked PASSENGER facility produces no impediment at all — FIXED.
impediments()(src/sim/narrate.ts) opened withif (!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 frompassengerRefusal, 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 theofficecard, so every passenger row would have readfacility 0,0next tomineTipple 1,-3; it is named by its tier now. -
The log lowercases the first letter of every narration it attributes to a player, so
EXTRA X18 started…renders asPlayer Solitaire eXTRA X18 started…(src/web/game.ts:1065,n.text.charAt(0).toLowerCase()). Harmless-looking and it hits every line that opens with an all-caps keyword —TRAIN 1 MADE UP,COLLISION,EXTRA. Found while playing the Interchange start; predates it. Wants a rule that leaves an already-capitalised word alone. -
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 incontent.tsand 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. -
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.
-
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.tsitem 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. -
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.
-
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 ofminCombinedRevenue, defaulting to3 × 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.
Multiplayer
Deferred while planning the server; decisions and reasoning are in docs/architecture/multiplayer.md.
-
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. -
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. -
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. -
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. -
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 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.
-
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;openingRollsis on theFramenow 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 ifstationmaster.<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. -
~
WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTERAnswered 2026-08-20: the collision floor, not the revenue floor. Traced16 STAGES OF A POSSIBLE 60?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).collisionsTodayis 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 predatesDEFAULT_HOUSE_RULES(v0.4.2) and most likely ran under what is nowLEGACY_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-measuremultiplayer.md§3's sizing table once the redesign lands, but expect similar early endings by design. -
Victory conditions unified across solitaire, competitive and coop— designed and implemented 2026-08-20. One shared, fully-configurable set ofGameConfigdials replacesLENGTH_PROFILES.target,VictoryCondition: 'firstToTarget'(confirmed dead — grepped, never selected anywhere in the codebase today) and the flatCOLLISION_FLOOR_PER_DAYconstant:| 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. -
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:enterPhaseresetsactorOffset = 0on entering the phase (advance.ts:172) andnewTrainPhasenever incremented it the wayplayerPhasedoes 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 offtray.consist.lengthinstead — it already counts placements toward that tray and resets per train with no new state needed. Test inmultiplayer.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. -
The redaction test (multiplayer.md §7) is more done than the plan suggests, but the exhaustive check is still missing.
test/multiplayer.test.ts's "the view shows one seat at a time" section (added earlier) already provessnapshot(s, ..., viewer)gives each seat its own hand, board, Revenue and impediments — tracedsnapshot()itself (src/sim/view.ts:1180-1219):handreads onlys.decks.hands.get(viewer),deckis a count, other seats' hands appear only as.length, andFrame's type has noseed,rngStateor card-id-dictionary field for anything to leak through by accident. What exists is all spot-checks, though — "this seat's Frame has the right hand length." What's still missing is the exhaustive one §7 actually calls for: serialize a seat'sFrameand assert it contains none of another seat's actual card ids and no deck order, so a future careless edit is caught rather than assumed safe. Doesn't need a server — buildable now againstsnapshot()and the existinggame()/playGameharness already inmultiplayer.test.ts. Held for now, 2026-08-20. -
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 anullthat 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 atLobby.Start— which would have shifted theplayerindex everyPlayerSessionrecords at join time and that/api/streamand/api/intentroute by, quietly handing a player somebody else's railroad. Tested intest/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"). -
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. -
THE FOUR— resolved in v0.6.0.optionalRulesARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHINGsisterTrainsis deleted: Q9 records that the Second Section card supersedes it, and that card is built.employeeRotationis implemented — the rotation is four lines inadvance.tsbecause 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. -
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. -
D19's switching-instrumentation still needs writing, once real people are playing. "13% for the bot" (
multiplayer.mdD19) was a one-off measurement, not code — nothing inbot.tsor 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 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
checkPlayanswers both categoriesNOT_IMPLEMENTEDand dealing them would make ~9% of draws reject outright. FlipopponentCardsInDeckinsetup.tswhen 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. -
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.
Display
- THE BOARD STILL DOES NOT SAY WHICH WAY A HEAVY GRADE CLIMBS. RAR, twice: "grade should
tell you which way is up."
gradeUpis 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 does 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.
-
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. -
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. -
INVESTIGATE: three explicit display options for the Office map — always hidden, always on, auto-hide. Raised by Jesse 2026-08-22.
**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. -
INVESTIGATE: the same three options for the Division map, where
automeans something different. Raised by Jesse 2026-08-22.**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. -
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. -
INVESTIGATE: the game's settings belong in a card, not along the top line. 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 interacts with the Display items about the right column and the middle of the Division map (item 22): if the shared board moves to the centre, the right column is "yours", and a settings card is not yours — it is the table's. -
INVESTIGATE: the Fedora belongs at the right-hand end of the phase row. Raised by Jesse 2026-08-23, the day after it was added: "on the next row down, we recently added Superintendent and saying who's got the Fedora. That information should be all the way on the right, where currently it says 'Supervisor Shift'. Would it be possible to say 'Supervisor Shift — and then the player name, who's the current supervisor'? If not, just moving the Superintendent and the Fedora graphic to the right-hand side of Supervisor Shift is probably a better place for that."
**Where it is now.** `turnChartHtml` (`sim/turnchart.ts`, shared by the play screen and both replay viewers) lays out four blocks in a row: the Day/Stage/clock, the phase, "waiting on <name>", then the Fedora chip (`.tc-super`), and last the `<ol class="tc-phases">` of five phase pills. So the Fedora sits in the middle of the row, immediately before the pills. **Putting the name IN the Supervisor Shift pill is the appealing version and needs thought.** The pills are a WHERE-ARE-WE indicator — each lights violet while its phase is running and dims once it is done — so a name inside one may read 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). The pill is also the one whose tooltip already explains the hat passing every third Stage, which is why it is the natural home. Worth trying both and looking at them. **The fallback Jesse names** — move `.tc-super` to the end of the row, after the pills — is a two-line change and safe. Neither should be done without looking at the row as a whole: it is the most-glanced-at strip on the page, and item 15's "most recent action" line wants space in the same place. -
THE DIVISION MAP DRAWS NO TRACK GEOMETRY — a turnout is indistinguishable from a straight. Reported by Jesse 2026-08-23, playing the v0.7.0 build on StartOS: he upgraded a straight on his Running Track to a turnout and "did not see the division map on my side updated. And in the opponent's screen, they did not see any update in their division map either."
**It did update. The whole visible change is one word.** Measured rather than reasoned about — a real two-player game played out ~260 turns, then a running-track straight upgraded to a turnout, diffing the drawn SVG on both seats: ``` own division view : CHANGED other's view : CHANGED drawn SVG : CHANGED removed: <text class="bs-name" ...>straight</text> added : <text class="bs-name" ...>turnout</text> ``` **Why.** `divisionSvg` draws the SAME generic rail on every Running Track cell — `rail(c.x + 6, c.y + 32, c.x + c.w - 6)`, unconditionally — and prints the card's name under it. So a straight, a curve and a turnout are pixel-identical on the map and only the caption differs. `officeSvg` does it properly for the district grid: it reads `cell.links` and draws the real geometry, 45° legs included. **The data is already there and unused.** `RunningCardView` carries `links: connectionsFor(card)` — the same field `officeSvg` draws from — and `divisionSvg` never reads it. So this is a rendering change with no plumbing behind it. **What is NOT wrong, checked at the same time:** a card laid anywhere BELOW the Running Track correctly changes nothing on the map — the map is the through route between the Limits, and district interiors live in the Office Area grid. Of 4,000 bot steps in a two-player game, all 15 running-row plays changed both players' maps and none of the 19 below-the-row plays did. The delta is not dropping anything: `deltaFrame` JSON-compares `division` and sends it whenever it differs. **Do it with items 19, 20, 21 and 24** — Jesse's instruction, 2026-08-23. Drawing real geometry is the same pass as turning that geometry through 90° down a side lane, and both are wasted work if the map is later rotated to seat the viewer at the bottom. -
INVESTIGATE: turn the track art vertical on a Division card laid vertically. Raised by Jesse 2026-08-22: a side lane "looks like a bunch of disconnected left-right tracks stacked one on top of another instead of looking like a continuous track."
**It does, and the cause is one line.** `divisionSvg` draws every cell's rail with `rail(c.x + 6, c.y + 32, c.x + c.w - 6)` — horizontal, unconditionally, whatever side of the table the cell was laid on. **`railV` already exists**, is already used for the connector between stacked cells, and takes the same shape turned ninety degrees. So the cell needs to know which lane it is in (`dir[i]` is `'top' | 'right' | 'bottom' | 'left'`, known at layout time and currently not stored on the cell) and pick the one that matches. Everything else inside a side cell — the name, the capacity line, the train chips — is laid out horizontally too, so this is bigger than swapping one call: it is deciding whether a side cell is a rotated card or a differently-arranged one. Worth a sketch before any code. -
INVESTIGATE: run the inter-row connector round the outside, as rail, with angled corners. Raised by Jesse 2026-08-22. CONFIRMED IN PLAY 2026-08-23, and it is worse than a drawing complaint: "as a train traverses the board in a multiplayer game, east is not always to the right. Sometimes, for train direction, east might be south, west, or north as it traverses the different players." The route wrapping through the lanes is exactly this, and a player reading direction off the screen is being told the wrong thing — not merely an ugly corner.
**Where it comes out is wrong.** The turn between two sides of the table is drawn from `a.y + CH` — the BOTTOM edge of the last cell in the top row — across to `b.y`, the TOP edge of the first cell in the next. So the route leaves the top row's rightmost Limits downward out of its underside, when it should leave from its right-hand side; and on the bottom row it enters through the top edge when it should come in from the right. Jesse: "it should be on the outside circumference of the display." **What it is drawn WITH is wrong too.** That connector is a single `<path class="bs-turn">` — one plain polyline — while every other join on the map is `rail()` or `railV()`, two rails with cross ties. The connector between two stacked cells down a side already uses `railV`, which is why the vertical run looks like track and the corners do not. **And the corners want an angled piece**, Jesse's wish-list item: a 45° rail between the horizontal and vertical runs rather than a right-angled elbow, matching how the district's own track geometry works (everything leaving a card's north or south edge does so at 45°). That would want a `railD` beside `rail` and `railV`. All three are the same drawing pass and should be done together. Note the current elbow routes through `(ay + by) / 2` — the middle of the board — which is the space the item below wants to fill, so these two interact. -
INVESTIGATE: the Division Point captions overflow, and the buffer stops point the wrong way once the route wraps. Raised by Jesse 2026-08-22. CONFIRMED IN PLAY 2026-08-23 at two players: "the eastern division point has the end marker to the right, so it overlays where the track is into the person's area, instead of off the left at the actual end of the track." The stop is drawn past the cell's right-hand edge whatever lane the cell ended up in, so at a wrapped route it points back into the board — over a district, not away from the line.
**The captions.** "west end · in and out" and "east end · in and out" are centred under their own cell at `x = c.x + c.w / 2`. That was a deliberate fix — they used to hang off the outside of the board and print clipped mid-word — and it works for a single row. It does not survive a wrap: a Running Track cell is 78px wide and the caption is not, and `boardW`/`boardH` are computed from CELL extents plus `PAD = 22`, never from the text. **The viewBox does not know the captions exist**, so any caption wider than its cell plus the padding is outside it. This is the same class of bug as the one the comment above the code says was already fixed once; the fix solved the one-row case. **The buffer stops.** Both are drawn on the HORIZONTAL ends of their cell — `first.x - 9` and `last.x + last.w + 9` — regardless of which side of the table the cell was laid on. With one row that is right: the line ends to the left and to the right. Wrapped, the East Division Point can end up on the bottom row or down a side, and its stop still points right, into the board rather than away from the route. Jesse's read is that it belongs at the top there. The correct rule is probably "away from the neighbour it joins", which is derivable rather than a special case — and it depends on the vertical-card question above, so do that one first. -
INVESTIGATE: fill the dead centre of the Division map with the common board. Raised by Jesse 2026-08-22: with three or four seats the map is a ring with a large empty middle, while the timetable sits in a column on the far right.
**The proposal, in his words:** move the timetable into the middle; maybe the yards, "because that is too applicable to everybody"; maybe the Department and Salvage decks as well, since those apply to all players. "Then the Division map becomes the common board area. The right-hand side — your move, your cards, your facilities, what's blocked — is your side of things." **That is a genuinely good division of the screen** and it names a principle the layout does not currently have: SHARED in the middle, YOURS on the right. Worth writing down as the rule even if the move itself is deferred, because it decides where anything new belongs. **What makes it awkward.** The middle is only empty at three and four seats — at one seat there is no middle at all, and at two the rows face each other across a gap the width of `SIDE_GAP`. So whatever goes there needs somewhere else to live at low seat counts, which is close to building both layouts. And the map is an SVG built by `divisionSvg` while the timetable, yards and decks are HTML panels (`panels.ts`), so "put them in the middle" means either foreign-objecting HTML into the SVG or positioning HTML over it — neither free, and the map is embedded into the replay by `toString()`, which constrains what it may reach for. -
INVESTIGATE: seat the viewer at the bottom of the Division map and wrap the table around them. Raised by Jesse 2026-08-22: "consider that the player being displayed is always at the bottom of the display, and the rest of the table is wrapped around. This would give something of a feel of sitting at an actual table with my cards in front of me and the other players in front of me as well." His own note: not a decision, something to think about.
**It needs no new data.** `divisionSvg` already takes `roster.viewer` — "the player this map is being drawn for" — and `main.ts` already passes it. The layout is a fixed lane order, `top → right → bottom → left`, filled in ROUTE order west to east; rotating means offsetting which lane the first seat lands in so the viewer's own district comes out on `bottom`. That is an index shift in one array, not a new layout engine. **The strongest argument for it is already in the code.** The map marks your district with a colour AND spells out "(you)", and the comment says why: "a colour alone cannot say which of four railroads is the reader's, and that is the first thing anybody wants to know at a table they just sat down at." A fixed position answers that structurally — you would know before reading anything. The marker stays as reinforcement rather than being the only signal. **What it costs is the other thing the map says.** The section is headed "The Division — west to east" and the route is a LINE, not a loop: it starts at the West Division Point and ends at the East one, with buffer stops at both and a deliberately open gap between them. Today that line starts top-left, where a reader starts reading. Rotate it and west starts wherever your seat put it — and the open gap, which is the thing that stops the ring being read as a loop, moves with it. Sometimes it would land behind you, out of the eye's path, which is exactly where the one feature that says "this is not a circle" should not be. **So the question is which of the two the map is FOR**, and it may not have the same answer at every seat count. At one seat there is nothing to rotate. At two it is a swap, and free. At three, rotating changes which way the horseshoe opens. At four it moves the break in the square. A rule like "rotate at three and four, leave one and two alone" is defensible but has to be decided rather than fallen into. **One caller has no viewer at all**: the site's replay viewer calls `divisionSvg(f.division)` with no roster (`web/replays.ts`). A replay watches every seat and belongs to none, so it has nothing to rotate around — the feature has to degrade cleanly to today's layout there, which is an argument for building it as an optional rotation rather than as the layout. **Same drawing pass as items 19, 20 and 22** — vertical track art, the corner connectors and the dead centre. All four move cells around the ring or change what is drawn inside them, and doing them one at a time means laying the map out four times. -
INVESTIGATE: history newest-at-the-top, and timestamps on it. 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.
Rules Questions
-
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. -
An unload does not check the facility's commodity.
laborer.beginUnloadgates onallows.inbound, a loaded car, an empty of that type in the Division Yard and room in the red box — but never onfacilityCarTypes(f), whichfreightAgent.stockOutbounddoes 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.
Other
Doesn't fit the above.
-
THE DECK IS
docs/Deck cards5.xlsxEXACTLY, 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. -
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-cardsnow searches for a Division holding a single-track card,multiplayerfor a game that reaches Day 3,webclicks every play verb rather than assuming the first one goes on the board, and the twosimcommodity samples were re-measured (tank is first set out at game 216 now, unload Revenue at game 46). Thewebfix is on BOTH lines — it broke onmainat 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. -
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
playtestbranch. Almost none of it does — that line has no lobby and no server, and theundo()config fix is inert there because itsGameConfigcarries no victory dials, soconfigWithonly 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. -
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/StationMaster-Home-Deck-v0.4.5.md(a readable per-card table, a version-stamped snapshot),docs/rules/implications.md§5 (the transcription ofTrains3.pdf, deliberately frozen at what the design SAYS),docs/Trains3.pdf(the artwork), anddocs/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.** 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. Not started. Worth deciding first whether this is a build step writing Markdown into `docs/`, or a page on the site beside the replay viewer — the site can render it from the same view-model the game uses, which argues for the second. -
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'sspeechSynthesis, 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) andcrash(§10 — a collision) are now synthesised too, v0.4.9 — three chuffing/screeching cues built from the same oscillator-and-filtered-noise toolkit asstage, wired totrainArrived,trainHighballed(Office departures only), andtrainsDestroyed. 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. -
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: -
entryPointsis 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. -
card-reference.md's industry table may still be stale beyond Grocer's Warehouse, the Oil Refinery and Freight House (corrected v0.5.0) — Mine Tipple, Produce Shed and Power Plant were NOT re-verified. The v0.5.0 pass corrected three rows (and the "Freight House is not a card" claim acrosscard-reference.md,glossary.md,rules-v0.2.mdandopen-questions.md) on Jesse's explicit call. Checkingcontent.tswhile making that change turned up thatmineTippleandpowerPlantare ALSO base 1 out/in + 1 Laborer in the engine — the same uniform model as the three that were corrected — whilecard-reference.mdstill prints Mine Tipple 3/3/4 and Power Plant 3/3/4, and the "Throughput — why these Laborer counts" section right below the table is built entirely on those higher numbers. Flagged inline incard-reference.mdrather than silently rewritten — this needs the same kind of decision Jesse made for the other three, not an assumption that the same correction applies, since raising or lowering Laborer counts is also a balance question, not only a docs one.**v0.4.9e narrows it.** The Direction column for the Grocer's Warehouse and the Oil Refinery was the wrong half of that v0.5.0 pass and has been put back to one-way each, from gameplay testing and Jesse's confirmation. The *numbers* in those two rows are still the card reference's own (1 Laborer, 3–4 track) and still unverified against the engine, so this entry stands as written for all five industries — what changed is only that the two rows the v0.5.0 pass claimed to have re-verified turn out to have been re-verified against a premise rather than against a card.
Done, kept for the reasoning
-
Gitea#15 — a rail may stop dead against its neighbour, and the rule is on MOVEMENT. Filed 2026-08-25 as "track placements must connect": a right-hand curve had been laid with its north leg against an Ice House and the turnout below pointing at its portless south edge, and the report called that illegal. RAR reversed it on review (2026-08-26) — the placement is fine, and a stub like that has a use, as a siding to park cars on. What he asked to confirm instead is that no train can traverse the gap.
It could not, and cannot:
exploreMovesgates every hop onjoins, which tests both ports AND that two 45° legs lie on the same diagonal — never a bare pair ofhasPortcalls. That was already true;track.test.tsnow pins it against the reported geometry, including the check that the curve IS reachable from the side that joins, so the negative test cannot pass on a card that is merely unreachable.A per-edge placement check was written and then taken out, along with the matching guard on
checkTurnoutUpgrade. Both are documented in place as deliberately absent, because this is exactly the rule someone will "fix" again.canPlaceAtkeeps only the weaker requirement it always had: the piece must touch the network somewhere, which is what stops orphaned track.A Modifier is scenery (Jesse, 2026-08-26): a rail pointing at a building is fine, so nothing guards Modifier placement either. Measured before the ruling: 24 of 283 Modifiers across 200 bot games sit where a neighbour's rail points at them, and that is simply legal.
The attached save is dead — Gitea#14 took the deck from 206 cards to 121, so its card ids no longer exist and the replay stops at the first
card.play. Every save filed before that deck change is in the same position, including Gitea#17's. Reproduce from the geometry, not the file. -
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.
-
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. -
Stop the bot discarding its own freight.
canStockProductivelydid not check the Division Yard while the engine'sstockOutbounddoes, 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. -
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.
-
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 fromINDUSTRY_PROFILESnow, 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. -
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.
-
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
bestTrackLaynever declined a piece, so it spent the track supply on whatever was legal. -
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.
-
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.
-
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.
-
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.
-
§6.2's reshuffle. Implemented, and the
deckReshuffledevent 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. -
Q12 — Whistle Post lock-in. Players always start at a Whistle Post; office density doubled instead.
-
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.unloadBeganandpassengersDetrainednow 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_SUPPLYis 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, incard.standing, are easy to leave out of the count and make a conserved game look like a leaking one.) -
state = fold(events)was not true, and the docs said it was. Settled: the INTENTS are canonical. Measured before deciding —advance.tsnever callsreduce, 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 carriesFrames, 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.tspins 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. -
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. -
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.
-
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.
-
stats.tsundercounts 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. -
LEFT AND RIGHT ARE ON THE WRONG DIAGONAL — for turnouts and for curves, the same way. The engine's
leftturnout 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'sleftcurve is arcsw, 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:462draws rails fromconnectionsFor()geometry alone, so only words are wrong. Decision: flip thehandvalue on theTRACK_CARDSrows 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 inTRACK_CARDSuntouched:setup.ts:95builds the deck by iterating that array, so flipping only the labels leaves pre-shuffle slot 32 holding ane_swcurve before and after, andvariantsFor(…)[0]still'sw'— same seed, same board, and every published replay still plays. Also:track.ts:126arc fallback,track.ts:191-212doc block,view.ts:1041andview.ts:1208diagonal phrases,bot.ts:837-852arcInHand, seven test files, and the prose plus ~14data-tip="Turnout · left"strings indocs/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. -
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), andcanPlaceAthard-stops atif (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:1249salvages only cards that were not placed). Reusecard.playwith a placement on an occupied square;legal.ts:252must offer those squares for turnouts, and theattachmentsset atlegal.ts:137is 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. -
A Depot shows three MEN AT WORK boxes it can never work. Offices are Passenger Facilities — no freight — but
setup.ts:176gives every one a three-slotmenAtWorkarray, 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 getlaborers: 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: makemenAtWorknullable and null for passenger facilities, which is what the "Freight only" comment atstate.ts:153has 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:562calls a Depot "SHIPS + RECEIVES", an industry's flow word, andboard-svg.ts:599'sMath.max(1, trackCap)draws it a siding slot although its industry track has length 0.FacilityViewneeds akindfield; it has no freight/passenger flag today. -
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, andcheckPlay'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 withcopies > 1to go partly dead in solitaire, where there is one Office Area — correct, since the spare copies exist for other players' districts. -
A drawn card lands at the far end of the hand with nothing to mark it.
apply.ts:1219pushes, the hand renders in state order (game.ts:355), and withflex-wrapthe 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(reversinghandandhandWhatidentically, or they desync) covers the replay viewer and the standalone replay together, andgame.ts:355covers the play page. Keepinghand.pushmeans the bot's option-iteration order does not move, so every revenue figure in this file stays comparable; an engineunshiftwould invalidate the lot. The marker is play-page only — aFramecarries card names, not ids, so a replay cannot say which card arrived that step. Follow thegame.scheduledprecedent for ajustDrawnfield, but make the badge persist rather than flash: it says which card is new, not something just happened. A static::beforebadge as.handcard.targetdoes it (panels.ts:262), no keyframe — the innerHTML rebuild would restart an animation on every render. Clear it inrenderUndo()and afterfromSave(), or a fresh page load badges last session's draw. -
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 — exceptpanels.ts, whose sharedboxes()helper (panels.ts:162) emits classffor every filled box regardless of direction, so the red row atpanels.ts:222comes out green. The board SVG on the same page draws it correctly, which makes the panel actively contradict the board. Giveboxes()its class from the caller asreplay.tsalready does, add.box.rinboard-svg.ts:820's palette, and take the siding off green in both panels andreplay.ts:525. -
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 inENHANCEMENT_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.tshad passing tests for all four the whole time. -
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
suppressedGrantssays 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 isflow: 'inbound'soallows.outboundis 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. SoapplyModifier(apply.ts:1788) gates each capacity grant onallowsand growsindustryTrack.lengthonly by what was actually applied. Audit all 17 profiles for the same trap:iceHouse,truckDockandforkliftsall printaddOut: 1and listgrocersWarehouse;waitingArea,restaurantandhotelprintaddOut: 1forhosts: ['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. -
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.
-
Nine of the twelve special-train rules are declared and read by nothing. Done — all nine enforced, and one of them deleted instead.
copiesNextScheduledwas 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. -
Carry
linksforward in replay frames. Done, and the premise was wrong in an instructive way: measured,linkswas 5% of thecellspayload. What actually cost was thewhatprose (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. -
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, andharness.test.tsnow 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. -
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 internalMapkeys are untouched. -
"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.movenow refuses only a move that would couple a fresh car — the same waydropOnlywas already handled — so these trains can still be shunted onto Secondary Track to clear the mainline.switch.dropCarsandswitch.sortConsiststay blocked. -
Q3 corrected: Expedite governs WHERE a train may stand, not WHEN it leaves — v0.4.9. The forced same-Stage departure (
departsThisStage, theshiftChangeexpedite 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. -
evaluateClearancechecked 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. -
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 bybuild-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. -
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 coacharrangement 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 acoachesOnlyflag instead of refusing the Office outright; trains 7/8'scoachStaysOnStationTrackrule 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. -
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.
-
THE INDUSTRY TABLE VS. THE CARD REFERENCE — v0.5.0, Jesse's call: the engine was right, the docs were stale.
card-reference.mdprinted 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. -
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. -
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 (createGameis pure, andpendingDecisionis 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.mdlisted 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. -
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.tswas already doing this); the comments andimplications.md§10 Q11 previously framed it as a placeholder awaiting an interactive setup phase — corrected. -
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 viacrewTrayCount/freeTrays;NO_FREE_TRAYalready fires exactly when engine supply would run out too. No code change; corrected the comments that called this provisional or unsourced. -
Player settings, saved — v0.5.0. A
localStoragesettings 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 disabledlocalStorage, the same guard the save already had. -
The test suite's flakiness under
npm test— fixed, v0.5.0. Two changes: (1) apretestnpm script now builds the shareddist/once, beforenode --testruns, so every test readingdist/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 owndist-test/directory (BUILD_DIST_DIRenv var,scripts/build-web.ts) instead of rebuilding the shareddist/out from under the tests reading it.dist/is now single-writer. -
Curves and turnout diverging legs now draw as smooth curves, not two straight segments meeting at a corner — v0.5.0.
curvedRailinboard-svg.tsreplaces 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. -
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 existingoverflow-x:autoscrollbars keep doing the panning with no new gesture code. Persisted in the new settings object above.