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.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user