Files
Jesse c3c5cbfeec v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted
Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary).
This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per
docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed
cards, StartOS packaging) are still ahead.

Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing
Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling
submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add
POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/.
src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found
and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server
computing every connected seat's Menu would have handed the acting player's legal moves to a
waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a
rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and
test/redaction.test.ts. Not verified: an actual browser (none available in this environment).

Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then-
rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing
it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment
there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified
live: server killed and restarted mid-game, both seats reconnected exactly where they left off.

Two rules bugs found while building this: the New Train phase never implemented its car-placement
round (every car of every train was placed by the Superintendent alone, in every mode, all along —
now reads the round position off tray.consist.length); and victory conditions are now one shared,
configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and
a dead firstToTarget condition.

Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching
train's crew badge failing to draw once it left the Office square, an unload that always took the
westmost car regardless of which was picked, and a legal decision that could render with zero
buttons.

docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json)
included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated
side-project work, not part of this release. 635 tests, 0 failures.
2026-08-20 23:50:38 -04:00

196 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Station Master 0.5.0 — Test Plan
Written 2026-08-21, against an uncommitted working tree. Covers only the changes made in this
session's work — victory-condition unification, the New Train phase fix, and the multiplayer server
(Phases 2-3). Nothing here is committed yet; see the Coordination Note below before running any of it
against a tree that has since moved.
## Coordination note — shared working tree
Another thread is bug-fixing the 0.4.9 series **in this same uncommitted working tree**, concurrently.
`git status` shows changes I did not make in: `apply.ts`, `events.ts`, `track.ts`, `board-svg.ts`,
`docs/rules/*.md`, `package-lock.json`, `scripts/build-web.ts`, `.gitignore`, plus new
`docs/station-master-seed*.json` files still appearing as of this writing. **This plan does not cover
any of that** — it is scoped to the files below, which are the ones I actually touched.
Practical implications:
- Before running this plan, confirm the files under "Files this plan covers" still read the way this
document describes them — the other thread's commits could in principle touch the same functions
(`apply.ts`'s `check()` is used directly by `src/server/session.ts`, for instance).
- Run the full regression gate (`npm run typecheck && npm test`) fresh, not from a memory of an earlier
green run — both threads are editing live.
- The two bodies of work are unrelated in intent (0.4.9 patch fixes vs. 0.5.0 multiplayer groundwork)
but share a tree, so a clean split into separate commits before either is finalized is worth doing
deliberately rather than by accident.
### Files this plan covers
```
Engine: src/engine/advance.ts, src/engine/content.ts, src/engine/setup.ts, src/engine/state.ts
Web: src/web/game.ts, src/web/main.ts, src/web/play.html, src/web/session.ts
Sim: src/sim/view.ts, src/sim/frame-delta.ts (new), src/sim/compare.ts, src/sim/harness.ts,
src/sim/replay.ts
Server: src/server/ (new — index.ts, http.ts, session.ts, persistence.ts)
Tests: test/advance.test.ts, test/multiplayer.test.ts, test/web.test.ts, test/setup.test.ts,
test/redaction.test.ts (new), test/frame-delta.test.ts (new), test/server/ (new)
Docs: TODO.md, package.json (test script only)
```
---
## 0. Automated regression gate — run first, every time
```
npm run typecheck
npm test
```
Expected: typecheck clean, **all tests passing** (632 at the time this plan was written — the exact
number will drift as the other thread's work lands; what matters is zero failures). If either command
is red, stop and diagnose before doing anything below — a red gate means the manual steps are testing
against a broken build.
**If you only have time for one thing before the break ends, run this.** Everything else in this
document is either already covered by it, or is the manual/live verification that can't be — flagged
per-section below.
---
## 1. Regression baseline — solitaire must play exactly as before
The engine and dialog changes touch shared code paths solitaire also uses. None of this should have
changed solitaire's actual behavior.
| # | Test | Steps | Expected |
|---|---|---|---|
| 1.1 | Fresh deal | `npm run serve:web`, open `/play.html`, no URL params | A new random-seed solitaire game deals normally, board and hand render |
| 1.2 | Full game to completion | Play (or let a bot/harness play) a solitaire game to Day 5 | Game ends with a win/loss exactly as it would have before — `minCombinedRevenue` defaults to `collectiveRevenueFloor(1,5)=15`, not the old fixed `20`; a game scoring 15-19 that used to lose now wins (**intentional** — see §3, not a bug) |
| 1.3 | Save / restore | Play a few turns, reload the page (auto-restores from `localStorage`) | Game resumes exactly where it left off, house rules and victory dials intact |
| 1.4 | Save file download | Click "Save replay" mid-game | Downloads a small JSON; re-opening it in the replay viewer plays back identically |
| 1.5 | Undo | Play a few turns, click Undo repeatedly to the start | Each step back is clean; log never shows a move that "un-happened" |
| 1.6 | Existing published replays | Open each file under `public/replays/` in the replay viewer | All three still play back — `LEGACY_HOUSE_RULES` fallback for saves with no `houseRules` field is unaffected by this session's changes |
Automated coverage: `test/advance.test.ts`, `test/web.test.ts`, `test/session.test.ts` already assert
most of this at the unit level. §1 is about confirming nothing *visibly* changed for a solitaire
player, which only a live playthrough shows.
---
## 2. New Train phase car-placement rotation (engine fix)
**What changed:** the Superintendent used to place every car of every train alone, even in
competitive mode, against §7's written rule. Now the round rotates Superintendent-then-left, one car
per player, per `tray.consist.length`.
Automated: `test/multiplayer.test.ts`, "the New Train phase car-placement round rotates (§7, Gap 9)" —
asserts the exact actor sequence for 2p (wraps: 0,1,0) and 3p (no wrap: 0,1,2) against a real train
profile.
| # | Test | Steps | Expected |
|---|---|---|---|
| 2.1 | Manual multi-seat check | Drive a 3-4 player competitive game via the bot harness (`node src/sim/harness.ts`) or by hand through `src/server/`, and watch a Timetabled train get made up | Car-placement decisions visibly move between seats rather than one player filling the whole consist |
| 2.2 | Solitaire unaffected | Play any solitaire game where a train is made up | No visible change — one player, nothing to rotate, `tray.consist.length % 1 === 0` always |
Not covered, flagged in `TODO.md` (do not re-test, it's expected to be unreachable): `newTrain.passCar`
still has a dormant `check()`/`reduce()` gap, harmless because `trainNeedingCars` never offers a tray
with nothing suitable in the yard.
---
## 3. Victory conditions unified (`GameConfig` dials)
**What changed:** `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
`pvpCardsAllowed` replace the old `length`-preset/`target`/flat collision constant, across all three
modes. Automated coverage: `test/advance.test.ts`'s "victory conditions (§3, Gap 10e) — unified
2026-08-20" describe block (8 tests) covers every case below at the engine level already. This section
is for confirming the *dialog* actually produces the numbers the engine then honors.
| # | Test | Steps | Expected |
|---|---|---|---|
| 3.1 | `minCombinedRevenue` — loss | New Game, set it to a number higher than achievable, play to Day N | Loss, reason `revenueFloor`, regardless of individual score |
| 3.2 | `minCombinedRevenue` — win | Set it to 0, play to Day N with any score | Win — `0` genuinely disables the floor |
| 3.3 | `maxCollisionsPerDay` — **new solitaire behavior** | Set to 1, deliberately cause 1 collision (e.g. leave every A/D track occupied on an arrival) | Game ends **immediately**, mid-Stage, loss, reason `collisionFloor` — solitaire could never lose this way before this change |
| 3.4 | `maxCollisionsTotal` | Set `maxCollisionsPerDay=0` (off), `maxCollisionsTotal=2`, cause 2 collisions across different Days | Game ends on the 2nd collision, whichever Day it falls on |
| 3.5 | Both collision caps at 0 | Set both to 0, cause several collisions | Game does **not** end early — same as pre-change solitaire (collisions still cost Revenue, just don't end the game) |
| 3.6 | Mode radio defaults | Open New Game, click Competitive | `minCombinedRevenue` suggests `3 × 4 × days` (nominal 4-player assumption — no lobby yet to ask real seat count), `pvpCardsAllowed` checks and enables, Deal disables with a "needs a server" note |
| 3.7 | Mode radio — Co-op | Click Co-op | `pvpCardsAllowed` unchecks and **greys out** (forced off, not just defaulted off — no valid target when everyone's on one side), Deal disables |
| 3.8 | Mode radio — back to Solitaire | Click Solitaire again | Deal re-enables, `pvpCardsAllowed` unchecks and greys, all four dials show the numbers the *currently playing* game actually has (not the preset) |
| 3.9 | URL round-trip | Deal a game with non-default `days`/`minrev`/`colday`/`coltotal`, copy the URL, open it in a new tab | Identical settings — all four now travel via `?days=&minrev=&colday=&coltotal=` alongside the existing `?hand=&passenger=&freight=&transit=` |
**Not yet clicked through in a real browser** (no browser binary in the sandbox this was built in —
verified only via a simulated-DOM test harness, `test/web.test.ts`'s "the New Game dialog" describe
block, which drives the actual compiled bundle). §3.6-3.9 specifically should get a real click-through
before calling this done.
---
## 4. Multiplayer server — Phase 2 (session core)
New in this release: `src/server/` (session host, HTTP/SSE, static serving), `src/web/session.ts`'s
`createRemoteSession`, `src/sim/frame-delta.ts`. Automated: `test/server/session.test.ts` (12 cases,
pure logic, no sockets), `test/redaction.test.ts` (4 cases — the exhaustive "no seat sees another
seat's secrets" check), `test/frame-delta.test.ts` (5 cases).
| # | Test | Steps | Expected |
|---|---|---|---|
| 4.1 | Boot | `JOIN_SECRET=x PORT=8081 node src/server/index.ts` | Starts, logs the bind address and `dist/` path |
| 4.2 | Join secret gate | `curl -X POST localhost:8081/api/game` (no `?secret=`) | `403 bad or missing secret` |
| 4.3 | Create game | `POST /api/game?secret=x` with `{config, playerNames}` | `200 {ok:true, playerCount:N}`; a second call while a game exists → `409` |
| 4.4 | Per-seat SSE | Open `/api/stream?seat=0` and `?seat=1` for a 2-player game | Only the current actor's push has a non-null `menu`; the other seat's is `null` |
| 4.5 | Turn enforcement | `POST /api/intent` as the **non-acting** seat | `{ok:false, code:"NOT_YOUR_TURN"}`, no broadcast, no state change |
| 4.6 | Accept + broadcast | `POST /api/intent` as the acting seat, a legal intent | `{ok:true}`; **both** SSE streams receive a push with fresh narration |
| 4.7 | Idempotent resend | Submit the same `{seq, intent}` twice | Second call: `{ok:true}`, **no** new SSE push (already applied, silently ignored) |
| 4.8 | Illegal intent | Submit something `check()` would reject | `{ok:false, code:"<real RejectionCode>"}`; the rejection text does **not** appear in the *other* seat's narration (private feedback, §session.ts's design note) |
| 4.9 | Board delta | Submit an intent that doesn't move anything on the board (e.g. `localOps.choose`) | The next push's `frame.division`/`frame.cells` are `null` (unchanged-since-last-push-to-this-seat) |
| 4.10 | Heartbeat | Hold an SSE connection open >20s idle | A `: ping` comment line appears (EventSource ignores it; keeps a proxy from reaping the socket) |
| 4.11 | **Real browser — not yet done** | Two actual browser tabs, `?seat=0` and `?seat=1`, playing a shared game by clicking | Confirm the whole loop works through the actual UI, not just curl: turn indicator, disabled controls for the non-acting seat, board updates on both sides, a rejected action shows *something* sensible on screen rather than silently doing nothing |
§4.1-4.10 were run live against a real server process during this session (not just unit-tested) — see
the session transcript for the actual curl commands and captured output. §4.11 genuinely was not done;
no browser was available in the environment this was built in.
---
## 5. Multiplayer server — Phase 3 (persistence and resumption)
New: `src/server/persistence.ts`, `game.ts`'s `fromMultiplayerSave`, `session.ts`'s `exportSave`/
`resumeSession`/turn-timing tracking. Automated: `test/server/persistence.test.ts` (6 cases),
`test/server/session.test.ts`'s "turn timings" and "persistence hooks" describe blocks.
| # | Test | Steps | Expected |
|---|---|---|---|
| 5.1 | Immediate persistence | `POST /api/game`, then check `DATA_DIR/game.json` | Exists immediately, empty `history`, correct `engineVersion` (= `package.json`'s `version`) |
| 5.2 | History grows | Submit a few intents | `game.json`'s `history` array grows by one entry per accepted intent, in order |
| 5.3 | Turn timings recorded | Submit enough intents to cross a full turn (player/phase/day/stage change) | `DATA_DIR/turn-timings.json` gains an entry: `{player, phase, day, stage, startedAt, endedAt}`, `endedAt >= startedAt` |
| 5.4 | **Restart and resume** (the actual "done when" for Phase 3) | Kill the server process mid-game, restart it against the same `DATA_DIR` | Log line: `"Resumed a saved game... N intents replayed"`; reconnecting both `?seat=` streams shows the exact same Day/Stage/phase/whose-turn as before the kill, correct narration attribution ("Player X chose to...", not anonymous) |
| 5.5 | Version-mismatch refusal | Hand-edit `game.json`'s `engineVersion` to a bogus value, restart | Log line refusing to load, naming both versions; server starts with **no** active game (confirm via `POST /api/game` succeeding, not `409`ing); the file is **not** deleted or modified |
| 5.6 | Atomic writes, no stray temp files | After several intents, check `DATA_DIR` | Only `game.json`/`turn-timings.json` present — no leftover `.tmp` files from an interrupted write |
§5.1-5.6 were all run live during this session (kill-and-restart included) — see the transcript. This
is the most thoroughly live-verified section of the whole plan.
---
## 6. Things this session found but did *not* fix — verify they're still correctly deferred
These are logged in `TODO.md`, not bugs to chase here — listed so a tester doesn't rediscover them and
assume something regressed.
| Item | Where | Current state to confirm |
|---|---|---|
| `fromSave`'s replay loses "Player X" narration attribution | `src/web/game.ts` | Still present in `fromSave` (untouched, out of scope); **fixed** in the new `fromMultiplayerSave` — §5.4's narration check is what confirms the fix landed where it needed to |
| `newTrain.passCar`'s `check()`/`reduce()` gap | `src/engine/apply.ts` | Still dormant/unreachable — confirm no new caller has started exercising it (would only matter if the *other* thread's 0.4.9 work touches this area) |
| No real browser click-through | This session | §3.6-3.9, §4.11 — the two gaps a human still needs to close |
---
## Summary checklist
- [ ] §0 automated gate green
- [ ] §1 solitaire regression (six checks)
- [ ] §2 New Train rotation, manual multi-seat check
- [ ] §3 victory conditions, especially §3.6-3.9 (dialog, not yet browser-verified)
- [ ] §4 server core, especially §4.11 (real browser, not yet done)
- [ ] §5 persistence — already live-verified this session; worth a second independent run
- [ ] §6 confirm the three known-and-deferred items are still exactly as described