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:
Jesse
2026-08-21 14:53:53 -04:00
parent 62b6ed7e1b
commit 2fbfe11977
13 changed files with 561 additions and 59 deletions
+61
View File
@@ -19,6 +19,67 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.5.3 — 2026-08-21
Everything a StartOS administrator needs to see and manage a server full of games, plus the seat
control that came out of the first real multiplayer session.
### The host picks the table size, and a gap stops being expressible
The seats array used to GROW as people joined, which made the four rows on screen 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 the array with a `null` and silently disabled Start behind a one-line note. The host now
chooses 2, 3 or 4 when creating the game and the array is built at that length once. A gap cannot
be written down rather than merely being refused.
That also removed a trap nobody had sprung yet. 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 without an 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 was not known until START, so the lobby guessed 4. Every
2-player game was playing 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 reason that was a UI
artifact rather than a rule. The real count now reaches `defaultMultiplayerConfig`.
### Administration: what is running, and how to end it
`/api/health` gained `games: { active, lobby }`, which is what the StartOS package's health check
reports as "3 games in progress, 1 waiting to start". It reads `summary()` — a new, cheap
`GameSession` accessor — rather than `exportSave()`, which would copy every intent of every game to
answer a question about none of them.
Three administrative routes are new, gated by an `ADMIN_SECRET` env var in an `x-admin-secret`
header: `GET /api/games` (every game and lobby, summarised — players, names, started-at,
last-move-at, Day/Stage/phase, and who it waits on), `GET /api/games/<id>/save`, and
`DELETE /api/games/<id>`. Until this, a started game could not be ended by anybody: no route, no
player action, no resignation. An abandoned game stayed `active` in the index and was faithfully
resumed on every boot, forever.
Three deliberate choices in that:
- **The admin secret is not the join secret.** Every player holds the join secret, so gating a
delete with it would let anyone at the table destroy anyone else's game.
- **Unset means the routes are not there** — 404, the same answer as any unknown path, with or
without a header. A server never given an administrator does not advertise that it has one.
- **A delete returns the deleted game's save.** The intents are the game (D5), so that is the whole
thing and not a summary: nothing is destroyed without being handed to whoever destroyed it.
`SavedGame` gained `lastMoveAt` so "has this stalled?" survives a restart. It is optional and falls
back to `createdAt`, and it is 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.
### Boot
`Resuming N saved games…` is logged *before* the replay loop rather than one line per game after
it, so the pause before the port opens has a reason on screen while it is happening. Measured at
**100 ms** for a full 4-player game, and only unfinished games are replayed — so the pause is
tenths of a second in practice, and listening before loading would have bought nothing for the cost
of a "still loading" state on every route.
---
## 0.5.2 — 2026-08-21
Found packaging Phase 6 for StartOS: the splash's "Play multiplayer" door had sat `disabled`,