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:
co-authored by
Claude Opus 5
parent
42adfda390
commit
06db36e5b5
+244
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user