v0.6.1 — five of six playtest bugs: one button per train, and a load that has to go somewhere

Gameplay testing on 0.4.9d returned six reports. Five are fixed; the sixth could not be
reproduced and is written up in TODO.md with the two questions that would pin it down.

TWO TRAINS AT ONE PLATFORM ANSWERED TO ONE BUTTON. `porter.board` and `porter.detrain`
carried no tray, so there was one button per platform however many trains stood at it and
the reducer filled the first empty coach on the A/D tracks. `check` and the reducer were not
even asking the same question: `check` skipped a train whose card refuses passenger work and
the reducer did not. Both intents now carry an optional `trayId`, one function resolves the
train and the coach for check/execute/reduce alike, `legal.ts` offers one candidate per train,
and the label names it.

A LOAD COULD BE MADE AND BROKEN WITHOUT GOING ANYWHERE. A Freight House could unload the
boxcar it had just loaded; a platform could detrain the passengers it had just boarded. Full
Revenue at both ends for a movement that never happened. Jesse's rule: a load made anywhere in
an Office Area may not be broken anywhere in that Office Area, ever — it has to be carried to
another district. The load carries the seat that made it (`RollingStock.origin`), stripped by
`pooled` at every yard push. Measured at -0.60 +/- 0.10 Revenue a game (t = -6.1) over 400
paired deals: 78 worse, 3 better, 319 unchanged — free Revenue coming off the board, not a nerf.

THE GROCER'S WAREHOUSE SHIPPED AND THE REFINERY RECEIVED. Both were `flow: 'both'` on the
reading that "Freight House" was a collective term for exactly those two, and therefore what
§9.3 described. The engine has dealt a Freight House CARD since before v0.4.9, so §9.3 names
it and the argument goes. The card set agrees: all three Refinery modifiers grant +1 outbound.
Refinery outbound-only, Grocer's inbound-only, Freight House the one two-way industry — which
leaves exactly the one same-district pairing the rule above refuses.

NOT REPRODUCED: cars left behind when backing up over them. Five layouts tried, including cars
spotted at an industry; every one couples the lot. Three are pinned in `apply.test.ts`. One way
to create such cars was closed anyway — `flyingSwitch` wrote its cut past `carsOn`.

Both published replays that had gone dead were re-recorded; a rules change retires a save, and
`harness.test.ts` is what catches it.

The same change ships as v0.4.9e on the 0.4.9 line, branched from the v0.4.9d commit — the engine
files these fixes touch are identical across the two lines, so the patch applied cleanly both ways.

Also carries the two "Queued 2026-08-22, from playing on StartOS" TODO items that were staged
before this work started (Games in Progress readability, and getting back into a game after
losing a browser). They are notes, and items 9-12 below them are numbered against them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
This commit is contained in:
Jesse.Markowitz
2026-08-21 23:54:24 -04:00
co-authored by Claude Opus 5
parent 40f07b0710
commit 83a5450866
25 changed files with 2853 additions and 1811 deletions
+145 -2
View File
@@ -30,7 +30,24 @@ Queued 2026-08-21, from playing the StartOS build:
6. ~~**Stop every release destroying every game in progress**~~ — done in v0.6.0, by replaying the
save rather than comparing version strings.
Nothing else queued at the moment.
Queued 2026-08-22, from playing on StartOS:
7. **Make "Games in Progress" readable** — nested groups rather than one run-on line per game, plus
sorting by name, start time or last move. Reasoning in Multiplayer below. Small, and it is the
action most used for actual administration.
8. **Give a player a way back into a game after losing their browser** — today a fresh browser is
locked out permanently, even though the server still knows who they are. Reasoning in
Multiplayer below; needs Jesse's call on whether a token in a URL is acceptable.
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. **NOT REPRODUCED: cars left behind when backing up over them** — see Rules Questions below. The
one report of the six that is still open, and it needs a board from whoever filed it.
---
@@ -374,6 +391,79 @@ number until the rules stop moving.
Deferred while planning the server; decisions and reasoning are in `docs/architecture/multiplayer.md`.
- [ ] **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?"
**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.** 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.
- [ ] **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
@@ -772,6 +862,45 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
---
## Rules Questions
- [ ] **NOT 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).
**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; 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 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.
@@ -828,6 +957,13 @@ Doesn't fit the above.
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
@@ -1019,7 +1155,14 @@ Doesn't fit the above.
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.** The earlier "no bug" verdict below was wrong. The reasoning had been that
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