# 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:""}`; 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