v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them

This commit is contained in:
Jesse
2026-08-13 14:06:02 -04:00
parent 216006b091
commit 49f8504b05
34 changed files with 1743 additions and 526 deletions
+79
View File
@@ -21,6 +21,85 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased
## 0.4.0 — 2026-08-13
### Phases 0 and 1 of the multiplayer plan — the seams, not the server
`docs/architecture/multiplayer.md` is the plan; this is its first two phases. Nothing a player can see
changed, and that is the exit criterion: the whole point is that solitaire is byte-for-byte the same
game while the code underneath it stops assuming there is only ever one of you.
**Seat and player are now different things.** They were the same integer everywhere, which is correct
today and wrong the moment Employee Rotation moves someone to a different chair. Offices and districts
are keyed by SEAT; hands, Revenue and the Fedora belong to the PLAYER. `seating[]`, `seatOf` and
`playerAtSeat` make the mapping explicit — it is still the identity mapping, so nothing moves yet.
The split immediately found a real bug: `awardDeparture` was indexing `s.players` with a seat. Correct
under the identity mapping, silently paying the wrong railroad under rotation, and unfalsifiable in
solitaire. That is what the change is for.
**Per-player turn state.** `turn: TurnState` became `turns: Map<PlayerIndex, TurnState>`, one per
player at phase entry, read through `turnOf(s, player)` at ~50 sites. **Behaviour-neutral by
construction** — the phase cursor still walks one player at a time, and every existing test passed
unchanged. It is done now rather than in Phase 2 because it is a wide, mechanical change to the engine
that costs almost nothing today and would be a rewrite once a wire format depends on the shape. What
it buys later is players acting in parallel where the rules allow it, which is where the latency in a
turn-based game over the internet actually lives.
**The page renders from `Frame` + `Menu` alone.** `main.ts` had eleven reads through into `GameState`
— the deck, the RNG, other players' hands — each one a thing a server would never send. `Frame` grew
`option`, `status`, `outcome`, `players[]` and `handCount` to cover them. A test now reads `main.ts`
and fails if a reader comes back, because the cheap moment to catch that is now and not in Phase 2.
**A `Session` between the page and the game.** `LocalSession` runs the engine in the browser exactly
as before; a `RemoteSession` will hold no authoritative state at all — it cannot, having neither the
deck order nor the other hands. So the interface is deliberately the smaller of the two, and what only
a local session can do (undo, local save, dealing a new game) is declared in `capabilities`, which the
page reads to hide those controls rather than calling them and failing. `submit` is async even though
the local one answers immediately: a page written against a synchronous submit would have to be
rewritten for the server.
`test/session.test.ts` is the proof — 13 tests, the first of which plays seed 77 to a finish through
both routes and asserts the boards and the histories are identical.
**Docs.** `protocol.md` was written from the rules before the engine existed and had become a second,
drifting copy of `intents.ts` and `events.ts`; it now points at them and keeps only what the types
cannot say — what is deliberately *not* an intent, the two loops a client must not flatten, and the
redaction surface. Three architecture documents still said WebSocket where D5 chose SSE + POST; fixed.
**Then a review found the seat/player audit was half done, and it was right.** Two flagged sites, and
sweeping for the pattern found six more. All of them are correct today and all of them break the
moment Employee Rotation lands, which is exactly the failure mode the split was supposed to end.
*Keyed an Office Area by player when `officeAreas` is keyed by seat:* `refusesThisOffice`
(`apply.ts` — the Crack Limited's terminals-only rule), `spendDispatchBonus` (`advance.ts` — the
Fedora is held by a PLAYER, the Telegraph is installed in a SEAT), `impediments` (`narrate.ts`), and
two district lookups in the bot. `adTrackCount(s, player)` in `narrate.ts` was passing a player into
a `SeatIndex` parameter — the type said seat and the caller said player, and TypeScript cannot tell
two `number` aliases apart, which is precisely why this needs a test rather than a type.
*Read seat 0 regardless of the viewer:* the bot's `takingRank` ranked a face-up Office card against
**seat 0's** Office tier for every player, so in a multi-player bot game everyone chased player 0's
upgrade. `stats.ts` and `save-replay.ts` also index seat 0, which is correct — they are solitaire
summaries — and now say so through `areaAtSeat` instead of looking like the same bug.
**And `Frame` was seat-scoped in the board and the hand but not in four fields beside them.**
`revenue`, `moves`, `blocked` and the pace note all still reported player 0. A player would have been
shown someone else's score, someone else's Moves remaining, and someone else's jammed facilities —
that last one a list of squares that are not on the board they are looking at. Worse, the crew lookup
was keyed by `row,col` alone while every district shares an origin, so a crew standing at (0, 1) in
one Office Area was drawn at (0, 1) on **every** player's board.
Five new tests, all of which fail against the previous commit: per-seat Revenue/pace/impediments,
crews staying on their own board, a full 3-player game played with `seating` rotated before the first
move, impediments following a player to their new chair, and a source-level guard that fails if
anything outside `state.ts`/`apply.ts` touches `officeAreas` directly — the class, rather than the
eight instances of it found by hand.
Bot unchanged at 7.0 mean over 200 solitaire Standard games (the bot fixes are behaviour-neutral with
one player), and all four published replays still play to the end — which, for a change of this width,
is the whole report.
## 0.3.1 — 2026-08-13
### Groundwork for multiplayer