Jesse, playing v0.7.8: "Solitaire game ended. I did not have an option to extend the game by a day." The engine and the Frame were right — checked before changing anything. A solitaire game at the end of its timetable reaches awaitingExtension with extensionVotes [null], and renderEnding writes "play one more Day" into #actions. It then opens #resultsdlg, which is MODAL, so those buttons were directly underneath a dialog whose only control was Close. The results dialog now carries the question itself, hidden unless a vote is pending: Play One More Day / End the Game Here, casting the same game.extend intent. The #actions buttons stay as the fallback once it is closed. TODO.md #35 recorded extended play as verified on phoenix.local — over the HTTP API, which renders no dialog. What was proven was that the server supports it, not that a player can reach it. Noted there. Rides along in the unshipped v0.7.9. 870 tests pass, one new. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
2384 lines
187 KiB
Markdown
2384 lines
187 KiB
Markdown
# 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:
|
||
|
||
1. ~~**Fix the New Train phase car-placement round**~~ — done, see Multiplayer below.
|
||
2. ~~**Unify victory conditions across solitaire, competitive and coop**~~ — done, see Multiplayer
|
||
below.
|
||
3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below.
|
||
|
||
Queued 2026-08-21, from playing the StartOS build:
|
||
|
||
4. ~~**The lobby must offer every game parameter the solitaire New Game dialog does**~~ — done in
|
||
v0.6.0.
|
||
5. ~~**Decide what the four `optionalRules` are**~~ — done in v0.6.0: `sisterTrains` deleted,
|
||
`employeeRotation` implemented, the other two were already live.
|
||
6. ~~**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:
|
||
|
||
7. **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.
|
||
8. **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):
|
||
|
||
9. ~~**Two trains in one station answer to one button**~~ — done in v0.4.9e / the release below.
|
||
10. ~~**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.
|
||
11. ~~**The Grocer's Warehouse ships and the Refinery receives**~~ — done in v0.4.9e / the release
|
||
below: both are one-way again.
|
||
12. **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.
|
||
|
||
13. **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.
|
||
|
||
14. **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.
|
||
|
||
15. **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.
|
||
|
||
16. **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.
|
||
17. **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.
|
||
18. **Give every phase a visible beat.** The automatic phases are not too fast — they are never
|
||
drawn at all, because `pump` runs them all before the page renders once.
|
||
19. ~~**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.
|
||
20. ~~**Run the inter-row connector round the OUTSIDE**~~ — **SUPERSEDED by Gitea#18.** No second
|
||
row, so nothing to connect.
|
||
21. ~~**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.
|
||
22. ~~**Fill the dead centre of the Division map with the common board**~~ — **SUPERSEDED by
|
||
Gitea#18.** A row has no centre to fill.
|
||
23. **History: newest at the top?** Plus the timestamps question from item 14, which lands here.
|
||
24. ~~**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:**
|
||
|
||
25. ~~**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.
|
||
26. ~~**CONFIRMED IN PLAY: the buffer stop points the wrong way** at two players~~ — **SUPERSEDED by
|
||
Gitea#18**, with item 21.
|
||
27. ~~**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.
|
||
|
||
28. **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.
|
||
|
||
29. **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.
|
||
|
||
30. ~~**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 naming `2ab25e3` and `e9683cc` and 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`.
|
||
|
||
31. ~~**Bump the StartOS wrapper to 0.7.2.**~~ — done 2026-08-26 (`1bfea8d`). Submodule pinned to
|
||
`v0.7.2`, `current.ts` at `0.7.2:0`, release notes rewritten in all five locales, `README.md` and
|
||
`instructions.md` updated. No new version file and no migration. Verified: `npm run check` clean
|
||
and `make x86` packs as `v0.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.ts` refuses 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.
|
||
|
||
32. **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):
|
||
|
||
33. **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.trainsCompletedWithWork` is the switching-master join, `longestStand`
|
||
is the engine that sat on a siding — and because the statistics are DERIVED from the event stream
|
||
rather than recorded, a second pass can add any of them retroactively to games already played and
|
||
saved. Needs Jesse and a conversation, not code, to start.
|
||
|
||
34. **`replay.ts` prints 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`'s `reasonSentence`), but they are written against a `Frame` and
|
||
the replay recorder has a `GameState`, 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.
|
||
|
||
35. **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 reached `awaitingExtension` on Day 2 with
|
||
`official = { 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 to `active` with `extraDays: 1` and 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 own
|
||
`WHISTLE-4086` and the `FREIGHT-3230` lobby.
|
||
|
||
**The save carry-over claim was also checked rather than asserted**: phoenix held five saves
|
||
before the update, of which `WHISTLE-4086` resumed and three were already refused by the 0.7.2
|
||
deck change. After updating to 0.7.3 the log is identical — same game resumed with the same 7
|
||
intents, same three refusals at the same move with the same code.
|
||
|
||
**What is still untested is 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.
|
||
|
||
**A HUMAN DID REACH IT ON 2026-08-30, AND IT WAS UNUSABLE — fixed in v0.7.9.** Jesse played a
|
||
solitaire game to the end and was never offered the extra Day. Nothing was wrong with the engine
|
||
or the Frame: `renderEnding` wrote the two buttons into `#actions` and then opened `#resultsdlg`,
|
||
which is **modal**, so the question sat underneath a dialog whose only control was Close. The
|
||
dialog asks it now. **The verification recorded above is exactly why this survived** — it was
|
||
driven over the HTTP API, which renders no dialog, so what was proven was that the SERVER
|
||
supports extended play, not that a player can reach it. Read that distinction into every "verified
|
||
on phoenix.local" line in this file.
|
||
|
||
36. **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 `trainStoodStill`
|
||
would supply it, "emitted per Stage, so a run of them is exactly the streak you describe". That
|
||
is wrong, and was found only by reading `advance.ts` while building the tally: the event fires
|
||
for a train whose profile sets `stopEarnsPoint` — the X18 Circus and nothing else — and
|
||
`tray.stopPointClaimed` guarantees it fires at most once per train per game. 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 way `stats.ts`'s funnel probe does — which the tally cannot do today, because it
|
||
folds a batch of events AFTER `advance` has already mutated past the moment they describe. Worth
|
||
settling with the badge pass (#33) rather than on its own, since that is the only consumer.
|
||
|
||
37. ~~**Bump the StartOS wrapper to 0.7.3, then 0.7.4.**~~ — 0.7.3 done 2026-08-29 (`74aea24` in
|
||
`station-master-startos`). Submodule pinned to `v0.7.3` (`45580d8`), `current.ts` at `0.7.3:0`,
|
||
release notes in all five locales, `README.md` and `instructions.md` updated. No new version file
|
||
and no migration — the outgoing `0.7.2:0`'s `up` was empty, `versions.md`'s common case, so
|
||
`current.ts` bumped in place. Verified: `npm run check` clean, prettier clean, `make x86` packs
|
||
as `v0.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 in
|
||
`public/replays` (all recorded under an older ruleset) and asserts every intent still applies.
|
||
|
||
**Bumped again to `0.7.4:0` the same day** (`085b88b`), pinned to `v0.7.4`, and installed on
|
||
`phoenix.local` — verified there, not merely packed: the resume log shows `WHISTLE-4086` coming
|
||
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.ts` in place (the outgoing `up` has been empty every time, `versions.md`'s common
|
||
case), rewrite the notes in all five locales, update `README.md` and `instructions.md`, then
|
||
`npm run check` / prettier / `make x86` / `make install`. `UPDATING.md` in the wrapper is the
|
||
authority and has not needed changing.
|
||
|
||
38. ~~**Gitea#13, #5 and #19 — three rules corrections.**~~ — done 2026-08-29 in v0.7.4
|
||
(`5e34c73`, `2280276`, `19a6a47`), wrapper `085b88b` as `0.7.4:0`, installed on `phoenix.local`.
|
||
Each issue carries a comment naming its commit and what was ruled, per #30. Reasoning is in
|
||
`CHANGELOG.md`; what matters here is what they left behind, below.
|
||
|
||
39. **NONE OF v0.7.4 HAS BEEN PLAYED BY A HUMAN.** The Yard Office offer, the Red Flag hold and its
|
||
out-of-phase prompt, and the loaded-Extra make-up rules are 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.
|
||
|
||
40. **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-4086` did survive on `phoenix.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.
|
||
|
||
41. **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.
|
||
|
||
42. ~~**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 `#solitairesetup` screen in `play.html` asks 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 one `wireGameTypeBlock()`/`commitNewGame()` pair instead of the dialog carrying its own
|
||
copy. Reasoning in `CHANGELOG.md`.
|
||
|
||
**Played in a browser on `phoenix.local` 2026-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.html` load could not tell "I
|
||
clicked Play solitaire" apart from "I reloaded mid multiplayer game" — the same class of problem
|
||
`?lobby` already 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.
|
||
|
||
**And it happened AGAIN on v0.7.6, which is what found the real cause — fixed in v0.7.7.** Every
|
||
packaged build published the same cache-bust key (`?v=nogit`, because the `.s9pk` build has no
|
||
`.git` for `git rev-parse`), and the server sent no `Cache-Control` at all, so neither release
|
||
ever reached the browser that asked for it. Both earlier fixes were correct and both were
|
||
verified by reading what the SERVER served — which was true and was never the thing in doubt.
|
||
**The lesson worth keeping: when a fix appears to have had no effect, check that it arrived
|
||
before re-diagnosing it.** A hard-reload would have answered it on the first report.
|
||
|
||
**And a THIRD report, 2026-08-30, with the build confirmed current on screen — which is what
|
||
finally found it. Fixed in v0.7.8.** v0.7.5 skipped the setup screen whenever a save existed
|
||
("a saved game is a game to resume"), so any browser that had ever played solitaire could never
|
||
reach it again; the private window that seemed to vindicate v0.7.7 simply had no save. The door
|
||
outranks a save now, a bare reload still resumes, and `#ss-resume` keeps the game in progress
|
||
one button away since Deal clears it.
|
||
|
||
**Three attempts, two of them fixing something real that was not the reported fault.** Each was
|
||
reported as verified, and each verification read what the SERVER served rather than exercising
|
||
the path with the state a returning player actually has. The thing that worked was a failing
|
||
test written before the fix. Worth remembering next time a report repeats: reproduce the user's
|
||
state first, and treat "I verified it" as unearned until something failed the way they described.
|
||
|
||
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`) calls `record(game,
|
||
result.events)` without the `actor` argument `submit()` always passes it (`game.ts`'s own
|
||
`record(game, events, actor)` — `actor` is what turns "Chose to draw a card" into "Player X
|
||
chose to draw a card"). So a restored save, an undone game (`undo` rebuilds via `fromSave`
|
||
internally), or a replayed one all lose attribution on every line — invisible in solitaire
|
||
because nothing ever compares a `fromSave`-built log against a live-played one (the one test
|
||
that compares logs, `test/web.test.ts`'s "leaves nothing in the log describing a move that was
|
||
taken back", compares `undo`'s `fromSave`-built log against ANOTHER `fromSave`-built log, so
|
||
the missing attribution cancels out both sides), but it would read as broken the moment more
|
||
than one seat's history is on screen at once — exactly what the replay viewer and any
|
||
multiplayer post-game replay (D20) need to get right. Fixed in `fromMultiplayerSave`
|
||
(multiplayer's version of this function, added for Phase 3) by passing `actor` through; not
|
||
touched in `fromSave` itself since it's used far more widely (undo, save/restore, the replay
|
||
viewer) and deserves its own careful look rather than a fix bundled into an unrelated change.
|
||
|
||
- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts
|
||
--seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON
|
||
saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored,
|
||
so it is a developer tool that happens to look like a product feature. It carries two panels
|
||
the site viewer does not — the bot's decision trace ("what it chose, why, and what it passed
|
||
over") and the timetable — which is debugging material rather than something a player wants.
|
||
Decide: fold the decision trace into the JSON viewer and delete the standalone, or keep it and
|
||
accept that it is a tool. No action for now.
|
||
|
||
- [ ] **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: `redFlagsSet` fires ZERO times in 200
|
||
solitaire games.** The old measurement (600 games: OFFERED 4,212 times, PLAYED 4) described a
|
||
bot that declined a card it was constantly handed. That bot is gone.
|
||
|
||
Gitea#19 replaced the rule outright: a flag is planted on one side of your own Limits and holds
|
||
the next train from that direction, and it can be played out of phase when the engine breaks in
|
||
with "COLLISION RISK! FLAG AGAINST T2?". The bot takes that prompt **unconditionally** — the
|
||
engine only raises it when an arrival is certainly about to collide, so there is nothing left
|
||
to judge. It still never plays one, because the prompt needs two things to coincide: an arrival
|
||
that would collide (0.14 collisions per game, about one game in seven) AND the district's owner
|
||
holding a Red Flags card at that moment, from a three-card hand drawn out of 121.
|
||
|
||
**What is left to fix is the OTHER half of the card**, and it is the half a human would use:
|
||
planting a flag on purpose to buy a Stage for switching. That needs the bot to know it wants
|
||
time, which it has no notion of today. Until then the anomaly canary in `sim.test.ts` is
|
||
measuring deck luck rather than reachability, and its comment now says so.
|
||
|
||
- [ ] **THE BOT DOES NOT KNOW TO BRING AN EXPEDITED TRAIN BACK TO THE STATION — new in v0.4.9.**
|
||
The `expediteFault` mechanic (§7, Q3) charges 1 Revenue every Mainline Phase an expedited train
|
||
is left off the Office square, and the bot has no heuristic that accounts for it: measured over
|
||
30 fresh games, one left Train 4 (3/4 Express) parked on Secondary Track from Day 3 Stage 10 to
|
||
the end of the game, drawing the fault **26 times**. Not an engine bug — the mechanism fires
|
||
exactly as designed — but a clear next bot heuristic: prefer ending a switching turn with any
|
||
expedited crew back on the Office square, at least once it has finished the work it went out for.
|
||
- [ ] **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 > 2` floor that
|
||
caught this had never actually been met** — it read 2.16 at 150 games and 1.76 at 400, so it
|
||
was passing on which seeds the sample happened to include. Now 400 games and a floor of 1.2.
|
||
- **Track laid badly, 7% → 15%** of pieces butting a card that cannot accept them. Forced to
|
||
shed on turn one, the bot would rather lay a piece than discard it; a player would discard
|
||
the ones with nowhere good to go. It also means the district-size gain from the new deal is
|
||
partly padding rather than useful railroad.
|
||
- **Interlocking placed, 15/60 → 7/60 games.** Departure Revenue pulls the bot toward other
|
||
work and it spends its opening on the track it was dealt.
|
||
- **Aimless shuttling in 3 games of 16** — thirteen are clean, so this is a minority behaviour
|
||
rather than the every-game waste the detector was written for.
|
||
Each floor was moved to match what is measured, with the reasoning written into the test. None
|
||
is a crisis on its own; together they say the bot spends its openings worse than it did.
|
||
- [ ] **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. `flyingSwitch` is exempted by name in the reachability sweep in `sim.test.ts`; deleting
|
||
that line is the test that this is fixed. Same root cause as the item below.
|
||
- [ ] **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
|
||
|
||
- [ ] **A SOLITAIRE GAME CAN NOW END ON THE COLLISION FLOOR (v0.7.9, 2026-08-30) — small, but every
|
||
full-length figure in this file predates it.** §3.4's check was gated on
|
||
`mode === 'competitive' || mode === 'coop'`, so the two collision limits were live settings in
|
||
the solitaire dialog that did nothing; Jesse's ruling was that they should do what they say, and
|
||
the gate is gone. Measured over 200 standard developer-bot games: `loss/collisionFloor` fires in
|
||
**1 game in 200**, Days played 5.00 → 4.98 mean with a **minimum of 1**, and collisions per game
|
||
unchanged at 0.14 (max 3).
|
||
|
||
Nothing here needs acting on — the effect is smaller than the noise on every number in this
|
||
section — but it is a new way for a run to be short, so **a mean taken over games that all ran
|
||
five Days is no longer quite what is being sampled.** Worth remembering when the rebalance pass
|
||
re-measures, and worth watching if a future change makes collisions more common, because the
|
||
cost of one stops being "−1 Revenue" and starts being "the game".
|
||
Gitea#3, measured 2026-08-26 across the same 100 games: freight share of gross fell **8% → 5%**,
|
||
and completed freight loads went from something a 40-game sample caught reliably to needing
|
||
200 — on `sim.test.ts`'s seeds, 40 games now yield 0 loads, 80 yield 3, 120 yield 10, 200
|
||
yield 21.
|
||
|
||
**It runs against the obvious expectation.** The change SPEEDS crossings up, so more trains
|
||
should reach more districts, not fewer. Revenue is flat (−0.2 against 0.0) and collisions are
|
||
unchanged at 0.1 a game, so nothing is obviously eating the traffic. Candidates worth checking:
|
||
trains now clear a district before a crew can work them; the entry-time collision rule
|
||
(below) destroying trains at the Office; or simply that faster turnover means fewer trains
|
||
standing where freight can be loaded.
|
||
|
||
- [ ] **THE CREW TRAY COUNT IS DUE A RE-EXAMINATION, and this is the change that triggers it.**
|
||
`players + 3` was set when a Slow train took roughly twice as long to cross as a Fast one, and
|
||
Q2's recorded consequence was that "every Slow train is still on the road when the next Day
|
||
begins, holding its Crew Tray". Gitea#3 removed the Slow penalty from every card but Hilly.
|
||
Measured on the Mainline cards alone, a 3-player Division now costs a fast train ~5.6 Stages
|
||
and a slow one ~6.0, against ~5.4 and ~9.4 before: **fast traffic is unchanged, slow traffic is
|
||
about a third quicker**, and the gap across a whole Division collapses from roughly four Stages
|
||
to less than one. RAR's own closing note on the issue: "been worried about the time it takes to
|
||
cross the division. More thunking on this is needed."
|
||
|
||
|
||
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
|
||
target is settled and freight carries its intended share; read no balance conclusion from a revenue
|
||
number until the rules stop moving.
|
||
|
||
- [ ] **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.** `minCombinedRevenue` replaces the fixed target these numbers were read
|
||
against; re-measure once that lands rather than off this. **RE-MEASURE THE BOT AT THE NEW
|
||
DEFAULTS.** Both provisional rules below are now **settings on
|
||
the New Game dialog** rather than fixed choices, and the defaults are not what the numbers in
|
||
this file were measured under: the opening hand defaults to **three random cards** (the
|
||
prototype rule) rather than 3+3, and **train revenue per transit defaults to 0** rather than 1.
|
||
That second one is the big move — it was worth ~5.4 of a 7.0 mean, so the bot's revenue should
|
||
fall to roughly the working freight-and-passenger economy alone, which is the number this game
|
||
has actually been trying to read all along. Every mean, floor and threshold quoted below and in
|
||
the tests predates it. The three revenue rates run 0–5, so the useful next step is a sweep
|
||
rather than a single re-run.
|
||
- [ ] **Not superseded by the 2026-08-20 victory-condition redesign (Multiplayer) — these two stay
|
||
`houseRules` dials, separate from the new `GameConfig` victory dials.** Noted only so the two
|
||
redesigns aren't conflated. **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN
|
||
PLAYED — both went in provisional, and both are
|
||
now selectable rather than fixed.** Jesse's call, both implemented and measured, both flagged
|
||
in `rules-v0.2.md`. What follows is what was measured when each was the only option.
|
||
|
||
**The opening deal (3 track + 3 other, from two separately shuffled piles).** It did what it
|
||
was aimed at, modestly: run-arounds **4/60 → 7/60** and districts **17.9 → 20.3 cards**, with
|
||
revenue unmoved on its own (−0.1, inside noise). Still nowhere near the 91/100 of the
|
||
private-supply era, so the supply question is softened rather than answered. Two things to
|
||
watch at the table: whether opening with six against a limit of three is a real decision or
|
||
just bookkeeping, and whether three is the right number of each.
|
||
|
||
**~~One Revenue for every train that clears your section.~~ Now: one Revenue to EVERY player
|
||
when a train completes its run.** Jesse's revision in v0.4.2. The first version paid the Office
|
||
a train departed, which on a five-Office railroad paid five separate times for one train and
|
||
paid most to whoever it passed first. It pays once now, when the train runs off the end of the
|
||
Division, and it pays the whole table — getting a train the length of the railroad is the
|
||
shared achievement, and every Office it crossed had to clear it.
|
||
Solitaire is nearly unmoved (7.0 → 7.3 mean over 200 games) because one player's departures and
|
||
completions run at almost the same rate; **in a multi-player game the shape is completely
|
||
different** and needs measuring once multiplayer exists — N players × 1 per completed run
|
||
against the old N payments per train. **The victory-target question stays live**: 20 over 5 Days
|
||
is still reachable largely on traffic, which is either the intent or an argument for raising it
|
||
— and at the new default of 0 per transit it is not reachable on traffic at all, which is the
|
||
first thing a playtest should check.
|
||
- [ ] **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 `loadStarted` past 2.7. The chain is not leaking and the cars are arriving
|
||
correctly (57% of drops land on a facility that wants them, 0% on one that does not). The
|
||
binding constraint is now upstream of routing: 60 Local Operations actions a game, and a load
|
||
needs a stocked green box AND a spotted car AND a free Laborer to line up in the same Stage.
|
||
Measure how many Stages have all three before changing any heuristic — the answer may be that
|
||
the economy, not the bot, is what caps freight.
|
||
- [ ] **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.
|
||
|
||
- [x] **A blocked PASSENGER facility produces no impediment at all — FIXED.** `impediments()`
|
||
(`src/sim/narrate.ts`) opened with `if (!f || f.kind !== 'freight') continue`, so the "why
|
||
nothing is moving" panel had never had anything to say about a platform. That was the second
|
||
half of Gitea#2 and the half that was unambiguously a bug: the player above was not merely
|
||
blocked, he was given no reason — the button simply was not there. A platform now reports
|
||
passengers with no train, a train the card bars Porters from working, full coaches, full red
|
||
slots, the same-district rule, and the coach shortage itself — the last naming how many coaches
|
||
are stranded in Classification and what brings them back. The reason comes from
|
||
`passengerRefusal`, the engine's own predicate, so the panel cannot drift from the rule that
|
||
actually refused. Fixing the label found a second defect: a Passenger Facility rides on the
|
||
`office` card, so every passenger row would have read `facility 0,0` next to `mineTipple 1,-3`;
|
||
it is named by its tier now.
|
||
|
||
- [ ] **The log lowercases the first letter of every narration it attributes to a player**, so
|
||
`EXTRA X18 started…` renders as `Player 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 in `content.ts` and was
|
||
scaled alongside the Gap 12 industry increase. Now that the Classification Yard returns stock
|
||
only when the Division Yard empties, these numbers set the real supply pressure. Adjust from
|
||
playtesting rather than theory, and watch whether industry density feels light or heavy at the
|
||
same time.
|
||
- [ ] **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.ts` item in Done) and partly the car-selection bug; freight now runs at 37%. Worth
|
||
re-deciding whether 27 is still the right number now that the industries are actually served.
|
||
- [ ] **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 of `minCombinedRevenue`, defaulting to `3 × players × days` (15 for
|
||
1-player/5-day, not 20) — a different number, deliberately not tuned to match this table.
|
||
Whether the Office-ladder bottleneck below still applies at the new default is worth
|
||
re-measuring once the redesign lands, but the fixed "20" this data argues against no longer
|
||
exists as a target. **The victory target (20 over 5 Days) is out of reach by a factor of
|
||
about four, and the Office ladder is why.** Measured over 800 games with the tuned bot, which
|
||
no longer throws
|
||
revenue away on collisions (0.0 a game, down from 0.4):
|
||
|
||
| trains scheduled | games | revenue | | Office reached | games | trains | revenue |
|
||
|---|---|---|---|---|---|---|---|
|
||
| 0 | 110 | 0.67 | | Whistle Post | 297 | 0.81 | 0.62 |
|
||
| 1 | 379 | 1.69 | | Depot | 272 | 1.49 | 2.92 |
|
||
| 2 | 234 | 3.72 | | Station | 176 | 1.89 | 4.36 |
|
||
| 3 | 68 | 5.68 | | Terminal | 55 | 1.93 | 5.04 |
|
||
| 4 | 9 | 5.78 | | | | | |
|
||
|
||
Revenue is almost exactly linear in trains scheduled — about **1.9 a train** — and trains are
|
||
capped by A/D capacity, which is the Office tier, which is a card you have to draw. So the
|
||
whole economy hangs off one valve: **37% of games never leave the Whistle Post and earn 0.62;
|
||
53% of all games earn nothing at all.**
|
||
|
||
Extrapolating the line, 20 Revenue needs roughly **11 trains and therefore 11 A/D tracks**. A
|
||
Terminal has four. The target is not merely missed, it is structurally unreachable under this
|
||
deck at this Office ladder — no amount of bot skill closes it, and the best game seen in 800
|
||
was 26 against a median of 0.
|
||
|
||
The three ways out are all yours to choose between, and they are different games:
|
||
1. **Lower the target** to what a 5-Day game can produce (6–8 looks like the honest number).
|
||
2. **Open the valve** — more Office cards, or a cheaper first upgrade, or more A/D capacity at
|
||
the Whistle Post, so the ladder is climbed rather than drawn.
|
||
3. **Raise revenue per arrival.** It is 0.46 today; each arrival can in principle pay 2 for
|
||
passengers alone. That is the freight/passenger conversion problem, not the traffic problem.
|
||
|
||
Nothing here is a bot weakness any more, which is what this measurement was waiting on.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
- [x] **~~The lobby, the setup form and the start of a game~~ — done 2026-08-23 (Jesse's cleanup
|
||
pass).** Kept for the reasoning, since several of these were decisions rather than fixes.
|
||
|
||
**The game types.** Co-op, Competitive, Cutthroat, Solitaire and Custom (`src/web/presets.ts`),
|
||
replacing the old two-mode radio. A type is a set of DEFAULTS, not a ruleset: every rule stays
|
||
editable, and editing one selects Custom, which keeps the scoring of the type it came from.
|
||
Jesse's numbers — Co-op pays 1 per transit and asks 3 combined Revenue per player per Day;
|
||
Competitive asks 2 and pays nothing for transits; Cutthroat asks nothing at all and lifts the
|
||
whole-game collision cap, leaving three-in-one-Day as the only shared way to lose; every type
|
||
deals six cards. **The Revenue floor is a formula**, so table size and Day count re-derive it
|
||
rather than making a game Custom — which is why those two, and the seed, sit above the type
|
||
radios as parameters. The type is DERIVED from the numbers, never stored, so no saved game
|
||
carries a label that can disagree with itself (`test/presets.test.ts`).
|
||
|
||
**Where an Extra may start was missing from the lobby entirely**, so every multiplayer game
|
||
ever played used the most permissive setting (`anyOffice` — an Extra may be planted in another
|
||
player's district) and no host was ever asked. It is a Cutthroat-only default now. The reverse
|
||
hole existed too: the solitaire dialog had none of the three optional rules. Both screens ask
|
||
the same eleven questions through one shared module (`settings-form.ts`), and a test asserts
|
||
the markup carries every field on both — the drift is what motivated the shared block.
|
||
|
||
**The dead PvP checkbox is gone from both screens.** `buildDeck` ANDs `pvpCardsAllowed` with a
|
||
hard-coded `cardsImplemented = false`, so the control could not do anything whatever it was
|
||
set to. The 22 opponent-directed cards (and the 7 defences held out with them) are now a
|
||
property of the game type, and the fact is stated in words where the checkbox was.
|
||
|
||
**The lobby itself:** joining is a door of its own rather than a heading below fifteen fields
|
||
the joiner has no use for; a stored join secret collapses to one line and re-opens on a 403; a
|
||
display name is remembered and may not duplicate another at the same table (`NAME_TAKEN`); the
|
||
seating list numbers bots as the game will; the code copies as a code AND as an invite link;
|
||
server codes are translated into sentences in a red block instead of `.dim` grey; the lobby
|
||
stream has an `onerror` that tells a blip from a dead lobby (and finds a game that started
|
||
while the connection was down); and **a player may read the whole rule set before taking a
|
||
seat** (`/api/lobby/preview`, which never carries the seed).
|
||
|
||
**The start of a game**, which nobody had ever drawn: a handoff curtain with a deliberate beat
|
||
instead of a "connecting" line written into the DISCONNECT banner, an announcement naming the
|
||
game and its type, a marker at the top of the log so the bots' opening turns are visibly after
|
||
the start, the game code and the type in the header for the rest of the game, and a Start
|
||
button that cannot be pressed twice.
|
||
|
||
**The four transient signals reach a remote client at last.** `createRemoteSession` answered
|
||
all four with empty values, so multiplayer had no sound, no timetable flash, no announcement
|
||
when a completed run paid the table, and no badge on the card you had just drawn. `justDrawn`
|
||
is the redaction-sensitive one — `game.justDrawn` is one field for the whole game and does not
|
||
say whose card it is — so the server remembers who drew and sends it to that seat alone.
|
||
|
||
**Two things found by RUNNING it rather than reading it.** A bot seat was being reported as a
|
||
disconnected player, which would have put "waiting on Bot 1 — not here yet" on every screen
|
||
for a whole game. And the six-card opening survives a table with bots in it: a three-seat game
|
||
(two humans, one bot) played through Day 1 without stalling on the discard round.
|
||
|
||
- [ ] **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; `openingRolls` is on the `Frame` now and the play page prints the chain under the
|
||
Division map — *West to East: Alice (1) → Bot 2 (5) → Bot 1 (11)* — so the rolls that formed
|
||
it are visible rather than only their result (`lobby-and-sessions.md` §4).
|
||
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
|
||
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
|
||
pointed at the open internet for long. Note that one-game-at-a-time per person is expected
|
||
usage and deliberately NOT enforced — enforcing it needs cross-game state whose only job is
|
||
deciding when to release someone, and getting that wrong locks a player out.
|
||
- [x] **~~WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTER ~16 STAGES OF A POSSIBLE 60?~~ Answered
|
||
2026-08-20: the collision floor, not the revenue floor.** Traced `checkVictory`
|
||
(`advance.ts:1086-1133`): in competitive mode the revenue floor can only fire at the exact
|
||
Day-5 boundary (Stage 60), so it structurally cannot explain a 16-Stage ending. Only the
|
||
collision floor can (`advance.ts:1076-1080`, 3 collisions in one Day, checked at every Stage
|
||
boundary). `collisionsToday` is one counter every seat feeds, so a 4-player table burns a
|
||
fixed shared budget roughly 4x faster than one player would. Also: `multiplayer.md` §3's
|
||
8-game sample predates `DEFAULT_HOUSE_RULES` (v0.4.2) and most likely ran under what is now
|
||
`LEGACY_HOUSE_RULES` — that sizing data is stale on top of the collision-floor explanation.
|
||
Jesse's call, 2026-08-20: keep the collision caps flat rather than player-scaled (below), so
|
||
16-Stage games under default settings are an accepted, deliberate outcome, not something to
|
||
re-tune away — re-measure `multiplayer.md` §3's sizing table once the redesign lands, but
|
||
expect similar early endings by design.
|
||
|
||
- [x] **~~Victory conditions unified across solitaire, competitive and coop~~ — designed and
|
||
implemented 2026-08-20.** One shared, fully-configurable set of `GameConfig` dials replaces
|
||
`LENGTH_PROFILES.target`, `VictoryCondition: 'firstToTarget'` (confirmed dead — grepped, never
|
||
selected anywhere in the codebase today) and the flat `COLLISION_FLOOR_PER_DAY` constant:
|
||
|
||
| dial | meaning | default |
|
||
| --- | --- | --- |
|
||
| `days` | how many Days the game runs | 5, all modes |
|
||
| `minCombinedRevenue` | everyone loses if the table's total Revenue is below this when Days run out | `3 × players × days` — reuses `collectiveRevenueFloor()` (`content.ts:1020`), now also applied to solitaire (1 player) rather than competitive-only |
|
||
| `maxCollisionsPerDay` | everyone loses immediately, mid-game, once collisions in one Day reach this | 3, **flat — not scaled by players.** Jesse's call: more players means more independent chances to collide, not a bigger shared budget, so multiplayer is deliberately riskier than solitaire at the same default |
|
||
| `maxCollisionsTotal` | same, summed across the whole game | 5, flat, same reasoning |
|
||
| `pvpCardsAllowed` | whether the 22 opponent-directed cards (still unbuilt, see below) are in the deck | forced off in solitaire and coop — no valid target for them in either — on by default in competitive |
|
||
|
||
`0` means "off" for every dial. Win/lose shape is otherwise unchanged from what solitaire
|
||
already does: most Revenue when Days run out wins, unless `minCombinedRevenue` was missed, in
|
||
which case everyone loses — just made configurable per game instead of a fixed `length`
|
||
lookup. Coop keeps its existing "score is the table's total" model, now against a
|
||
configurable floor instead of `profile.target * players.length`.
|
||
|
||
**New Game dialog:** one shared dialog for all three modes, per Jesse — a mode radio button
|
||
at the top, the same field set underneath for all three, greyed out wherever a mode forces a
|
||
value (the PvP checkbox in solitaire/coop). Solitaire gains the four new dials alongside the
|
||
starting-hand and revenue-rate fields it already has; picking a mode only changes the
|
||
defaults, never the field set. **Deal stays disabled for Competitive/Co-op** with a "needs a
|
||
server" note, since Phase 2 didn't yet expose a way to actually start one from the browser
|
||
(see below) — only Solitaire's Deal path is wired to a real game today.
|
||
|
||
- [x] **~~New Train phase car-placement is one player's job even in competitive mode~~ — fixed
|
||
2026-08-20.** §7 (`rules-v0.2.md:346-363`) is explicit: "starting with the Superintendent and
|
||
working left, each player may place ONE car... the round repeats... until the consist is
|
||
full," with a worked 2-player example. `newTrainPhase` (`advance.ts:187-282`) never
|
||
implemented the round: `enterPhase` resets `actorOffset = 0` on entering the phase
|
||
(`advance.ts:172`) and `newTrainPhase` never incremented it the way `playerPhase` does for
|
||
Local Ops (`advance.ts:140`), so the actor was always the Superintendent alone, for every car
|
||
of every train made up that Stage. Fixed by reading the round position off
|
||
`tray.consist.length` instead — it already counts placements toward that tray and resets per
|
||
train with no new state needed. Test in `multiplayer.test.ts`, "the New Train phase
|
||
car-placement round rotates."
|
||
|
||
**Found in the process, not fixed, logged separately:** `newTrain.passCar`'s `check()`
|
||
(`apply.ts:959-967`) tests whether the *entire* Division Yard is empty rather than whether a
|
||
car suitable for *this* tray exists, and `reduce()` has no case for `carPassed` at all
|
||
(`apply.ts:2246-2247`, falls to `default: break` — applying a pass currently mutates nothing).
|
||
Unreachable in practice today: `trainNeedingCars` only ever flags a tray that already has a
|
||
suitable car waiting, so a legal `passCar` for the flagged tray can't occur. Only matters if a
|
||
future change lets the New Train phase address more than one tray at a time. Not fixed here —
|
||
nothing to verify against an intent that can't legally fire.
|
||
|
||
- [ ] **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 proves `snapshot(s, ..., viewer)` gives each seat its
|
||
own hand, board, Revenue and impediments — traced `snapshot()` itself
|
||
(`src/sim/view.ts:1180-1219`): `hand` reads only `s.decks.hands.get(viewer)`, `deck` is a
|
||
count, other seats' hands appear only as `.length`, and `Frame`'s type has no `seed`,
|
||
`rngState` or 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's `Frame` and 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 against `snapshot()`
|
||
and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now,
|
||
2026-08-20.
|
||
|
||
- [x] **~~The lobby's seat controls could not express "nobody in this chair"~~ — done in v0.5.3.**
|
||
Raised by Jesse 2026-08-21. The seats array grew as people joined, so the four rows on screen
|
||
were partly fictional: a 2-player game simply started with a 2-long array, and a host who
|
||
added a bot to a later chair padded the array with a `null` that silently disabled Start
|
||
behind a one-line note. **The host now picks the table size (2-4) when creating the game**
|
||
and the array is built at that length once, so a gap cannot be expressed rather than merely
|
||
being rejected. That also removed the need to compact seats at `Lobby.Start` — which would
|
||
have shifted the `player` index every `PlayerSession` records at join time and that
|
||
`/api/stream` and `/api/intent` route by, quietly handing a player somebody else's railroad.
|
||
Tested in `test/server/lobby.test.ts` ("seat index is player index, with no compaction to
|
||
shift it", "never grows the table, whoever asks", "refuses a chair that is not at the
|
||
table").
|
||
|
||
- [x] **~~EVERY RELEASE DESTROYS EVERY GAME IN PROGRESS~~ — fixed in v0.6.0, by option 3.**
|
||
Raised 2026-08-21 after v0.5.2, v0.5.3 and v0.5.4 each killed the games on the StartOS box in
|
||
turn — v0.5.4's changes were *rendering only*, and it still refused two saved games.
|
||
|
||
**Why it happens, and why the design is right as far as it goes.** A save is a seed plus a
|
||
list of intents (D5), so loading one means replaying those intents through the current engine.
|
||
A move that was legal under the old rules may be rejected under the new ones, and a
|
||
half-replayed game is worse than no game — so `loadGame` refuses on any `engineVersion`
|
||
mismatch and `index.ts` logs it and carries on (D7). Nothing is deleted; rolling the version
|
||
back makes the games loadable again. That is all correct. The problem is only that the test is
|
||
**exact equality against the package version**, which moves for reasons that have nothing to
|
||
do with the rules.
|
||
|
||
**Why it is getting worse rather than better.** It was harmless while Jesse was the only
|
||
player. It stops being acceptable the moment other people are seated: their game is destroyed
|
||
because somebody shipped a CSS fix. It also interacts badly with the stranded-session bug
|
||
fixed in v0.5.5 — the refusal is precisely what stranded a browser on a blank page.
|
||
|
||
Three ways out, cheapest first:
|
||
|
||
1. **A separate rules version, bumped by hand.** `RULES_VERSION` in `content.ts`, stamped into
|
||
the save instead of `package.json`'s version, and raised only when a change can alter
|
||
whether an intent is legal. v0.5.4 would not have touched it and both games would have
|
||
survived. Cheapest and the least clever, but it is a judgement call on every release, and
|
||
getting it wrong silently corrupts a game rather than refusing it — the failure is worse
|
||
than the one it replaces.
|
||
2. **A declared compatibility floor.** The save records the version that wrote it; the engine
|
||
declares the oldest save it will accept. Loading checks `saved >= floor` rather than
|
||
`saved === current`. Same judgement call as (1), but expressed as a range, which makes
|
||
"this release breaks saves" an explicit act rather than the default.
|
||
3. **Verify rather than assume — replay and see.** Load the save, replay it, and refuse only
|
||
if an intent actually rejects. This is the honest test and needs no judgement at all: it
|
||
answers the real question ("does this game still replay?") instead of a proxy for it. It
|
||
costs a full replay per game on boot, which is ~100 ms per finished game (measured
|
||
2026-08-21) and only unfinished games are loaded — so at any realistic table count it is
|
||
free. The work is in reporting a partial failure well: the game is intact up to the
|
||
rejected intent, and a player would probably rather resume there than lose it entirely.
|
||
|
||
**(3) was done.** `loadGame` no longer looks at the version; `tryResumeSession` replays the
|
||
save and reports the first intent the engine refuses, and `index.ts` resumes or refuses on
|
||
that. A save stamped with a version the server has never run now resumes, provided its moves
|
||
replay — verified against a file hand-stamped `0.4.9-ancient`. A save that genuinely does not
|
||
replay is refused as before, but the log now names the move: *"move 3 of 8
|
||
(localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN"*.
|
||
|
||
One thing deliberately NOT done: resuming a partially-replayable game at the last good move.
|
||
The note above suggested a player would rather have that than nothing, and on reflection it
|
||
is worse — the game would silently rewind to a position nobody played to, and the browsers
|
||
holding a later Frame would have no idea. Refusing keeps the file intact, so putting the
|
||
previous version back still recovers the game. Revisit only with a way to tell the table what
|
||
happened.
|
||
|
||
- [x] **~~THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING~~ — resolved
|
||
in v0.6.0.** `sisterTrains` is deleted: Q9 records that the Second Section card supersedes it,
|
||
and that card is built. `employeeRotation` is implemented — the rotation is four lines in
|
||
`advance.ts` because the seat/player split (D9) exists precisely for it, so Revenue, hands and
|
||
the Fedora travel with the player and the district stays with the chair. All three survivors
|
||
are now settable from the lobby. Original reasoning kept below.
|
||
|
||
**Original note:** Split out
|
||
at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby
|
||
item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`,
|
||
`sisterTrains`, `employeeRotation` and `emergencyToolbox`. Neither the solitaire New Game
|
||
dialog nor the lobby exposes any of them, and every construction site in the codebase
|
||
hardcodes all four to `false` (`web/game.ts`, `sim/harness.ts`, `sim/replay.ts`,
|
||
`sim/compare.ts`), so no game has ever been played with one on.
|
||
|
||
**Check what is real before building a form for it.** Only two are wired:
|
||
|
||
| rule | status |
|
||
| --- | --- |
|
||
| `reducedVisibility` | **live** — read at `advance.ts:53`, gates on `NIGHT_STAGES` |
|
||
| `emergencyToolbox` | **live** — read at `setup.ts:374`, seeds each player's Red Flags |
|
||
| `sisterTrains` | **nothing reads it.** Declared, defaulted, never consulted — and §9a Q9 records that the Second Section card *supersedes* the Sister Trains optional rule, so this flag is most likely dead rather than unbuilt. Decide whether to implement or delete it |
|
||
| `employeeRotation` | **nothing reads it.** Declared, defaulted, never consulted. Note the seat/player split (Phase 0, D9) was built specifically so this rule *could* exist — the groundwork is there, the rule is not |
|
||
|
||
So a dialog listing all four would offer two working toggles beside two that silently do
|
||
nothing — the exact failure `checkPlay`'s `NOT_IMPLEMENTED` and `enhancementText`'s
|
||
live/dormant/unbuilt table exist to prevent. Either implement the two dead ones, delete
|
||
them, or label them on screen the way an unbuilt Enhancement already labels itself. Doing
|
||
that is what decides whether this is a UI job or a rules job.
|
||
|
||
- [x] **~~THE LOBBY OFFERS NO GAME PARAMETERS AT ALL~~ — done in v0.6.0.** A "Game settings" block
|
||
on the create form carries the same dials the solitaire dialog has — seed, starting hand, the
|
||
three revenue rates, days, the combined-Revenue floor, both collision caps, the PvP toggle —
|
||
plus the three surviving optional rules. Mode and table size set the defaults and every field
|
||
stays editable, matching the solitaire dialog's own behaviour. Original note below.
|
||
|
||
**Original note:** Raised by
|
||
Jesse 2026-08-21 after playing the StartOS build. Creating a multiplayer game asks for a
|
||
display name and a mode, and nothing else — every other dial comes from
|
||
`defaultMultiplayerConfig(mode)` (`web/game.ts`), hardcoded, with no way to change it.
|
||
Solitaire's New Game dialog (`play.html`, `#ng-*`) asks for all of it: seed, starting hand
|
||
(`ng-hand` — three random / six random / three track + three other), the three revenue rates
|
||
(`ng-passenger` / `ng-freight` / `ng-transit`), `days`, `minCombinedRevenue`,
|
||
`maxCollisionsPerDay`, `maxCollisionsTotal` and `pvpCardsAllowed`. Multiplayer should ask for
|
||
the same set. Note that `GameConfig.optionalRules` (reduced visibility, sister trains,
|
||
employee rotation, emergency toolbox) is exposed by NEITHER dialog and is hardcoded false in
|
||
both — worth deciding on separately rather than folding in silently.
|
||
|
||
**~~The bug this hid~~ — fixed in v0.5.3.** `defaultMultiplayerConfig` defaults to
|
||
`players = 4` and `lobby.ts` called it without the argument, so `minCombinedRevenue` was
|
||
always `collectiveRevenueFloor(4, 5)` = 60 whatever the table's real size — a 2-player game
|
||
played against a floor meant for four (60 rather than 3x2x5 = 30), and missing that floor
|
||
means *everyone loses*. It fell out of the seat-control change: the host now picks the table
|
||
size when creating the game, so the real count reaches `defaultMultiplayerConfig` and the
|
||
ordering problem that caused this (config fixed at CREATE, seat count unknown until START)
|
||
no longer exists. **The form itself is still missing** — that is what this item is now.
|
||
|
||
- [ ] **D19's switching-instrumentation still needs writing, once real people are playing.** "13%
|
||
for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts`
|
||
or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land
|
||
before Phase 2 and realistically not before Phase 4 (real people at a lobby, not bots). A few
|
||
lines when the time comes: log whether a legal local-only action existed for a waiting player,
|
||
and whether they took it the moment their turn arrived.
|
||
- [ ] **THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF EVERY DECK UNTIL THEY
|
||
ARE BUILT.** Jesse's call. They were already cut from solitaire (Q6, no legal target with one
|
||
player); they are now cut from the competitive deck too, because `checkPlay` answers both
|
||
categories `NOT_IMPLEMENTED` and dealing them would make ~9% of draws reject outright. Flip
|
||
`opponentCardsInDeck` in `setup.ts` when they land. They are played AT another player —
|
||
Watertower, Derail, Railroad Crossing and so on — so they are genuinely multiplayer work, and
|
||
**three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are
|
||
wired and read, and fire only against these cards. Until then those three are dormant by
|
||
design rather than broken.
|
||
- [x] **Multiplayer proper — Phases 0-4 done (v0.4.0 through v0.5.1), Phases 5-6 to go.** The
|
||
full plan is `docs/architecture/multiplayer.md` §12. Phase 2 (server core) landed in one pass:
|
||
|
||
- `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a
|
||
smaller, purpose-written replacement for reusing `replay.ts`'s `compress()` — that function
|
||
interns strings across a whole recorded array, which a live single-frame push has nothing to
|
||
intern against; only its one-step-back "null if unchanged" idea carried over.
|
||
- **Found and fixed a real bug tracing this**: `actionMenu(game, seat)` only used `seat` for the
|
||
`hand` field — everything else came from `currentActor(game)` regardless of who asked, so a
|
||
server computing every connected seat's Menu would have handed the acting player's legal
|
||
moves to a waiting seat, paired with the wrong seat's cards. Fixed in `game.ts` with a guard;
|
||
tested in `multiplayer.test.ts`.
|
||
- The redaction test (§7) is built — `test/redaction.test.ts` — and passed on the first run
|
||
against the existing `snapshot()`, confirming it was already correct, not just apparently so.
|
||
- `src/server/session.ts` — the game session host (pure logic, no sockets, reuses `game.ts`'s
|
||
`Game`/`submit`/`currentActor`/`actionMenu` wholesale rather than re-deriving intent
|
||
application/narration). **Found while building it**: `submit()` derives the acting player from
|
||
`currentActor(game)` itself and does not check who is calling it — safe for `LocalSession`
|
||
(one possible caller) but not for a server, so the session host verifies `seat ===
|
||
currentActor(game)` itself before ever calling `submit`, rejecting with `NOT_YOUR_TURN`
|
||
otherwise. Idempotent resend (a repeat `seq`) and the illegal-intent path (checked via
|
||
`check()` directly, so a rejection never pollutes the shared narration log with "not allowed"
|
||
text meant only for the submitter) are both handled here too.
|
||
- `src/server/http.ts` / `src/server/index.ts` — plain `node:http`, no framework (confirmed
|
||
nothing to reuse and nothing else warranted — zero runtime dependencies anywhere else in the
|
||
project). `POST /api/game`, `GET /api/stream` (SSE, per-seat, with a 20s heartbeat and an
|
||
`id:` line per push), `POST /api/intent`, and static serving of `dist/` so the server can be
|
||
same-origin with itself (D16). No `gameId`/multi-game concept yet — one game per process,
|
||
matching "Phase 2 has no lobby."
|
||
- `src/web/session.ts` gained `createRemoteSession`; `main.ts`'s `start()` switches on `?seat=`
|
||
presence (D4 — one bundle, unchanged). Every `LocalSession`-only call site in `main.ts`
|
||
(`seed()`, `save()`, `undo()`, the New Game dialog) now goes through an `isLocal()` type guard
|
||
rather than assuming, since `session` can now be either.
|
||
- **Found and fixed a real infrastructure bug**: adding `test/server/` broke `npm test`'s glob.
|
||
`"test": "node --test test/**/*.test.ts"` relied on bash's non-globstar behaviour of passing
|
||
the *literal, unexpanded* pattern through to Node (which then globs it correctly itself) —
|
||
that only happens when the pattern matches *no* files at the shell level. The moment a
|
||
subdirectory existed, bash expanded it to just that one file, and `npm test` silently ran only
|
||
the new suite. Fixed by listing both depths explicitly:
|
||
`"test": "node --test test/*.test.ts test/**/*.test.ts"`.
|
||
- Verified two ways: `test/server/session.test.ts` exercises the session host directly (no
|
||
sockets); a live end-to-end curl smoke test (server started, a 2-player game created, two SSE
|
||
streams opened, an intent rejected from the non-acting seat, accepted from the acting seat and
|
||
broadcast to both, a resent `seq` producing no second push, and the board correctly nulled on
|
||
the second push) — see the session transcript. **Not verified**: an actual browser — no
|
||
browser binary exists in this environment, so `RemoteSession`'s DOM-facing code
|
||
(`EventSource`/`fetch` wiring) compiled and typechecks but was not clicked through visually.
|
||
|
||
**Phase 3 (persistence/resumption) done, same session, 2026-08-21.** Per §12 steps 14-16 and
|
||
`lobby-and-sessions.md` §5-6 (unusually concrete — the exact storage shape was specified, not
|
||
designed here):
|
||
|
||
- `src/server/persistence.ts` — `game.json` (`{engineVersion, seed, config, playerNames,
|
||
history, status, createdAt}`) and `turn-timings.json`, both atomic-rewrite-then-rename, no
|
||
`gameId`/index yet (one game per process, same deferral as Phase 2's `gameId`).
|
||
- `game.ts` gained `fromMultiplayerSave` — `fromSave`'s multi-player sibling, built on
|
||
`newMultiplayerGame`. **Found while testing it**: `fromSave`'s replay loop calls
|
||
`record(game, result.events)` without the `actor` argument `submit()` itself always passes,
|
||
so every replayed line loses its "Player X" attribution — invisible for solitaire (nothing
|
||
ever compares a `fromSave` replay against a live-played log; `undo`'s rebuilt game is itself
|
||
`fromSave`-built, so the one test that compares logs only ever compares two unattributed
|
||
replays against each other) but immediately visible for multiplayer, where anonymous "Chose
|
||
to..." lines are unreadable the moment there is more than one seat. Fixed in the new function;
|
||
**`fromSave` itself still has the gap** — not touched here, since it is used far more widely
|
||
(undo, save/restore, the replay viewer) and deserves its own careful pass rather than a
|
||
touch-in-passing. Worth its own TODO item if picked up.
|
||
- `session.ts` gained `exportSave()`, `resumeSession()`, and turn-timing tracking — a `TurnTiming`
|
||
span (player, phase, day, stage, start/end wall-clock) closes and reopens whenever the acting
|
||
player, phase, Day or Stage changes; recorded entirely in the session host, never touching the
|
||
engine (which must stay clock-free and deterministic) and never stored inside `history` (a
|
||
replay must reproduce a game from decisions alone). No reporting/aggregation/UI on this data
|
||
yet — §5 calls that "optional... if unobtrusive," and the Phase 3 deliverable is the data
|
||
being recorded, not a view of it.
|
||
- `index.ts` loads `game.json` on boot before starting the HTTP listener: version match →
|
||
`resumeSession`, replayed straight through; mismatch → refused explicitly and loudly (the
|
||
file is left untouched, so rolling the running version back recovers it), server starts with
|
||
no active game rather than replaying under the wrong rules.
|
||
- Verified live, matching this phase's own "done when": server started against a fresh data
|
||
directory, a 2-player game created, intents submitted from both seats, **the server process
|
||
killed and restarted**, both `?seat=` streams reconnected and picked up exactly where they
|
||
left off — same Day/Stage/phase, correct whose-turn-it-is, correct narration attribution.
|
||
Separately confirmed the version-mismatch path: hand-edited `engineVersion` to a bogus value,
|
||
restarted, server logged the refusal and started with no active game (confirmed via `POST
|
||
/api/game` succeeding rather than 409ing).
|
||
|
||
**Phase 4 (lobby, sessions, reconnection) done, 2026-08-21 — v0.5.1.** Per §12 steps 17-20 and
|
||
`lobby-and-sessions.md` in full:
|
||
|
||
- `src/server/lobby.ts` — pure logic, no sockets, no filesystem, same split `session.ts`
|
||
already draws. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, a
|
||
speakable game code (`RAIL-4471` style, from a small railroad-word list rather than a
|
||
dictionary — read aloud across a table, not typed from memory), and the 2-4 player cap
|
||
(`playerCountAllowed`) — see the doc-fix note below.
|
||
- **The server now holds more than one game.** `persistence.ts` gained one directory per
|
||
`gameId` (`games/<gameId>/`) plus a top-level `index.json` naming every game, so `index.ts`
|
||
can resume all of them on boot rather than the one `game.json` Phase 3 assumed.
|
||
`writeGame`/`loadGame`/`appendTiming` needed no signature change — they already took a
|
||
directory directly.
|
||
- **Session tokens replace `?seat=&secret=` on the running-game routes.**
|
||
`lobby-and-sessions.md` §1: the token alone proves identity, so `/api/stream` and
|
||
`/api/intent` now read `?token=` and the join secret's job ends at the lobby door
|
||
(`/api/lobby/create`/`/api/lobby/join`). `web/session.ts`'s `createRemoteSession` takes
|
||
`(token, seat)` — `seat` still passed in rather than learned from a push, since it has to
|
||
answer before any push necessarily arrives, and the caller already has it from the
|
||
join/create/start response.
|
||
- **Bots fill empty seats at `Lobby.Start` only (D8)**, never mid-game. `session.ts` gained
|
||
`driveBots()`: after any accepted intent (and once at construction, for a resume that lands
|
||
exactly on a bot's turn), it plays `developerBot` forward through every consecutive bot seat
|
||
before the push goes out — reuses `legalActions`/`developerBot` wholesale, no new bot logic.
|
||
`SavedGame` gained `botSeats: PlayerIndex[]` so a bot seat survives a restart.
|
||
- **Host rights pass to the earliest-joined remaining player** if the host's LOBBY connection
|
||
closes before start (`lobby-and-sessions.md` §2) — tracked via `Lobby.joinOrder`, a token
|
||
list rather than seat order, since a bot-filled seat has no join time of its own.
|
||
- **Disconnect/reconnect** (§5): `Push` gained an optional `presence` field — connection news
|
||
about ANOTHER seat, built entirely by `http.ts` (which owns the connection table) and never
|
||
routed through `session.ts` or the engine, since a disconnect is transport news about a
|
||
connection, not a `GameEvent`. The page shows a banner naming who has dropped
|
||
(`renderPresence`, `main.ts`) and clears it the moment they reconnect. Reconnect itself needed
|
||
no new engine-side work: `session.connect(seat)` already sent a full un-delta'd `Frame`.
|
||
- **The client lobby** (`src/web/lobby.ts`, wired from `main.ts`'s `start()`): create-or-join
|
||
forms, a live seating screen (host-only bot toggles and Start button, updated over a new
|
||
`/api/lobby/stream` SSE), and `localStorage` in place of `?seat=` for "was I already in a
|
||
game" — found on load, reconnects straight to `createRemoteSession` and skips the lobby
|
||
entirely. A `Multiplayer` button beside `New game` is the entry point; the New Game dialog
|
||
itself is untouched, still solitaire-only, its old "needs a server" note repointed at the
|
||
new button.
|
||
- **Found and fixed while running the live smoke test, not by typechecking:** `/api/intent`
|
||
read its token from the JSON body, but `web/session.ts`'s `submit()` — unchanged from Phase
|
||
2 — sends it in the query string, same as `/api/stream`. Every request failed `no such
|
||
game`. Both sides independently typecheck fine (an HTTP body is `unknown` on the wire), which
|
||
is exactly why the curl-level smoke test exists rather than stopping at `tsc --noEmit`.
|
||
- **Doc fix:** `multiplayer.md`'s D18 said "player cap 6", citing `lobby-and-sessions.md` §2 —
|
||
which actually specifies 2-4 and gives the reasoning (what `test/multiplayer.test.ts` exercises).
|
||
The two had drifted apart; "6" was never implemented or tested anywhere. D18 now says 2-4.
|
||
- Verified: `test/server/lobby.test.ts` (pure logic — creating, joining, capacity, bot seats,
|
||
host transfer, starting) plus new coverage in `session.test.ts` (bot-driving, including two
|
||
bots in one game) and `web.test.ts`. A live smoke test through `curl`: create a lobby, join a
|
||
second player, start, submit intents from both (including the wrong-actor rejection and an
|
||
idempotent resend), reconnect after a real server kill-and-restart, a bot-filled coop lobby
|
||
starting and never stalling on the bot's seat, and a disconnect/reconnect presence notice
|
||
observed on an open stream. **Not verified: an actual browser** walking through the lobby
|
||
screens — none is available in this environment, the same limitation Phase 2's `RemoteSession`
|
||
shipped under.
|
||
|
||
---
|
||
|
||
## Display
|
||
|
||
- [ ] **THE BOARD STILL DOES NOT SAY WHICH WAY A HEAVY GRADE CLIMBS.** RAR, twice: "grade should
|
||
tell you which way is up." `gradeUp` is dealt at setup and drives which of Helpers or
|
||
Brakeman/Airbrakes can ever pay, and Gitea#3 made it matter more — the modifiers now move a
|
||
train's STARTING REGION, so playing the wrong one is three Stages of climb instead of two. The
|
||
tooltip says it (`mainlineDescription`); the map 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 `auto` means 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.beginUnload` gates on
|
||
`allows.inbound`, a loaded car, an empty of that type in the Division Yard and room in the red
|
||
box — but never on `facilityCarTypes(f)`, which `freightAgent.stockOutbound` does check. So a
|
||
Freight House (boxcars) will unload a hopper. Found reading the code for the v0.4.9e district
|
||
rule, not from play. Low impact today because the district rule now refuses the only same-Office
|
||
pairing that made it easy to hit, and because the bot spots matching cars — but it is a rule the
|
||
engine states in one direction and not the other.
|
||
|
||
---
|
||
|
||
## Other
|
||
|
||
Doesn't fit the above.
|
||
|
||
- [ ] **THE DECK IS `docs/Deck cards5.xlsx` EXACTLY, BAR TEN CARDS THAT ARE NOT BUILT.** Gitea#14,
|
||
2026-08-26. Card for card, **84 rows agree with the sheet** and the only ones that do not are
|
||
the ten it adds that we have never implemented — Cargo Theft, Civic Improvement, Civilian
|
||
angel, Delayed Clearance, Flares 2, Robbery, Service Delays, Shipper complaints, Strike,
|
||
Union Hall 2: **12 copies**, held out on Jesse's instruction until they are built. Gitea#12
|
||
partly specifies the Inspections among them.
|
||
|
||
What landed: track halved; the Q12 office doubling and the Gap 12 industry tripling both
|
||
removed; Interlocking 2→1, Water column 2→1, ABS Signals 2→1, Red Flags 5→3. **Everything
|
||
sheet 5 does not list is dealt 0 copies rather than deleted** — the Telegraph/Telephone/Radio
|
||
dispatching ladder, Facing Point Locks (Enhancement and Mainline both), Flying Switch, Section
|
||
House, Vandalism, all confirmed by Jesse as deliberate removals from the design, plus Poling
|
||
and the sharp curves which were already there. The rows and their rules stay, so the design
|
||
stays visible and each mechanic works the moment it is dealt again. Deck 206 → **121** dealt.
|
||
|
||
Measured, 100 games, developer bot: revenue per player **−0.2 → +0.4**, trains scheduled
|
||
1.3 → 1.6, cards played 16.8 → 13.0. Freight share fell 9% → 4%, and part of that is Flying
|
||
Switch going to zero — it was a freight mechanic. Worth a look if freight is meant to carry
|
||
more.
|
||
|
||
**NOT A DISCREPANCY, though it looks like one in a card-by-card diff:** Second Section is on
|
||
neither sheet and is not a drawn card here either. It is a New Train phase intent
|
||
(`newTrain.secondSection`), and the `copies: 1` on the `SECOND_SECTION` constant is vestigial —
|
||
nothing deals it. Worth removing that field so the next diff does not flag it again.
|
||
|
||
**The deck reads 40% track against the sheet's 31%**, and the whole of that gap is the ten
|
||
held-out cards concentrating everything else. Building them moves the ratio to the sheet's on
|
||
its own, which is why the share is held to a loose band in `setup.test.ts` rather than pinned.
|
||
|
||
- [ ] **FIVE TEST FIXTURES PINNED A SEED AND MEANT "A GAME LIKE THIS".** All five broke on Gitea#14
|
||
and none of them was about card counts — the deck's SIZE moves the RNG stream, so changing it
|
||
re-deals every fixture that names a seed. Fixed in place: `mainline-cards` now searches for a
|
||
Division holding a single-track card, `multiplayer` for a game that reaches Day 3, `web` clicks
|
||
every play verb rather than assuming the first one goes on the board, and the two `sim`
|
||
commodity samples were re-measured (tank is first set out at game **216** now, unload Revenue
|
||
at game **46**). The `web` fix is on BOTH lines — it broke on `main` at the next count change,
|
||
exactly as predicted. `multiplayer`'s seed search is still playtest-only; port it when
|
||
convenient.
|
||
|
||
A sixth turned up when the dropped cards went to zero: `mainline-cards`' `hand()` helper threw
|
||
if the card it wanted was not in the deck, so zeroing Flying Switch took five passing tests of
|
||
an UNCHANGED rule down with it. It mints a card that is no longer dealt now — which is the
|
||
point of keeping a row at zero, and the same will hold for the ladder if anyone tests it.
|
||
|
||
- [ ] **THE 0.4.9 PLAYTEST LINE IS BEHIND ON A RULES RULING, and that was checked rather than
|
||
assumed.** Recorded 2026-08-23, when Jesse asked whether any of v0.7.0 needed porting to the
|
||
`playtest` branch. Almost none of it does — that line has no lobby and no server, and the
|
||
`undo()` config fix is inert there because its `GameConfig` carries no victory dials, so
|
||
`configWith` only ever varies the house rules the save already restores.
|
||
|
||
**But one v0.5.0 ruling is a CODE difference the testers do not have.** §A.4, the Local's
|
||
coach — so on that build a coach still may not be set out at the Office.
|
||
|
||
**IT IS THREE SITES, NOT ONE.** This entry named only the first until 2026-08-25, when a full
|
||
branch diff found the other two. Porting just the `apply.ts` line would leave the build in a
|
||
WORSE state than either line is in today: the coach could be set out at the Office and the next
|
||
arriving train would then collide with it.
|
||
|
||
1. `src/engine/apply.ts` — `main` reads `if (dropRules.coachStaysOnStationTrack &&
|
||
cut.some(coach) && !atOffice)`; `playtest`'s is the same line **without `&& !atOffice`**.
|
||
This is the one that refuses the drop.
|
||
2. `src/engine/track.ts` — `canDropCarsAt(area, coord, count, coachesOnly)` takes a fourth
|
||
`coachesOnly` parameter on `main` and returns the Office square as droppable when it is set.
|
||
`playtest`'s signature has no such parameter and returns `false` for the Office outright.
|
||
3. `src/engine/advance.ts` — the §8.3 "cars fouling the Running Track" check. `main` reads
|
||
`officeCard.standing.some((c) => c.type !== 'coach')`, so a coach parked at the Office is
|
||
not a hazard to the next arrival; `playtest` reads `officeCard.standing.length > 0`, which
|
||
collides with anything standing there. **This one is behavioural and easy to miss** — it is
|
||
in a different file from the drop rules and reads as a collision fix rather than a coach one.
|
||
|
||
The other two questions the 0.4.9 README calls open are documentation-only there (Poling is
|
||
already at 0 copies; Heavy Grade behaves identically — both lines run the same
|
||
`rng.nextInt(2)`, re-verified 2026-08-25 — and it even deals the Mainline deck without
|
||
replacement, so the correction applies word for word).
|
||
|
||
**Jesse's call, 2026-08-23: do not port it now.** A settled rules change is not a playtest bug
|
||
fix, and pushing one into the build people are mid-playtest on would invalidate the feedback
|
||
that build exists to collect. Recorded so the divergence is a decision rather than a surprise —
|
||
and so the 0.4.9 README is not "corrected" to match `main`'s wording, which would then describe
|
||
behaviour that build does not have.
|
||
|
||
- [ ] **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 of `Trains3.pdf`, deliberately frozen at what the design SAYS),
|
||
`docs/Trains3.pdf` (the artwork), and `docs/rules/card-reference.md` (an invented placeholder
|
||
catalogue, banner-marked SUPERSEDED, whose train table still looks authoritative if you land in
|
||
the middle of the file). Every hand-maintained one of those drifts the moment a card changes,
|
||
and this release proved it.
|
||
|
||
**The deliverable, at minimum: a reference document for every card that can be played**,
|
||
generated from `content.ts` so it cannot disagree with the game. Sections, in order:
|
||
|
||
1. **Mainline cards** — the Division's own deck, dealt at setup rather than held in hand.
|
||
2. **Home Deck Cards — Trains**
|
||
3. **Home Deck Cards — Track**
|
||
4. **Home Deck Cards — Industry**
|
||
5. **Home Deck Cards — Modifiers**
|
||
6. **Home Deck Cards — PVP**
|
||
|
||
Each card wants its name, what it does, where it may be placed, and — the part only the
|
||
implementation knows — **whether its printed effect actually resolves yet**. `content.ts`
|
||
already carries that last one for Enhancements (`EnhancementRule.effect`, live / dormantSolo /
|
||
unbuilt, each row citing the file that reads it); the same honesty is what makes a generated
|
||
reference worth more than a transcription. `enhancementText()` and `mainlineDescription()` are
|
||
the model: prose composed from the data, so a tooltip cannot drift from the rule it describes.
|
||
|
||
**Deliberately NOT including card counts per category.** 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's `speechSynthesis`, so
|
||
it is whatever system voice the player happens to have — a robot, not a conductor. A real
|
||
clip is the single biggest improvement available here.
|
||
- `arrive` (a train pulling into an Office), `depart` (a train highballing out of one) and
|
||
`crash` (§10 — a collision) are now synthesised too, v0.4.9 — three chuffing/screeching cues
|
||
built from the same oscillator-and-filtered-noise toolkit as `stage`, wired to `trainArrived`,
|
||
`trainHighballed` (Office departures only), and `trainsDestroyed`. Good enough to keep as the
|
||
real thing rather than a placeholder — no WAV clips needed for these three.
|
||
- **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing.
|
||
**Every file added needs three things recorded alongside it: the sound file itself, its
|
||
source (where it was obtained from), and its license.** The preferred license is **CC0
|
||
("Creative Commons Zero")** — a public-domain dedication: the creator waives all copyright
|
||
and related rights, so the file may be used, modified, and redistributed for any purpose,
|
||
including commercial, with **no attribution required and no restriction**. That is the
|
||
cleanest fit for a file committed straight into the repo, since it needs no attribution to
|
||
track going forward. Only fall back to an equally-permissive alternative (e.g. a license that
|
||
explicitly permits redistribution with no ongoing obligation) if CC0 isn't available for a
|
||
given sound, and record that license's actual terms plainly rather than assuming they match
|
||
CC0. Files also need to be small enough to commit, and a check that the "fetches nothing
|
||
external" test still passes — assets must be served from the site's own folder, never
|
||
hot-linked.
|
||
- Keep the synthesised versions as the fallback for anything not sourced, so a missing file is
|
||
a quieter game rather than a broken one.
|
||
- [ ] **Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions,
|
||
deriving position from what the crossing already cost. The engine still models a crossing as a
|
||
countdown of Stages, so two things printed on the cards remain unimplemented:
|
||
- `entryPoints` is declared on every Mainline profile and read nowhere. The Heavy Grade card
|
||
has five named Start positions, and playing Brakeman is supposed to move your entry point
|
||
along the card. The engine gets the same ANSWER by taking a Stage off the crossing, which is
|
||
why the derived drawing looks right — but the mechanism is not the printed one, so a card
|
||
whose starts do not correspond to its speed would be drawn wrong.
|
||
- `implications.md` §6 calls this "the single largest mechanical gap" and asks for typed cards
|
||
with speeds and named entries, with crossing time DERIVED from the region walk.
|
||
Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the
|
||
freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the
|
||
source Start-position art for the ten card types before it can begin.
|
||
- [ ] **`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 across `card-reference.md`, `glossary.md`, `rules-v0.2.md` and `open-questions.md`) on
|
||
Jesse's explicit call. Checking `content.ts` while making that change turned up that
|
||
`mineTipple` and `powerPlant` are ALSO base 1 out/in + 1 Laborer in the engine — the same
|
||
uniform model as the three that were corrected — while `card-reference.md` still 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 in
|
||
`card-reference.md` rather 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: `exploreMoves` gates every hop on `joins`, which tests both ports AND
|
||
that two 45° legs lie on the same diagonal — never a bare pair of `hasPort` calls. That was already
|
||
true; `track.test.ts` now 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. `canPlaceAt` keeps 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.
|
||
|
||
- [x] **Put rolling stock back into circulation.** The Classification Yard was write-only — seven
|
||
writers, no readers — so 37% of all rolling stock left the game by Day 5. Returning it at the
|
||
Day boundary is **+2.32 ± 0.52 (t = 8.79)**, the largest single change measured on this bot,
|
||
and it was ranked THIRD and predicted not to matter because the Division Yard never runs dry.
|
||
The aggregate was the wrong measure; having the right commodity at the right moment is what
|
||
counts.
|
||
- [x] **Make Enhancements reachable at all.** The bot never laid a straight on the Running Track
|
||
(0.00 in 100 games) because two-arc run-arounds do not need one — so 13 of the 18 Enhancement
|
||
cards had nowhere to go, including Interlocking, the only cure for the only penalty in the
|
||
game (`no free A/D track`, 27% of gross). One scored straight fixed it: enhancements placed
|
||
0.64 → 3.01, collision cost 2.70 → 1.91, worst game −47 → −24. Revenue +0.70 ± 0.74 paired
|
||
over 400 seeds — real but not significant alone; the variance reduction is the clearer win.
|
||
- [x] **Stop the bot discarding its own freight.** `canStockProductively` did not check the Division
|
||
Yard while the engine's `stockOutbound` does, so Freight Agent was chosen when nothing could be
|
||
stocked and the follow-through fell through to an unjam that threw a waiting load out of the
|
||
green box — 3.10 a game against 2.71 started. Now 0.00. Revenue 6.0 → 6.5, wins 5 → 8 in 100.
|
||
Also confirmed **routing was never the problem**: 0% of drops land on a facility that does not
|
||
want the car.
|
||
- [x] **Why switching work did not become Revenue.** Answered: it was the freight the crew shuffled,
|
||
not the shuffling. The chain never leaked — 95% of started loads finished — it was barely
|
||
entered, because a load needs a matching empty car spotted and half the industries never asked
|
||
for one. Three fixes later (sidings, facility placement, car selection) revenue is 3.2 → 6.0
|
||
and freight 26% → 37% of gross.
|
||
- [x] **Fix car selection.** Three of six industries were invisible to `wantedCars` — a hand-written
|
||
industry→car map naming two industries that do not exist and omitting three that do — so tank
|
||
cars were dropped **0 times in 100 games**. Derived from `INDUSTRY_PROFILES` now, and the
|
||
second commodity of the two-commodity industries is reachable. Revenue 5.0 → 6.0, freight
|
||
share 25% → 37%, wins 1 → 5 in 100.
|
||
- [x] **Put the industries on the run-around.** Facility placement was unscored — the first legal
|
||
square — so 0.00 facilities a game sat on a loop; now 1.08. The instructive part was the
|
||
second bug: scoring facilities onto the siding row dropped run-arounds 91→36, because the
|
||
anchor test asked a card's KIND rather than its PORTS and an industry in the line read as a
|
||
dead end. Revenue 4.1 → 5.0. Freight did **not** follow, which is the item above.
|
||
- [x] **Make the bot build sidings that are sidings.** 0 run-arounds in 100 games → 91. Three bugs,
|
||
all scoring on local shape without checking it reached anything; the decisive one was that
|
||
`bestTrackLay` never declined a piece, so it spent the track supply on whatever was legal.
|
||
- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction
|
||
decides which car is droppable; the bot runs around rather than setting out, when the drop can
|
||
follow. Switching activity transformed, revenue unchanged.
|
||
- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing
|
||
reached north, so a district could only be a vertical column. Now two-port rotatable arcs.
|
||
- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a
|
||
card when §6.2 was written; a 26-piece supply has no hand to bound it.
|
||
- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player
|
||
sets orientation", so it is a property of the placed card, not a compass constant.
|
||
- [x] **§6.2's reshuffle.** Implemented, and the `deckReshuffled` event it had already declared and
|
||
narrated — but never emitted or reduced — is now real. Not yet reached in play: solitaire
|
||
Campaign ends with 168.8 of 243 in the deck and four-player Campaign with 86.6, and no run of
|
||
any length has emptied it. It is a safety net rather than a live mechanic today, which is worth
|
||
knowing before tuning draw rates.
|
||
- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled
|
||
instead.
|
||
- [x] **~~CLEARING AN INBOUND BOX MINTS A CAR — measured at 1.29 a game against a supply of 80.~~
|
||
Re-audited in v0.4.3: rolling stock is EXACTLY CONSERVED, 100 games out of 100, range 0..0.**
|
||
The old audit's premise was right — the two directions were not symmetrical — but the asymmetry
|
||
has since been closed from the other end. `unloadBegan` and `passengersDetrained` now take their
|
||
replacement empty OUT of the Division Yard rather than conjuring it, so a load is a car that
|
||
moved rather than a car that appeared: one leaves the yard, one arrives in Classification.
|
||
Jesse's description of the tabletop procedure confirms this is the intended model — the token
|
||
you push along the MEN|AT|WORK sign IS the car, fetched from the yard by the Freight Agent and
|
||
swapped onto the industry track at the end.
|
||
Both conjuring fallbacks now **throw** rather than minting, so the leak cannot silently return;
|
||
neither fired across the suite or 100 audited games.
|
||
**`ROLLING_STOCK_SUPPLY` is therefore unblocked** — it was waiting on this and can now be tuned
|
||
in the rebalance pass. (The first audit's own arithmetic was off in the same way mine was on the
|
||
first attempt: cars set out on a card, in `card.standing`, are easy to leave out of the count and
|
||
make a conserved game look like a leaking one.)
|
||
- [x] **`state = fold(events)` was not true, and the docs said it was. Settled: the INTENTS are
|
||
canonical.** Measured before deciding — `advance.ts` never calls `reduce`, so **14 of the 46
|
||
event types are never reduced**: the clock, and the entire Mainline phase, which is every train
|
||
movement in the game. Folding the log rebuilds a district and not a railroad. Jesse's call, and
|
||
the cheap one: the plan never needed fold — persistence is `{ engineVersion, seed, config,
|
||
history }` (`multiplayer.md` §10) and the wire carries `Frame`s, not events (D2/D3), so
|
||
reconnection is a fresh Frame rather than an event tail. Making the phase driver reduce would
|
||
have been a rewrite of the most rule-dense code in the project to buy something nothing uses.
|
||
Corrected in the README, four architecture documents and six source comments; `test/events.test.ts`
|
||
pins the unreduced set so that closing the gap later is a deliberate act, and asserts the
|
||
property that does hold. **If you ever do make the phase driver reduce, that test fails and
|
||
tells you which docs now understate the engine.**
|
||
- [x] **Measure with error bars from now on.** Built: `node src/sim/compare.ts 1600 <tweak>=<n>`
|
||
runs the current bot and one variant over the same deals and reports the paired difference.
|
||
Pairing drops σ from ~9 on the level to **5.3 on the difference**, so 1600 seeds gives ±0.13 in
|
||
about 1m45s — the noise floor is now ~±0.15 rather than ±1.0. Threshold to keep a heuristic is
|
||
**t ≥ 3**, and the report prints the better/worse/identical split beside the mean, because a
|
||
mean carried by a skewed tail is a different claim from broad improvement.
|
||
- [x] **Confirm the Classification Yard rule against the source.** Confirmed, and the guess was
|
||
wrong. The rule is: used Rolling Stock to the Classification Yard, used engines and cabooses
|
||
straight back to the Division Yard, and the Classification Yard empties ONLY when the Division
|
||
Yard is bare — then all at once. The Day-boundary version I had invented was far more generous
|
||
and worth **+2.42 revenue a game the game does not actually grant**. Corrected; revenue 9.67
|
||
→ 7.25.
|
||
- [x] **Enhancements are placed but mostly do nothing.** Measured: forbidding every Enhancement
|
||
except Interlocking is worth **-0.01 ± 0.41 (t = -0.04)** over 400 paired seeds. They neither
|
||
pay nor cost. Left alone. Unlocking the Running Track straight put
|
||
nine kinds on the board (telegraph 0.73, waterColumn 0.57 …), but only Interlocking has a
|
||
measured effect. The Telegraph/Telephone/Radio chain adds to the other train's number when
|
||
dispatching facing trains, which may be worth nothing in solitaire; Water Column removes a
|
||
Watertower; Facing Point Locks prevents Derail, which is multiplayer-only. Worth measuring
|
||
what each is actually worth before the bot spends actions on them.
|
||
- [x] **`stats.ts` undercounts freight.** Fixed: both halves counted, freight share 39% → 49%.
|
||
Worth revisiting the **industry density** decision in Play Balance, which was taken on the old
|
||
number.
|
||
- [x] **LEFT AND RIGHT ARE ON THE WRONG DIAGONAL — for turnouts and for curves, the same way.** The
|
||
engine's `left` turnout is `{stem:'w', through:'e', diverge:'s'}`: a train entering at the
|
||
points from the west heads east and the diverging route leaves to its **right**. The engine's
|
||
`left` curve is arc `sw`, which turns an eastbound train **right** as well. One consistent sign
|
||
error in the hand↔diagonal mapping, and it mislabels every track card a player ever holds.
|
||
**The artwork is right and does not change** — `board-svg.ts:462` draws rails from
|
||
`connectionsFor()` geometry alone, so only words are wrong. **Decision: flip the `hand` value on
|
||
the `TRACK_CARDS` rows AND the two variant tables in the same commit**, so the code keeps
|
||
speaking left/right like the physical supply and now means it. Keep the **row order** in
|
||
`TRACK_CARDS` untouched: `setup.ts:95` builds the deck by iterating that array, so flipping only
|
||
the labels leaves pre-shuffle slot 32 holding a `ne_sw` curve before and after, and
|
||
`variantsFor(…)[0]` still `'sw'` — same seed, same board, and every published replay still
|
||
plays. Also: `track.ts:126` arc fallback, `track.ts:191-212` doc block, `view.ts:1041` and
|
||
`view.ts:1208` diagonal phrases, `bot.ts:837-852` `arcInHand`, seven test files, and the prose
|
||
plus ~14 `data-tip="Turnout · left"` strings in `docs/design/track-geometry.html` — which has no
|
||
generator and must be hand-edited. Verify by fingerprinting a fixed seed's board before and
|
||
after: identical geometry, different words.
|
||
- [x] **A turnout should be playable as an UPGRADE, on top of a card already down.** On a straight,
|
||
or on a curve whose arc matches the turnout's diverging leg. Nothing like this exists — the only
|
||
"upgrade" in the game is the Office tier change, which is explicitly not a card swap
|
||
(`apply.ts:1290`), and `canPlaceAt` hard-stops at `if (existing && !isMovableSign) return false`
|
||
(`track.ts:518`). **Decision: cars AND enhancements both block it** — `standing.length > 0` →
|
||
`UPGRADE_OCCUPIED`, `enhancements.length > 0` → `UPGRADE_ENHANCED`, so an Interlocked straight
|
||
stays a straight. Express the curve rule on **arcs, not hands**, so it survives the flip above.
|
||
No extra connection requirement: all four turnout orientations are port supersets of a straight
|
||
and of any same-arc curve, so an upgrade can never sever an existing join, and the new leg is
|
||
allowed to dangle — that is what it is for. The lifted card leaves play, which is already how
|
||
board cards behave (`apply.ts:1249` salvages only cards that were *not* placed). Reuse
|
||
`card.play` with a placement on an occupied square; `legal.ts:252` must offer those squares for
|
||
turnouts, and the `attachments` set at `legal.ts:137` is already exactly that list.
|
||
**The other half of this report needs no work:** a turnout carries the through route
|
||
(`carriesThroughTrack`), so it is already legal at a Limit, along the Running Track and on
|
||
Secondary Track. Confirmed, not re-investigated.
|
||
- [x] **A Depot shows three MEN AT WORK boxes it can never work.** Offices are Passenger Facilities —
|
||
no freight — but `setup.ts:176` gives every one a three-slot `menAtWork` array, and the three
|
||
renderers loop it with no guard while the green and red rows beside them *are* guarded
|
||
(`board-svg.ts:548`, `panels.ts:218`, `replay.ts:522`). **Decision: all three tiers** — Depot,
|
||
Station and Terminal are all Passenger Facilities and all get `laborers: 0`, so the boxes are
|
||
inert on every one. **Fix it in the model, not the renderers**, so it cannot reappear in a
|
||
fourth place: make `menAtWork` nullable and null for passenger facilities, which is what the
|
||
"Freight only" comment at `state.ts:153` has claimed all along. Freight handling must be neither
|
||
allowed nor displayed there. Two more artifacts of the same "an Office is a Facility" modelling
|
||
go with it: `board-svg.ts:562` calls a Depot **"SHIPS + RECEIVES"**, an industry's flow word,
|
||
and `board-svg.ts:599`'s `Math.max(1, trackCap)` draws it a siding slot although its industry
|
||
track has length 0. `FacilityView` needs a `kind` field; it has no freight/passenger flag today.
|
||
- [x] **Two Ice Houses can be built in one district.** Industries already ban duplicates per Office
|
||
Area — `isLockedOut` (`apply.ts:1774`) covers the Mine Tipple half of the report — but **Ice
|
||
House is a Modifier, not an industry**, and `checkPlay`'s modifier branch (`apply.ts:662`) has
|
||
no duplicate check at all. **Decision: extend the ban to modifier kinds, leave enhancements
|
||
alone.** An Interlocking is a plant at one junction, so a second on another straight is a
|
||
different installation, and the Telegraph → Telephone → Radio chain is already gated per card.
|
||
Expect modifiers with `copies > 1` to go partly dead in solitaire, where there is one Office
|
||
Area — correct, since the spare copies exist for other players' districts.
|
||
- [x] **A drawn card lands at the far end of the hand with nothing to mark it.** `apply.ts:1219`
|
||
pushes, the hand renders in state order (`game.ts:355`), and with `flex-wrap` the newest card is
|
||
exactly where the eye is least likely to be. **Decision: reverse in the display layer, not the
|
||
engine** — `view.ts:898-899` (reversing `hand` and `handWhat` identically, or they desync)
|
||
covers the replay viewer and the standalone replay together, and `game.ts:355` covers the play
|
||
page. Keeping `hand.push` means the bot's option-iteration order does not move, so every revenue
|
||
figure in this file stays comparable; an engine `unshift` would invalidate the lot. The marker
|
||
is play-page only — a `Frame` carries card names, not ids, so a replay cannot say which card
|
||
arrived that step. Follow the `game.scheduled` precedent for a `justDrawn` field, but make the
|
||
badge **persist** rather than flash: it says *which card is new*, not *something just happened*.
|
||
A static `::before` badge as `.handcard.target` does it (`panels.ts:262`), no keyframe — the
|
||
innerHTML rebuild would restart an animation on every render. Clear it in `renderUndo()` and
|
||
after `fromSave()`, or a fresh page load badges last session's draw.
|
||
- [x] **The inbound boxes render green in the side panel.** Green is outbound and red is inbound
|
||
everywhere the colour carries direction — `board-svg.ts:528-545`, `replay.ts:452`, rules §9.1 —
|
||
except `panels.ts`, whose shared `boxes()` helper (`panels.ts:162`) emits class `f` for every
|
||
filled box regardless of direction, so the red row at `panels.ts:222` comes out green. The board
|
||
SVG on the same page draws it correctly, which makes the panel actively contradict the board.
|
||
Give `boxes()` its class from the caller as `replay.ts` already does, add `.box.r` in
|
||
`board-svg.ts:820`'s palette, and take the siding off green in both panels and `replay.ts:525`.
|
||
- [x] **An Interlocking on the board is a bare label, and it does nothing.** The enhancement text is
|
||
drawn with no tooltip (`board-svg.ts:651`) while the copy already exists as data in
|
||
`ENHANCEMENT_CARDS` (`content.ts:589`). Done: every card prints its effect, and the one nothing
|
||
reads says so. **CORRECTED — the first pass had five of the ten statuses wrong.** It claimed
|
||
only Telegraph/Telephone/Radio were live, because the survey grepped for four helper function
|
||
names and read "no match" as "no implementation". In fact **seven are live**: those three plus
|
||
Interlocking (`advance.ts:770`), Yard Office (`advance.ts:750`), Small Yard (`apply.ts:405`) and
|
||
ABS Signals (`advance.ts:599`, stored on the Mainline node). Facing Point Locks and Water Column
|
||
are wired but dormant in solitaire; **Overpass alone has no code path at all**. The shipped
|
||
tooltip briefly told players four working cards did nothing, which is worse than the bare label
|
||
it replaced — `enhancements.test.ts` had passing tests for all four the whole time.
|
||
- [x] **A modifier's grant can land on a direction its host cannot use, and nothing says so —
|
||
corrected in v0.4.7, and the correction was itself half wrong.** *(v0.4.9e: the ORIGINAL verdict
|
||
was right about the Grocer's.* A Grocer's Warehouse **is** inbound-only — gameplay testing said
|
||
so and Jesse confirmed it — so the Ice House's outbound grant beside one is genuinely dead, the
|
||
way the Truck Dock's inbound grant beside the outbound-only Packing Sheds is. What survives from
|
||
v0.4.7 is the machinery and the decision behind it: an industry's printed flow is absolute, the
|
||
grant is dropped rather than the direction opened, and `suppressedGrants` says so on the page.
|
||
What does not survive is opening the two facilities up. Original v0.4.7 note follows.)*
|
||
The earlier "no bug" verdict below was wrong. The reasoning had been that
|
||
a Grocer's Warehouse is inbound-only. It is not — the card reference says "Both" — so the grant
|
||
was being dropped on a direction the facility should have had. Reported again in play as
|
||
"grocer's warehouse didn't get extra outbound slot for truck dock". The suppression machinery
|
||
itself was right and is kept: it still fires for a passenger Modifier beside a Whistle Post,
|
||
which is not a Passenger Facility — and v0.4.7 gives that grant BACK when the Office is
|
||
upgraded, which it never used to. Original note follows.
|
||
Reported as "Ice House added the laborer but not the outbound slot" — **checked, and there is no
|
||
bug**: Ice House prints +1 *outbound*, a Grocer's Warehouse is `flow: 'inbound'` so
|
||
`allows.outbound` is false, and the capacity was raised on a direction that can never render or
|
||
be stocked. The laborer arrived because laborers have no direction gate. **Decision: an
|
||
industry's printed flow is absolute** — drop the grant on hosts that cannot use it rather than
|
||
opening the direction up. So `applyModifier` (`apply.ts:1788`) gates each capacity grant on
|
||
`allows` and grows `industryTrack.length` only by what was actually applied. **Audit all 17
|
||
profiles for the same trap:** `iceHouse`, `truckDock` and `forklifts` all print `addOut: 1` and
|
||
list `grocersWarehouse`; `waitingArea`, `restaurant` and `hotel` print `addOut: 1` for
|
||
`hosts: ['office']`, which includes a Whistle Post. Then say it in both directions — the hand
|
||
tooltip naming which printed hosts cannot use which half (computable from the profiles, no host
|
||
on the board needed), and the panel's "prints N, Modifiers add M" line showing a suppressed
|
||
grant as suppressed instead of quietly omitting it. That delta display already cites the Ice
|
||
House as the bug that motivated it.
|
||
- [x] **Q13 — rear-end collisions on a Mainline card.** Answered: collide on catching up.
|
||
Implemented, and not on cards that print "trains may pass". Invisible to a bot that always
|
||
denies clearance; a bot that always allows drops from 7.34 revenue to **-5.13**.
|
||
- [x] **Nine of the twelve special-train rules are declared and read by nothing.** Done — all
|
||
nine enforced, and one of them deleted instead. `copiesNextScheduled` was never carried by any
|
||
train card: a Second Section is a Maneuver with its own working intent, so the flag was an
|
||
unreachable second description of an existing mechanic. Cost 0.8 revenue and half the wins
|
||
(8.0 → 7.2, 14/200 → 6/200), which is what enforcing restrictions does.
|
||
- [x] **Carry `links` forward in replay frames.** Done, and the premise was wrong in an instructive
|
||
way: measured, `links` was 5% of the `cells` payload. What actually cost was the `what` prose
|
||
(32%), the facility object stored a second time inside its own cell (24%) and the rest of the
|
||
static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test
|
||
runs the page's own unpacking function.
|
||
- [x] **Every published replay was dead.** All three replayed **2 intents of roughly 400** and
|
||
presented as short games, exactly as the item below it (now in Replay / Save Games) predicted.
|
||
Re-recorded from bot games with `node src/sim/save-replay.ts`, which verifies each save
|
||
round-trips before writing it, and `harness.test.ts` now fails if a published replay stops
|
||
short. The version-stamp item in Replay / Save Games is still worth doing — this catches the
|
||
breakage, it does not explain it to a player.
|
||
- [x] **Coordinate labels read Y,X on the board — v0.4.9.** Now X,Y everywhere a coordinate is shown
|
||
to a player: the on-card label (`board-svg.ts`), the switching-crew tooltip and button
|
||
(`main.ts`), the rejected-option and switching-group text (`game.ts`, `view.ts`), and the
|
||
blocked-move text (`narrate.ts`). Display-only — `GridCoord{row,col}` already had the right
|
||
geometry (row increases north, col increases east), and internal `Map` keys are untouched.
|
||
- [x] **"No switching" blocked moving a train clear of the mainline — v0.4.9.** The six no-switching
|
||
cards (both expresses, Light Engine, Campaign, Circus, Military) mean may not add or drop cars,
|
||
not may never be touched. `switch.move` now refuses only a move that would couple a fresh car —
|
||
the same way `dropOnly` was already handled — so these trains can still be shunted onto
|
||
Secondary Track to clear the mainline. `switch.dropCars` and `switch.sortConsist` stay blocked.
|
||
- [x] **Q3 corrected: Expedite governs WHERE a train may stand, not WHEN it leaves — v0.4.9.** The
|
||
forced same-Stage departure (`departsThisStage`, the `shiftChange` expedite pass) is gone; an
|
||
expedited train now arrives and is released like any other train, switchable in between. New
|
||
fault instead: left off the Office square when a Mainline Phase begins, it costs 1 Revenue
|
||
(`expediteFault`, `EXPEDITE_FAULT_PENALTY`), every Phase it is still caught there. Resolved
|
||
"3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE" as a side effect — it can now reach the Local
|
||
Operations turn its printed freight rule needs. Revealed a bot gap instead: logged above under
|
||
Bot Performance.
|
||
- [x] **`evaluateClearance` checked only the first occupant it found — v0.4.9.** Found while
|
||
explaining a playtest report: the Superintendent was asked to rule on a same-direction train
|
||
instead of being held against an opposite-direction one also on the card, because the loop
|
||
returned on whichever occupant it examined first rather than checking all of them — invisible
|
||
until the Telegraph/Telephone/Radio exception made it possible for a card to hold two trains at
|
||
once. Now checks every occupant for an absolute bar before offering any judgment call. Pinned
|
||
with a test that fails against the old single-pass code.
|
||
- [x] **The splash page now shows the box art — v0.4.9.** `docs/StationMasterSplashScreen.png`
|
||
(3.0 MB) resized to a 145 KB JPEG (`public/images/`, copied into the build by `build-web.ts`)
|
||
and placed beside the title, tagline, blurb and both buttons in a side-by-side hero, stacking
|
||
to image-above-text on mobile.
|
||
- [x] **A coach may now be set out at the Office — v0.5.0, Jesse's call.** §A.4's blanket "no Rolling
|
||
Stock may be left at the Office" now carries one exception: any train (not just 7/8) may drop
|
||
one or more coaches there; freight and cabooses stay banned. Unlocks the `ENGINE boxcar coach`
|
||
arrangement that used to lock completely — measured at 1,181 refused set-outs over 60 games,
|
||
every one of them this case. `canDropCarsAt` (`track.ts`) takes a `coachesOnly` flag instead of
|
||
refusing the Office outright; trains 7/8's `coachStaysOnStationTrack` rule now forbids the coach
|
||
everywhere EXCEPT the Office, rather than everywhere. The Office's "cars fouling the Running
|
||
Track" collision (`advance.ts`) is exempted for coach-only standing cars, so a legally parked
|
||
coach is not a hazard to the next arrival.
|
||
- [x] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — closed, v0.5.0.** Confirmed already resolved as
|
||
a side effect of the v0.4.9 Expedite fix (see that entry above); removed from Rules Questions.
|
||
- [x] **THE INDUSTRY TABLE VS. THE CARD REFERENCE — v0.5.0, Jesse's call: the engine was right, the
|
||
docs were stale.** `card-reference.md` printed Grocer's Warehouse and Oil Refinery at 2/2 with
|
||
2–3 Laborers, and claimed "'Freight House' is not a card"; the engine already had both
|
||
industries at 1 out/1 in/1 Laborer and already dealt Freight House as a real sixth industry, 6
|
||
copies. Corrected the docs (`card-reference.md`, `glossary.md`, `rules-v0.2.md`,
|
||
`open-questions.md`) to match the engine; no engine change. Turned up that Mine Tipple, Produce
|
||
Shed and Power Plant may be similarly stale — flagged as a new item above rather than assumed.
|
||
- [x] **Poling — closed, v0.5.0.** Confirmed already at 0 copies, the same treatment as Sharp Curves,
|
||
pinned by `mainline-cards.test.ts`. No code change; removed from Rules Questions.
|
||
- [x] **Heavy Grade orientation — asked again 2026-08-23, and the answer did not change.** Raised as
|
||
"did we ever fix Heavy Grade to allow user placement of direction?", with the option of giving
|
||
the choice to the **Superintendent** considered and rejected. Jesse's call: v0.5.0 stands.
|
||
Three things came out of the re-examination and are recorded in `implications.md` §10 Q11 so it
|
||
is not asked a third time — the advantage is permanent while the office rotates, so a rotating
|
||
chooser moves the fairness problem rather than solving it; there is no setup phase to ask in
|
||
(`createGame` is pure, and `pendingDecision` is typed for clearance alone across 19 readers);
|
||
and since v0.6.2 deals the Mainline deck without replacement, only **20%** of solitaire games
|
||
contain a Heavy Grade at all. **The docs were the real defect** — `README.md` listed it among
|
||
three open rules questions, all three of which v0.5.0 had closed, and the Mainline deck
|
||
reference said the implementation "needs a player-selection step". Both corrected; no code
|
||
change, and none wanted.
|
||
- [x] **Heavy Grade orientation stays rolled, permanently — v0.5.0, Jesse's call.** The card prints
|
||
"Player sets orientation", but a Heavy Grade sits on the shared Division chain between two
|
||
players (or beyond an end Division Point, next to one) — never inside one player's own district
|
||
— so there is no single player with a fair claim to the choice. Settled as random from the
|
||
seed, identically for solitaire and multiplayer, overriding the card's print. No code change
|
||
(the roll in `setup.ts` was already doing this); the comments and `implications.md` §10 Q11
|
||
previously framed it as a placeholder awaiting an interactive setup phase — corrected.
|
||
- [x] **Engines are not a separate supply from Crew Trays — confirmed, v0.5.0.** `rules-v0.2.md`:339
|
||
(Gap 4b) ties Crew Trays and engine pieces together as one combined resource, `player count +
|
||
3` — not two independently-tracked supplies. The engine already enforces exactly that via
|
||
`crewTrayCount`/`freeTrays`; `NO_FREE_TRAY` already fires exactly when engine supply would run
|
||
out too. No code change; corrected the comments that called this provisional or unsourced.
|
||
- [x] **Player settings, saved — v0.5.0.** A `localStorage` settings object (`SETTINGS_KEY`, separate
|
||
from the game save) now persists district auto-focus mode, sound on/off, and board zoom level
|
||
across reloads — all three previously reset every time. Falls back to today's defaults on a
|
||
missing, corrupt, or disabled `localStorage`, the same guard the save already had.
|
||
- [x] **The test suite's flakiness under `npm test` — fixed, v0.5.0.** Two changes: (1) a `pretest`
|
||
npm script now builds the shared `dist/` once, before `node --test` runs, so every test reading
|
||
`dist/` no longer depends on another test in the file having built it first; (2) the one test
|
||
that actually exercises the build COMMAND now builds into its own `dist-test/` directory
|
||
(`BUILD_DIST_DIR` env var, `scripts/build-web.ts`) instead of rebuilding the shared `dist/` out
|
||
from under the tests reading it. `dist/` is now single-writer.
|
||
- [x] **Curves and turnout diverging legs now draw as smooth curves, not two straight segments
|
||
meeting at a corner — v0.5.0.** `curvedRail` in `board-svg.ts` replaces the old hard-cornered
|
||
"run to the frog, then a straight 45° leg" with a sampled cubic-Bezier easement: tangent to
|
||
horizontal at the east/west edge (so an abutting straight card's rail still reads as one
|
||
unbroken line) and tangent to exactly 45° at the north/south edge (so two stacked curves still
|
||
read as one continuous diagonal). Both plain curve cards and a turnout's diverging leg go
|
||
through this same code path, so both are fixed by the one change.
|
||
- [x] **Wide boards can now be zoomed — v0.5.0.** Discrete zoom presets (75/100/125/150%) for both
|
||
the district grid and the Division map, applied by resizing the rendered SVG's own pixel
|
||
dimensions (not a CSS `transform`), so the existing `overflow-x:auto` scrollbars keep doing the
|
||
panning with no new gesture code. Persisted in the new settings object above.
|