v0.7.0 — four game types, a lobby you can read and leave, and a multiplayer game that makes a sound

The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never
been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md.

- Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block —
  they had drifted, and each was missing a question the other asked.
- A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and
  keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is
  in, not just the last one.
- The start of a game is drawn: a handoff beat, an announcement, the code and type in the header.
- Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client;
  justDrawn goes to the seat that drew it and nobody else.
- Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board
  never named the Superintendent, bot seats were reported as absent players, and rule section
  numbers are out of every string a player reads.

Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
This commit is contained in:
Jesse.Markowitz
2026-08-23 05:46:42 -04:00
co-authored by Claude Opus 5
parent 42adfda390
commit 06db36e5b5
41 changed files with 4387 additions and 588 deletions
+244
View File
@@ -19,6 +19,250 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.7.0 — 2026-08-23
The multiplayer set-up, the lobby, the start of a game, and four things a remote client had never
been shown. Jesse's cleanup pass, worked area by area with the design settled before any code was
written.
### Four game types, and Custom
The lobby used to offer two modes and a folded block of numbers; the New Game dialog offered three
modes that only *previewed* defaults. Both now offer the same five: **Solitaire, Co-op, Competitive,
Cutthroat** and **Custom**.
A type is a **set of defaults, not a ruleset**. Picking one fills the whole form; every rule stays
editable; editing any of them selects **Custom**, which keeps the scoring of the type it was edited
away from and says so on screen ("Custom — scored as Co-op · 3 settings differ from Co-op"). Clicking
a named type again resets every rule below the radios.
| | Solitaire | Co-op | Competitive | Cutthroat |
| --- | --- | --- | --- | --- |
| Scored as | solitaire | coop | competitive | competitive |
| Opening hand | six | six | six | six |
| An Extra may start at | any Control Point | your own | your own | **any player's** |
| Passenger / freight / transit | 1 / 1 / 0 | 1 / 1 / **1** | 1 / 1 / 0 | 1 / 1 / 0 |
| Combined Revenue floor | 3 × players × Days | 3 × players × Days | 2 × players × Days | **off** |
| Collisions in one Day | 3 | 3 | 3 | 3 |
| Collisions in the game | 5 | 5 | 5 | **off** |
**The floor is a formula, not a number**, which is why the seed, the table size and the Day count sit
ABOVE the type radios as *parameters*: changing one re-derives the floor rather than making the game
Custom, so "Co-op, 3 players, 8 days" is still Co-op. Once a host types a floor themselves it is
theirs and nothing overwrites it.
**The type is derived, never stored** (`presetOf`, `src/web/presets.ts`). A saved game is its numbers;
a name written beside them is one more thing that can disagree with them. A join preview measures a
Custom game against the nearest type it is scored as, so "Custom" alone is never all a player is told.
**Every field says what its type would have set**, and goes amber when it differs — a non-standard
game should be possible and never accidental.
### The two screens stopped drifting apart
They each carried a hand-written copy of the same form, and had already diverged in both directions:
- **The lobby had no "where an Extra may start" at all**, so every multiplayer game ever played used
the most permissive setting — an Extra could be planted in another player's district — and no host
was ever asked. It is a Cutthroat-only default now.
- **The solitaire dialog had none of the three optional rules**, so Employee Rotation and the
Emergency Toolbox could not be played solo at all. They ride in the URL with everything else now
(`vis`, `rot`, `tool`), and Employee Rotation is disabled at a table of one, where it has no
meaning.
Both blocks are generated from one template and driven by one module (`src/web/settings-form.ts`);
`test/web.test.ts` asserts the built page carries every field on both screens, which is the guard the
shared markup was for.
### The dead PvP checkbox is gone
`buildDeck` ANDs `pvpCardsAllowed` with a hard-coded `cardsImplemented = false`, so the checkbox
could not change anything whatever it was set to — on either screen. The 22 opponent-directed cards
(and the 7 defensive cards held out with them) are a property of the game type now, and where the
control was there is a sentence saying they are not implemented yet.
### Victory conditions lost the magic zero
`0` means "off" to the engine, which is exact and unreadable: a Cutthroat game showed two zeroes and
left the player to know the convention. Each condition is a checkbox with its number now, and the
wording says what actually happens — *"The game ends and everyone loses if collisions in one Day
reach 3"*. Unchecked still writes `0`, so nothing underneath changed.
### The lobby
- **Joining is its own door**, not a heading below fifteen fields a joiner has no use for.
- **A player reads the whole rule set before taking a seat** — `GET /api/lobby/preview`, gated by the
same join secret, taking no seat, and **never carrying the seed**, which decides every shuffle.
- **A seat survives a reload.** The token used to live in a closure and reach `localStorage` only at
`Lobby.Start`, so refreshing while seated orphaned the chair: the player could not return and
nobody could free it, on a table that cannot start until every chair is taken. The record is
written at create/join with a `stage`, and a reload walks `/api/session` then the lobby stream to
land wherever the seat actually is.
- **There is a way out.** `POST /api/lobby/leave` frees a seat; naming somebody else's is host-only,
which is the host's *remove*; the last human leaving deletes the lobby, its code and its index row
rather than leaving a table of bots waiting for a host who no longer exists.
- **The lobby stream can fail out loud.** `onmessage` was the only handler, so a dropped connection
left the seating screen frozen and silent. It now says so, and its probe tells a blip from a dead
lobby — and finds a game that *started* while the connection was down.
- **A stored join secret collapses to one line** and re-opens itself on a 403, beside the field it is
about.
- **Two players may not share a display name** (`NAME_TAKEN`, trimmed and case-insensitive). The name
labels the district on the map, is what the turn chart means by "waiting on Jesse", and prefixes
every line that player causes; the names lock at `Lobby.Start`, so the refusal has to be at the
door. Refused rather than suffixed: a player should play under the name they chose.
- **Bots are numbered in the seating list** the way `startLobby` will number them.
- **The code copies as a code and as an invite link** (`?lobby&code=…`) — the link never carries the
join secret, which travels out of band by design.
- **Server codes became sentences** in a red block instead of `.dim` grey: `LOBBY_FULL` was being
printed at players verbatim.
- **Everyone at the table can see what they are about to play**, not just the host who typed it.
### The start of a game, which nobody had ever drawn
`beginRemote` wrote "… connecting to the game" into `#presence` — the DISCONNECT banner — and it
worked only because the first render overwrote it. The handoff is its own state now: a curtain with a
deliberate beat, a stall message if the board never arrives, then an announcement naming the game and
its type, and a marker at the top of the log so the bots' opening turns are visibly *after* the start
rather than merged into it. The **game code and the game type are in the header** for the rest of the
game — the code used to end at the lobby door, and the type was never on the `Frame` at all, so a
Cutthroat game looked exactly like a Co-op one from the board. Start cannot be pressed twice.
### A client is told who else is at the table
`/api/stream`'s connect push carried the board, the menu and the log — and nothing about anybody
else. `broadcastPresence` only ever reports a CHANGE, so a player learnt of a seat only if it dropped
*after* they connected: at a table where two people had not opened the game yet, the screen said
nothing at all. Every other seat now rides on the connect push, with `seen` separating **was here and
dropped** from **has never opened the game** — the first will probably be back, the second needs
somebody sent a link.
### The four transient signals reach multiplayer
`createRemoteSession` answered all four with empty values, so a game on a server had **no sound at
all**, no flash on the timetable slot a D12 had just filled, no announcement when a completed run
paid the table, and no badge on the card you had just drawn. All four ride on the push now.
The shared three (cues, the flash, the announcement) are drained once per broadcast and are identical
in every seat's push — a collision anywhere on the Division, the Stage bell and a train leaving the
Division are the table's, not one player's. `justDrawn` is **not** shared: `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. A reconnect gets the badge back but none of the shared three: a fresh
connection is drawing a state, and replaying the sounds of everything it missed is a burst of noise
about the past.
Draining them also fixed a slow leak nothing had noticed: `game.cues` grew without bound on a server,
because the only thing that ever emptied it was a local session's render.
### Two smaller fixes found on the way
- **Undo re-dealt a game under the wrong victory conditions.** `undo(game)` defaulted its config to
`SOLO_CONFIG`, and `Save` carries only the house rules — so undoing a move in an eight-Day game
replayed it as a five-Day one, with the Revenue floor and both collision caps reverting too. It
defaults to the game's own config now.
- **The page deals the Solitaire game type, not the engine's fallback.** Every type opens with six
cards (Jesse's call), but `SOLO_CONFIG` deliberately did not move: every engine test and every sim
measurement is taken against it. What a player is dealt when they open the page is the type.
### Verified by running it, not by reading it
A real server, a real lobby and a real game — which is where two of these came from:
- **A bot seat was being reported as a disconnected player.** Every screen would have carried
"waiting on Bot 1 — not here yet" for the whole game. Bots hold no connection and never will, so
they are not reported at all.
- **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 the six-card deal creates — the risk
worth checking before making six the default everywhere.
Also exercised end to end: preview before joining (and its 403/404s), the duplicate-name refusal,
leave freeing a seat, a non-host being refused somebody else's chair, the last human closing the
lobby down to its index row, and every one of the four signals arriving at two seats at once.
`test/presets.test.ts` pins the numbers in the table above, `test/server/session.test.ts` pins the
`justDrawn` boundary, `test/server/lobby.test.ts` covers leaving and the name rule, and
`test/web.test.ts` gained a suite for the lobby screen, which had none at all.
### Found by playing it on StartOS, and fixed in the same release
Everything above was written before the build went on a box. These came out of Jesse playing it:
- **An Extra belongs to the player who played it.** §7 gives an Extra to whoever played the card —
they place the Crew Tray and load the consist as they choose — while a TIMETABLED train's consist
is built by the table, starting at the Superintendent and working left. `pendingExtras` was a bare
`number[]`, so nothing recorded whose Extra it was and the phase asked whoever the acting order
happened to be on: right in solitaire, wrong at every table. It carries the player now, the tray
remembers who is building it (`builtBy`), and another seat is refused with `NOT_YOUR_EXTRA` and is
not even offered a start point. **No migration**: a save is a seed plus intents, so a `GameState`
shape change costs nothing.
- **The board never named the Superintendent.** Reported as "seat 1 played a train card BUT seat 2
was prompted to build the train — what's the rule?" The engine was right; the screen simply never
said who held the Fedora, so the question had no answer on it. `Frame` has carried `superintendent`
since v0.4.0 and only the standalone replay ever drew it. It is a chip on the turn chart now,
shared by all three screens and hidden in solitaire, and the New Train pill's tooltip says who
builds a consist rather than only what the phase does.
- **A player can leave a running game**, keeping their seat and their token, and the lobby lists
every game the browser is in with Rejoin and Forget. This fixes something worse than what was
asked for: `localStorage` held exactly ONE session, so joining a second game overwrote the first
token and locked that seat out permanently — `TODO.md` had it as the nearer half of the lost-token
problem. Leaving closes the SSE stream too, so the table sees the seat go quiet instead of being
told somebody is present who is not.
- **`?lobby` now beats resuming.** The splash's multiplayer door and an invite link both mean "I want
to pick a game", but a remembered session was checked first — so anyone already in a game was
dropped straight back into it and could never reach the lobby from the door. A bare load still
resumes.
- **The create form is two columns**, the lobby is 1040px rather than 640px wide, and Game settings
spans both columns when it opens with its five groups flowing into as many columns as fit — it
used to open inside one column and leave the other standing empty down a very long scroll.
- **"Every chair has to be taken" moved** next to the table size it explains, from below the rules
block.
- **A disabled game type now looks disabled** — the row dims and says why. A bare `disabled` on a
radio leaves the label at full strength and reads as a broken control.
- **Rule section numbers are out of every string a player reads** — 22 of them, across the page copy,
the turn chart, the narration, the panels and the bot's own explanation line. "§6.2 — you may not
end a turn holding more than three cards" is now "You may not end a turn holding more than three
cards". The sentences still state the rule; they no longer cite a number that a rewrite will
invalidate. Code comments keep their references, which is where they are useful.
Two more findings went into `TODO.md` rather than into code, at Jesse's direction: the Division map
draws no track geometry at all (a turnout is pixel-identical to the straight it replaced — the whole
visible change is a caption), and the map's buffer stops and direction go wrong once the route wraps
through the lanes. All three are one drawing pass with the items already queued there.
### Also in this commit: Heavy Grade orientation, asked again and unchanged
Uncommitted documentation work from the previous session, carried in here rather than left in the
tree. "Did we ever fix Heavy Grade to allow user placement of direction?" was raised a second time,
with the option of giving the choice to the **Superintendent** considered and rejected: the advantage
is permanent and the office rotates every three Stages, so a rotating chooser moves the fairness
problem rather than solving it. v0.5.0 stands — the orientation is rolled from the seed.
**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, the reasoning recorded in `docs/rules/implications.md` §10 Q11 so
it is not asked a third time, and `content.ts`/`setup.ts` gained comments saying which half of the
printed card is deliberately overridden and why. No code change, and none wanted.
### Housekeeping
`playtests/` is a gitignored home for save files that arrive with a bug report, with a committed
README saying what goes there and how to open one (the replay viewer takes a file straight off
disk). `package-lock.json`'s version had been stuck at 0.5.0 since that release — only
`package.json` was ever bumped — and now matches.
**`undo()` defaulted its config to `SOLO_CONFIG`** (noted above), and while confirming that, one more
came out of the same corner: nothing was draining `game.cues` on a server, so it grew without bound
for the life of a game. Draining it per broadcast is what makes multiplayer sound work and fixes that
at the same time.
### Not done here
The visual rendering of the new screens was not looked at in a browser while it was being written —
headless Firefox could not start on this box, so everything was verified by test and by driving the
real server. It has since been played on StartOS, which is where the fixes in the section above came
from; the Division map items it also turned up are in `TODO.md` rather than in this release.
---
## 0.6.3 — 2026-08-23
The deploy script only. No rules change, no game change, and the built site is byte-for-byte what