v0.5.3 — a table you size yourself, and games an administrator can see and end
Both halves came out of playing the StartOS build. The wrapper's health
check and admin actions consume this; they land separately.
The host picks the table size (2-4) when creating a game, and the seats
array is built at that length once. Before, it GREW as people joined, so
the four rows on screen were partly fiction — a 2-player game just started
with a 2-long array, while a host who dropped a bot into a later chair
padded it with a null and silently disabled Start behind a one-line note.
A gap can no longer be written down rather than merely being refused.
That also avoided a trap. Compacting seats at Lobby.Start — the obvious
way to support a "closed" chair — would have shifted the player index that
every PlayerSession stamps at join time and that /api/stream and
/api/intent both route by, handing a player somebody else's railroad with
no error anywhere.
And it fixed a live balance bug: minCombinedRevenue is derived from the
player count, but the config was fixed at CREATE while the count wasn't
known until START, so the lobby guessed 4. Every 2-player game ran against
a floor of 60 instead of 30 — and missing the floor means everyone loses,
so a 2-player competitive game was set up to fail for a UI artifact rather
than a rule.
/api/health gained games:{active,lobby}, read from a new cheap summary()
on GameSession rather than exportSave(), which would copy every intent of
every game to answer a question about none of them. Three admin routes are
new behind an ADMIN_SECRET env var in an x-admin-secret header: GET
/api/games, GET /api/games/<id>/save, DELETE /api/games/<id>. Until now a
started game could not be ended by anyone — no route, no player action, no
resignation — so an abandoned game stayed active in the index and was
faithfully resumed on every boot, forever.
Three deliberate choices there: the admin secret is NOT the join secret,
which every player holds and which would therefore let anyone at the table
destroy anyone else's game; unset means the routes 404 exactly as any
unknown path does, with or without a header, so a server never given an
administrator doesn't advertise that it has one; and a delete returns the
deleted game's save, since the intents are the game (D5) — nothing is
destroyed without being handed to whoever destroyed it.
SavedGame gained an optional lastMoveAt (falling back to createdAt) so
"has this stalled?" survives a restart. Kept out of history for the same
reason the turn timings are: a replay must reproduce a game from decisions
alone, and wall-clock is not a decision.
index.ts logs "Resuming N saved games..." before the loop rather than one
line per game after it. Measured a full 4-player game at 100ms to replay,
and only unfinished games are replayed, so listening before loading would
have bought nothing for the cost of a "still loading" state everywhere.
Verified: 667 tests pass (662 + 5), and the new session tests were checked
against two mutations (lastMoveAt never advancing; resume dropping it) to
confirm they fail without the code. Live against a running server: health
counts tracking through the lobby->game transition, admin auth rejecting a
missing and a wrong secret, list/export/delete, the deleted game's files
and index entry actually gone from disk, a second delete 404ing, the admin
routes invisible when ADMIN_SECRET is unset, and a 3-player table refusing
a 4th player and a size of 5 refused at the door.
Also carries the TODO items raised on 2026-08-21: the lobby offering no
game parameters (the floor bug within it now fixed, the form still
missing), and the four optionalRules — of which only reducedVisibility and
emergencyToolbox are read by anything, while sisterTrains and
employeeRotation are declared, defaulted, and consulted nowhere.
This commit is contained in:
@@ -21,7 +21,14 @@ Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplaye
|
||||
below.
|
||||
3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below.
|
||||
|
||||
Nothing else queued at the moment.
|
||||
Queued 2026-08-21, from playing the StartOS build:
|
||||
|
||||
4. **The lobby must offer every game parameter the solitaire New Game dialog does** — and it
|
||||
currently offers none of them. Reasoning in Multiplayer below; carries a live balance bug with
|
||||
it (the combined-Revenue floor is sized for four players whatever the table's real size), so
|
||||
this is not purely a UI job.
|
||||
5. **Decide what the four `optionalRules` are** before either dialog offers them — two are live,
|
||||
two are read by nothing at all. Reasoning in Multiplayer below.
|
||||
|
||||
---
|
||||
|
||||
@@ -479,6 +486,63 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
||||
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").
|
||||
|
||||
- [ ] **THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING.** Split out
|
||||
at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby
|
||||
item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`,
|
||||
`sisterTrains`, `employeeRotation` and `emergencyToolbox`. Neither the solitaire New Game
|
||||
dialog nor the lobby exposes any of them, and every construction site in the codebase
|
||||
hardcodes all four to `false` (`web/game.ts`, `sim/harness.ts`, `sim/replay.ts`,
|
||||
`sim/compare.ts`), so no game has ever been played with one on.
|
||||
|
||||
**Check what is real before building a form for it.** Only two are wired:
|
||||
|
||||
| rule | status |
|
||||
| --- | --- |
|
||||
| `reducedVisibility` | **live** — read at `advance.ts:53`, gates on `NIGHT_STAGES` |
|
||||
| `emergencyToolbox` | **live** — read at `setup.ts:374`, seeds each player's Red Flags |
|
||||
| `sisterTrains` | **nothing reads it.** Declared, defaulted, never consulted — and §9a Q9 records that the Second Section card *supersedes* the Sister Trains optional rule, so this flag is most likely dead rather than unbuilt. Decide whether to implement or delete it |
|
||||
| `employeeRotation` | **nothing reads it.** Declared, defaulted, never consulted. Note the seat/player split (Phase 0, D9) was built specifically so this rule *could* exist — the groundwork is there, the rule is not |
|
||||
|
||||
So a dialog listing all four would offer two working toggles beside two that silently do
|
||||
nothing — the exact failure `checkPlay`'s `NOT_IMPLEMENTED` and `enhancementText`'s
|
||||
live/dormant/unbuilt table exist to prevent. Either implement the two dead ones, delete
|
||||
them, or label them on screen the way an unbuilt Enhancement already labels itself. Doing
|
||||
that is what decides whether this is a UI job or a rules job.
|
||||
|
||||
- [ ] **THE LOBBY OFFERS NO GAME PARAMETERS AT ALL, AND THE ONE IT INFERS IS WRONG.** 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
|
||||
|
||||
Reference in New Issue
Block a user