Files
station-master/docs/testing/0.5.0-test-plan.md
T
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

14 KiB
Raw Blame History

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 409ing); 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