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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user