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:
Jesse
2026-08-20 23:50:38 -04:00
parent f9c4d9fa92
commit c3c5cbfeec
52 changed files with 5282 additions and 420 deletions
+281 -99
View File
@@ -14,8 +14,14 @@ to Rules Questions.
## Next
Nothing scheduled at the moment — v0.4.9's plan (coordinate labels, the no-switching fix, the
expedite rewrite, the `evaluateClearance` bug, the splash artwork) is built; see Done below.
Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplayer below), in order:
1. ~~**Fix the New Train phase car-placement round**~~ — done, see Multiplayer below.
2. ~~**Unify victory conditions across solitaire, competitive and coop**~~ — done, see Multiplayer
below.
3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below.
Nothing else queued at the moment.
---
@@ -58,6 +64,22 @@ The replay viewer, the save format, and how a game gets shared.
personal StartOS box at all.** If it is, (1) is the piece (2) would need anyway, so it is the
right thing to build first either way.
- [ ] **`fromSave`'s replayed narration loses "Player X" attribution — found 2026-08-20 building
multiplayer Phase 3, not fixed there.** `fromSave`'s loop (`game.ts`) calls `record(game,
result.events)` without the `actor` argument `submit()` always passes it (`game.ts`'s own
`record(game, events, actor)` — `actor` is what turns "Chose to draw a card" into "Player X
chose to draw a card"). So a restored save, an undone game (`undo` rebuilds via `fromSave`
internally), or a replayed one all lose attribution on every line — invisible in solitaire
because nothing ever compares a `fromSave`-built log against a live-played one (the one test
that compares logs, `test/web.test.ts`'s "leaves nothing in the log describing a move that was
taken back", compares `undo`'s `fromSave`-built log against ANOTHER `fromSave`-built log, so
the missing attribution cancels out both sides), but it would read as broken the moment more
than one seat's history is on screen at once — exactly what the replay viewer and any
multiplayer post-game replay (D20) need to get right. Fixed in `fromMultiplayerSave`
(multiplayer's version of this function, added for Phase 3) by passing `actor` through; not
touched in `fromSave` itself since it's used far more widely (undo, save/restore, the replay
viewer) and deserves its own careful look rather than a fix bundled into an unrelated change.
- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts
--seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON
saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored,
@@ -228,7 +250,10 @@ number until the rules stop moving.
those multipliers exist to relieve. The 8 sharp curves have already been taken out on that
argument; offices and industries are the two left. Until then, read no balance conclusion from the revenue
numbers; they are a functionality signal only.
- [ ] **RE-MEASURE THE BOT AT THE NEW DEFAULTS.** Both provisional rules below are now **settings on
- [ ] **Superseded 2026-08-20 by the victory-condition redesign (Multiplayer) — kept for the
measurements.** `minCombinedRevenue` replaces the fixed target these numbers were read
against; re-measure once that lands rather than off this. **RE-MEASURE THE BOT AT THE NEW
DEFAULTS.** Both provisional rules below are now **settings on
the New Game dialog** rather than fixed choices, and the defaults are not what the numbers in
this file were measured under: the opening hand defaults to **three random cards** (the
prototype rule) rather than 3+3, and **train revenue per transit defaults to 0** rather than 1.
@@ -237,7 +262,10 @@ number until the rules stop moving.
has actually been trying to read all along. Every mean, floor and threshold quoted below and in
the tests predates it. The three revenue rates run 0–5, so the useful next step is a sweep
rather than a single re-run.
- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional, and both are
- [ ] **Not superseded by the 2026-08-20 victory-condition redesign (Multiplayer) — these two stay
`houseRules` dials, separate from the new `GameConfig` victory dials.** Noted only so the two
redesigns aren't conflated. **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN
PLAYED — both went in provisional, and both are
now selectable rather than fixed.** Jesse's call, both implemented and measured, both flagged
in `rules-v0.2.md`. What follows is what was measured when each was the only option.
@@ -293,8 +321,15 @@ number until the rules stop moving.
- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often
than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density
changes.
- [ ] **The victory target (20 over 5 Days) is out of reach by a factor of about four, and the
Office ladder is why.** Measured over 800 games with the tuned bot, which no longer throws
- [ ] **Superseded 2026-08-20 by the victory-condition redesign (Multiplayer) — kept for the
measurements and the reasoning.** `LENGTH_PROFILES.target` (20 over 5 Days, `standard`) is
retiring in favour of `minCombinedRevenue`, defaulting to `3 × players × days` (15 for
1-player/5-day, not 20) — a different number, deliberately not tuned to match this table.
Whether the Office-ladder bottleneck below still applies at the new default is worth
re-measuring once the redesign lands, but the fixed "20" this data argues against no longer
exists as a target. **The victory target (20 over 5 Days) is out of reach by a factor of
about four, and the Office ladder is why.** Measured over 800 games with the tuned bot, which
no longer throws
revenue away on collisions (0.0 a game, down from 0.4):
| trains scheduled | games | revenue | | Office reached | games | trains | revenue |
@@ -368,11 +403,88 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
pointed at the open internet for long. Note that one-game-at-a-time per person is expected
usage and deliberately NOT enforced — enforcing it needs cross-game state whose only job is
deciding when to release someone, and getting that wrong locks a player out.
- [ ] **WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTER ~16 STAGES OF A POSSIBLE 60?** Measured while
sizing multiplayer: 8 games, all reaching Day 5, but only ~16 distinct (day, stage) pairs each
and ~355 intents. Most likely the collision or revenue floor (§3.4) firing early, which would
make a competitive game about an hour rather than four. Worth knowing whether that is the
design working or a balance bug — it decides what a lobby should tell players about length.
- [x] **~~WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTER ~16 STAGES OF A POSSIBLE 60?~~ Answered
2026-08-20: the collision floor, not the revenue floor.** Traced `checkVictory`
(`advance.ts:1086-1133`): in competitive mode the revenue floor can only fire at the exact
Day-5 boundary (Stage 60), so it structurally cannot explain a 16-Stage ending. Only the
collision floor can (`advance.ts:1076-1080`, 3 collisions in one Day, checked at every Stage
boundary). `collisionsToday` is one counter every seat feeds, so a 4-player table burns a
fixed shared budget roughly 4x faster than one player would. Also: `multiplayer.md` §3's
8-game sample predates `DEFAULT_HOUSE_RULES` (v0.4.2) and most likely ran under what is now
`LEGACY_HOUSE_RULES` — that sizing data is stale on top of the collision-floor explanation.
Jesse's call, 2026-08-20: keep the collision caps flat rather than player-scaled (below), so
16-Stage games under default settings are an accepted, deliberate outcome, not something to
re-tune away — re-measure `multiplayer.md` §3's sizing table once the redesign lands, but
expect similar early endings by design.
- [x] **~~Victory conditions unified across solitaire, competitive and coop~~ — designed and
implemented 2026-08-20.** One shared, fully-configurable set of `GameConfig` dials replaces
`LENGTH_PROFILES.target`, `VictoryCondition: 'firstToTarget'` (confirmed dead — grepped, never
selected anywhere in the codebase today) and the flat `COLLISION_FLOOR_PER_DAY` constant:
| dial | meaning | default |
| --- | --- | --- |
| `days` | how many Days the game runs | 5, all modes |
| `minCombinedRevenue` | everyone loses if the table's total Revenue is below this when Days run out | `3 × players × days` — reuses `collectiveRevenueFloor()` (`content.ts:1020`), now also applied to solitaire (1 player) rather than competitive-only |
| `maxCollisionsPerDay` | everyone loses immediately, mid-game, once collisions in one Day reach this | 3, **flat — not scaled by players.** Jesse's call: more players means more independent chances to collide, not a bigger shared budget, so multiplayer is deliberately riskier than solitaire at the same default |
| `maxCollisionsTotal` | same, summed across the whole game | 5, flat, same reasoning |
| `pvpCardsAllowed` | whether the 22 opponent-directed cards (still unbuilt, see below) are in the deck | forced off in solitaire and coop — no valid target for them in either — on by default in competitive |
`0` means "off" for every dial. Win/lose shape is otherwise unchanged from what solitaire
already does: most Revenue when Days run out wins, unless `minCombinedRevenue` was missed, in
which case everyone loses — just made configurable per game instead of a fixed `length`
lookup. Coop keeps its existing "score is the table's total" model, now against a
configurable floor instead of `profile.target * players.length`.
**New Game dialog:** one shared dialog for all three modes, per Jesse — a mode radio button
at the top, the same field set underneath for all three, greyed out wherever a mode forces a
value (the PvP checkbox in solitaire/coop). Solitaire gains the four new dials alongside the
starting-hand and revenue-rate fields it already has; picking a mode only changes the
defaults, never the field set. **Deal stays disabled for Competitive/Co-op** with a "needs a
server" note, since Phase 2 didn't yet expose a way to actually start one from the browser
(see below) — only Solitaire's Deal path is wired to a real game today.
- [x] **~~New Train phase car-placement is one player's job even in competitive mode~~ — fixed
2026-08-20.** §7 (`rules-v0.2.md:346-363`) is explicit: "starting with the Superintendent and
working left, each player may place ONE car... the round repeats... until the consist is
full," with a worked 2-player example. `newTrainPhase` (`advance.ts:187-282`) never
implemented the round: `enterPhase` resets `actorOffset = 0` on entering the phase
(`advance.ts:172`) and `newTrainPhase` never incremented it the way `playerPhase` does for
Local Ops (`advance.ts:140`), so the actor was always the Superintendent alone, for every car
of every train made up that Stage. Fixed by reading the round position off
`tray.consist.length` instead — it already counts placements toward that tray and resets per
train with no new state needed. Test in `multiplayer.test.ts`, "the New Train phase
car-placement round rotates."
**Found in the process, not fixed, logged separately:** `newTrain.passCar`'s `check()`
(`apply.ts:959-967`) tests whether the *entire* Division Yard is empty rather than whether a
car suitable for *this* tray exists, and `reduce()` has no case for `carPassed` at all
(`apply.ts:2246-2247`, falls to `default: break` — applying a pass currently mutates nothing).
Unreachable in practice today: `trainNeedingCars` only ever flags a tray that already has a
suitable car waiting, so a legal `passCar` for the flagged tray can't occur. Only matters if a
future change lets the New Train phase address more than one tray at a time. Not fixed here —
nothing to verify against an intent that can't legally fire.
- [ ] **The redaction test (multiplayer.md §7) is more done than the plan suggests, but the
exhaustive check is still missing.** `test/multiplayer.test.ts`'s "the view shows one seat at
a time" section (added earlier) already proves `snapshot(s, ..., viewer)` gives each seat its
own hand, board, Revenue and impediments — traced `snapshot()` itself
(`src/sim/view.ts:1180-1219`): `hand` reads only `s.decks.hands.get(viewer)`, `deck` is a
count, other seats' hands appear only as `.length`, and `Frame`'s type has no `seed`,
`rngState` or card-id-dictionary field for anything to leak through by accident. What exists
is all spot-checks, though — "this seat's Frame has the right hand length." What's still
missing is the exhaustive one §7 actually calls for: serialize a seat's `Frame` and assert it
contains none of another seat's actual card ids and no deck order, so a future careless edit
is caught rather than assumed safe. Doesn't need a server — buildable now against `snapshot()`
and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now,
2026-08-20.
- [ ] **D19's switching-instrumentation still needs writing, once real people are playing.** "13%
for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts`
or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land
before Phase 2 and realistically not before Phase 4 (real people at a lobby, not bots). A few
lines when the time comes: log whether a legal local-only action existed for a waiting player,
and whether they took it the moment their turn arrived.
- [ ] **THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF EVERY DECK UNTIL THEY
ARE BUILT.** Jesse's call. They were already cut from solitaire (Q6, no legal target with one
player); they are now cut from the competitive deck too, because `checkPlay` answers both
@@ -382,72 +494,90 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
**three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are
wired and read, and fire only against these cards. Until then those three are dormant by
design rather than broken.
- [ ] **Multiplayer proper — Phases 0 and 1 done (v0.4.0), Phases 2–6 to go.** The full plan is
`docs/architecture/multiplayer.md` §12. The engine now has seat/player separation and
per-player turn state, the page renders from `Frame` + `Menu` alone and talks to a `Session`
rather than to the engine — so a `RemoteSession` can be dropped in without the page changing.
Still no server, no turn submission and no per-player push: that is Phase 2, and it is
deliberately held until the two provisional rules have been playtested, because a rule change
after the wire format is live is much more expensive than one before it.
- [x] **Multiplayer proper — Phases 0, 1 and 2 done (v0.4.0, 2026-08-20), Phases 3–6 to go.** The
full plan is `docs/architecture/multiplayer.md` §12. Phase 2 (server core) landed in one pass:
---
- `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a
smaller, purpose-written replacement for reusing `replay.ts`'s `compress()` — that function
interns strings across a whole recorded array, which a live single-frame push has nothing to
intern against; only its one-step-back "null if unchanged" idea carried over.
- **Found and fixed a real bug tracing this**: `actionMenu(game, seat)` only used `seat` for the
`hand` field — everything else came from `currentActor(game)` regardless of who asked, so a
server computing every connected seat's Menu would have handed the acting player's legal
moves to a waiting seat, paired with the wrong seat's cards. Fixed in `game.ts` with a guard;
tested in `multiplayer.test.ts`.
- The redaction test (§7) is built — `test/redaction.test.ts` — and passed on the first run
against the existing `snapshot()`, confirming it was already correct, not just apparently so.
- `src/server/session.ts` — the game session host (pure logic, no sockets, reuses `game.ts`'s
`Game`/`submit`/`currentActor`/`actionMenu` wholesale rather than re-deriving intent
application/narration). **Found while building it**: `submit()` derives the acting player from
`currentActor(game)` itself and does not check who is calling it — safe for `LocalSession`
(one possible caller) but not for a server, so the session host verifies `seat ===
currentActor(game)` itself before ever calling `submit`, rejecting with `NOT_YOUR_TURN`
otherwise. Idempotent resend (a repeat `seq`) and the illegal-intent path (checked via
`check()` directly, so a rejection never pollutes the shared narration log with "not allowed"
text meant only for the submitter) are both handled here too.
- `src/server/http.ts` / `src/server/index.ts` — plain `node:http`, no framework (confirmed
nothing to reuse and nothing else warranted — zero runtime dependencies anywhere else in the
project). `POST /api/game`, `GET /api/stream` (SSE, per-seat, with a 20s heartbeat and an
`id:` line per push), `POST /api/intent`, and static serving of `dist/` so the server can be
same-origin with itself (D16). No `gameId`/multi-game concept yet — one game per process,
matching "Phase 2 has no lobby."
- `src/web/session.ts` gained `createRemoteSession`; `main.ts`'s `start()` switches on `?seat=`
presence (D4 — one bundle, unchanged). Every `LocalSession`-only call site in `main.ts`
(`seed()`, `save()`, `undo()`, the New Game dialog) now goes through an `isLocal()` type guard
rather than assuming, since `session` can now be either.
- **Found and fixed a real infrastructure bug**: adding `test/server/` broke `npm test`'s glob.
`"test": "node --test test/**/*.test.ts"` relied on bash's non-globstar behaviour of passing
the *literal, unexpanded* pattern through to Node (which then globs it correctly itself) —
that only happens when the pattern matches *no* files at the shell level. The moment a
subdirectory existed, bash expanded it to just that one file, and `npm test` silently ran only
the new suite. Fixed by listing both depths explicitly:
`"test": "node --test test/*.test.ts test/**/*.test.ts"`.
- Verified two ways: `test/server/session.test.ts` exercises the session host directly (no
sockets); a live end-to-end curl smoke test (server started, a 2-player game created, two SSE
streams opened, an intent rejected from the non-acting seat, accepted from the acting seat and
broadcast to both, a resent `seq` producing no second push, and the board correctly nulled on
the second push) — see the session transcript. **Not verified**: an actual browser — no
browser binary exists in this environment, so `RemoteSession`'s DOM-facing code
(`EventSource`/`fetch` wiring) compiled and typechecks but was not clicked through visually.
## Rules Questions
**Phase 3 (persistence/resumption) done, same session, 2026-08-21.** Per §12 steps 14-16 and
`lobby-and-sessions.md` §5-6 (unusually concrete — the exact storage shape was specified, not
designed here):
Blocked on a decision, not on work.
- [ ] **WHERE THE LOCAL'S COACH STANDS WHILE ITS ENGINE WORKS (§A.4) — now hit in play, still open.**
Trains 7/8 print "coach must remain on station track if switching", read as "the coach is never
set out". A cut comes off an OUTER end, so a coach on one outer end with the engine on the
other locks the train completely: it cannot set its freight car out, and cannot uncouple to run
around either, because that leaves the coach standing. Measured over 60 games — **1,181
positions where a set-out should have been possible, every one refused; no other train blocked
once.** Two of the six possible arrangements lock, and `ENGINE boxcar coach` — the one that
locks — is both prototypical and what make-up naturally produces.
**Worked around, not solved.** The make-up panel now tells the player to add the coach first
(v0.4.6), which produces `ENGINE coach boxcar` and works. The rules question is untouched: if
the coach may be set out **at the Office**, which is what the card's wording plainly says and
what a real mixed train does, then the prototypical make-up works and the advice becomes
unnecessary. That needs one exception to §A.4's blanket refusal to leave Rolling Stock at the
Office, for the coach and only on the Local. **Decision needed:** should the Office square — or
a station track beside it — accept a parked coach?
- [ ] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — Jesse's call.** The card says *"may drop or pick
up one freight car at every location"* and also prints **Expedite**. Expedite means the train
departs the Stage it arrives (Q3): it arrives in the Mainline phase, stands through Cargo, and
highballs in Supervisor Shift — so it is never on the board during a Local Operations phase,
which is the only phase in which freight is coupled or set out. Measured over 40 bot games:
**31 Office visits, 31 of them with no Local Operations turn.** X14 Fruit Growers Express is in
the same position, though its "may pick up one extra loaded reefer" is only a note today.
The four options put to Jesse, unchanged: drop Expedite from 3/4 only (the other Expedite
trains all print "no switching" and lose nothing); leave Q3 alone and strike the freight line
from the card; drop Expedite everywhere (it partly exists to relieve Crew Tray scarcity, so
this needs re-measuring); or move the Express's freight budget into the Cargo phase, where an
expedited train does still get a turn. **Nothing is broken** — this is a contradiction between
two lines on one card, and the timing rule itself is behaving exactly as recorded.
**Should be resolved as a side effect of the Expedite fix in Next**, once built: correcting
Q3 so an expedited train gets an ordinary Local Operations turn removes the contradiction
without picking any of the four options above. Leaving this open until that lands and is
confirmed in play.
- [ ] **THE INDUSTRY TABLE STILL DISAGREES WITH THE CARD REFERENCE — two items left, Jesse's call.**
v0.4.7 corrected the DIRECTIONS: the Grocer's Warehouse and the Oil Refinery are `flow: 'both'`,
as `card-reference.md` always said, which is what let an Ice House finally give a Grocer's its
outbound slot. Two discrepancies remain and both are deliberate for now.
**(a) Base capacities.** The reference prints Grocer's 2/2 with 2 Laborers and the Refinery 2/2
with 3; the engine gives every industry 1 per direction it allows, Mine Tipple included. Raising
one alone would be a balance change rather than a correction.
**(b) The Freight House card.** The reference is explicit — "'Freight House' is not a card. It is
the collective term for a freight facility that loads *and* unloads" — and the engine deals 6
copies of one. Removing them is a deck-composition change worth measuring, not a quiet delete.
- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect
as "TBD in the source". A test asserts it stays TBD so nobody invents one.
- [ ] **Heavy Grade orientation is rolled, not chosen.** The card prints "Player sets orientation",
but it is dealt during setup and setup has no decision point at all — `createGame` is a pure
function of the seed, which is also what makes a save portable. Rolled from the seed for now.
Revisit when setup gains an interactive phase; the orientation matters, because it decides
which direction climbs and therefore what Brakeman and Helpers are worth.
- `src/server/persistence.ts` — `game.json` (`{engineVersion, seed, config, playerNames,
history, status, createdAt}`) and `turn-timings.json`, both atomic-rewrite-then-rename, no
`gameId`/index yet (one game per process, same deferral as Phase 2's `gameId`).
- `game.ts` gained `fromMultiplayerSave` — `fromSave`'s multi-player sibling, built on
`newMultiplayerGame`. **Found while testing it**: `fromSave`'s replay loop calls
`record(game, result.events)` without the `actor` argument `submit()` itself always passes,
so every replayed line loses its "Player X" attribution — invisible for solitaire (nothing
ever compares a `fromSave` replay against a live-played log; `undo`'s rebuilt game is itself
`fromSave`-built, so the one test that compares logs only ever compares two unattributed
replays against each other) but immediately visible for multiplayer, where anonymous "Chose
to..." lines are unreadable the moment there is more than one seat. Fixed in the new function;
**`fromSave` itself still has the gap** — not touched here, since it is used far more widely
(undo, save/restore, the replay viewer) and deserves its own careful pass rather than a
touch-in-passing. Worth its own TODO item if picked up.
- `session.ts` gained `exportSave()`, `resumeSession()`, and turn-timing tracking — a `TurnTiming`
span (player, phase, day, stage, start/end wall-clock) closes and reopens whenever the acting
player, phase, Day or Stage changes; recorded entirely in the session host, never touching the
engine (which must stay clock-free and deterministic) and never stored inside `history` (a
replay must reproduce a game from decisions alone). No reporting/aggregation/UI on this data
yet — §5 calls that "optional... if unobtrusive," and the Phase 3 deliverable is the data
being recorded, not a view of it.
- `index.ts` loads `game.json` on boot before starting the HTTP listener: version match →
`resumeSession`, replayed straight through; mismatch → refused explicitly and loudly (the
file is left untouched, so rolling the running version back recovers it), server starts with
no active game rather than replaying under the wrong rules.
- Verified live, matching this phase's own "done when": server started against a fresh data
directory, a 2-player game created, intents submitted from both seats, **the server process
killed and restarted**, both `?seat=` streams reconnected and picked up exactly where they
left off — same Day/Stage/phase, correct whose-turn-it-is, correct narration attribution.
Separately confirmed the version-mismatch path: hand-edited `engineVersion` to a bogus value,
restarted, server logged the refusal and started with no active game (confirmed via `POST
/api/game` succeeding rather than 409ing).
---
@@ -455,12 +585,6 @@ Blocked on a decision, not on work.
Doesn't fit the above.
- [ ] **Engines are not a SUPPLY yet, only a position.** `engineAt` now records where the engine
sits in the tray and the consist shows it, but an engine is still conjured with the tray
rather than drawn from the Division Yard and returned to it. The rules put engines in the
Division Yard alongside the cars, with a predefined number of them, so running out of engines
should be a second way trains get held — today only the Crew Tray count does that. Needs a
number to start from, then playtesting.
- [ ] **Real audio, as committed assets.** Everything the game plays is synthesised from oscillators
(`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a
placeholder, not the finished sound. Sound therefore defaults to OFF.
@@ -473,9 +597,18 @@ Doesn't fit the above.
`trainHighballed` (Office departures only), and `trainsDestroyed`. Good enough to keep as the
real thing rather than a placeholder — no WAV clips needed for these three.
- **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing.
Needs licences that permit redistribution (CC0 or similar), files small enough to commit, and
a check that the "fetches nothing external" test still passes — assets must be served from the
site's own folder, never hot-linked.
**Every file added needs three things recorded alongside it: the sound file itself, its
source (where it was obtained from), and its license.** The preferred license is **CC0
("Creative Commons Zero")** — a public-domain dedication: the creator waives all copyright
and related rights, so the file may be used, modified, and redistributed for any purpose,
including commercial, with **no attribution required and no restriction**. That is the
cleanest fit for a file committed straight into the repo, since it needs no attribution to
track going forward. Only fall back to an equally-permissive alternative (e.g. a license that
explicitly permits redistribution with no ongoing obligation) if CC0 isn't available for a
given sound, and record that license's actual terms plainly rather than assuming they match
CC0. Files also need to be small enough to commit, and a check that the "fetches nothing
external" test still passes — assets must be served from the site's own folder, never
hot-linked.
- Keep the synthesised versions as the fallback for anything not sourced, so a missing file is
a quieter game rather than a broken one.
- [ ] **Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions,
@@ -491,22 +624,18 @@ Doesn't fit the above.
Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the
freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the
source Start-position art for the ten card types before it can begin.
- [ ] **Player settings, saved.** The district's auto-focus is the first of these: it is DISPLAY
state, so in a multiplayer game two players may reasonably want it set differently and it must
never become part of game state. It currently resets on reload. Worth a settings object in
localStorage — auto-focus mode to start with, and whatever else earns a preference — kept
strictly separate from the save, which is the seed plus the intents and has to stay portable.
- [ ] **The test suite fails at random under `npm test`, and it is the runner rather than the code.**
`node --test test/**/*.test.ts` runs the files in parallel and three suites write and read the
same `dist/` — the static build, the published-replay check and "the three places a game is
drawn stay in step". Back-to-back full runs measured **9 failures then 0**; run one file at a
time and every suite passes. That is worse than a slow suite: it trains us to shrug at a red
run, which is exactly how a real regression gets waved through. Give the build test its own
output directory, or mark the trio to run serially.
- [ ] **Curves are drawn as two straight segments meeting**, not true arcs. Fine at this size, angular
close up.
- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal
scrolling. Legible, not compact.
- [ ] **`card-reference.md`'s industry table may still be stale beyond Grocer's Warehouse, the Oil
Refinery and Freight House (corrected v0.5.0) — Mine Tipple, Produce Shed and Power Plant were
NOT re-verified.** The v0.5.0 pass corrected three rows (and the "Freight House is not a card"
claim across `card-reference.md`, `glossary.md`, `rules-v0.2.md` and `open-questions.md`) on
Jesse's explicit call. Checking `content.ts` while making that change turned up that
`mineTipple` and `powerPlant` are ALSO base 1 out/in + 1 Laborer in the engine — the same
uniform model as the three that were corrected — while `card-reference.md` still prints Mine
Tipple 3/3/4 and Power Plant 3/3/4, and the "Throughput — why these Laborer counts" section
right below the table is built entirely on those higher numbers. Flagged inline in
`card-reference.md` rather than silently rewritten — this needs the same kind of decision Jesse
made for the other three, not an assumption that the same correction applies, since raising or
lowering Laborer counts is also a balance question, not only a docs one.
---
@@ -768,3 +897,56 @@ Doesn't fit the above.
(3.0 MB) resized to a 145 KB JPEG (`public/images/`, copied into the build by `build-web.ts`)
and placed beside the title, tagline, blurb and both buttons in a side-by-side hero, stacking
to image-above-text on mobile.
- [x] **A coach may now be set out at the Office — v0.5.0, Jesse's call.** §A.4's blanket "no Rolling
Stock may be left at the Office" now carries one exception: any train (not just 7/8) may drop
one or more coaches there; freight and cabooses stay banned. Unlocks the `ENGINE boxcar coach`
arrangement that used to lock completely — measured at 1,181 refused set-outs over 60 games,
every one of them this case. `canDropCarsAt` (`track.ts`) takes a `coachesOnly` flag instead of
refusing the Office outright; trains 7/8's `coachStaysOnStationTrack` rule now forbids the coach
everywhere EXCEPT the Office, rather than everywhere. The Office's "cars fouling the Running
Track" collision (`advance.ts`) is exempted for coach-only standing cars, so a legally parked
coach is not a hazard to the next arrival.
- [x] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — closed, v0.5.0.** Confirmed already resolved as
a side effect of the v0.4.9 Expedite fix (see that entry above); removed from Rules Questions.
- [x] **THE INDUSTRY TABLE VS. THE CARD REFERENCE — v0.5.0, Jesse's call: the engine was right, the
docs were stale.** `card-reference.md` printed Grocer's Warehouse and Oil Refinery at 2/2 with
2–3 Laborers, and claimed "'Freight House' is not a card"; the engine already had both
industries at 1 out/1 in/1 Laborer and already dealt Freight House as a real sixth industry, 6
copies. Corrected the docs (`card-reference.md`, `glossary.md`, `rules-v0.2.md`,
`open-questions.md`) to match the engine; no engine change. Turned up that Mine Tipple, Produce
Shed and Power Plant may be similarly stale — flagged as a new item above rather than assumed.
- [x] **Poling — closed, v0.5.0.** Confirmed already at 0 copies, the same treatment as Sharp Curves,
pinned by `mainline-cards.test.ts`. No code change; removed from Rules Questions.
- [x] **Heavy Grade orientation stays rolled, permanently — v0.5.0, Jesse's call.** The card prints
"Player sets orientation", but a Heavy Grade sits on the shared Division chain between two
players (or beyond an end Division Point, next to one) — never inside one player's own district
— so there is no single player with a fair claim to the choice. Settled as random from the
seed, identically for solitaire and multiplayer, overriding the card's print. No code change
(the roll in `setup.ts` was already doing this); the comments and `implications.md` §10 Q11
previously framed it as a placeholder awaiting an interactive setup phase — corrected.
- [x] **Engines are not a separate supply from Crew Trays — confirmed, v0.5.0.** `rules-v0.2.md`:339
(Gap 4b) ties Crew Trays and engine pieces together as one combined resource, `player count +
3` — not two independently-tracked supplies. The engine already enforces exactly that via
`crewTrayCount`/`freeTrays`; `NO_FREE_TRAY` already fires exactly when engine supply would run
out too. No code change; corrected the comments that called this provisional or unsourced.
- [x] **Player settings, saved — v0.5.0.** A `localStorage` settings object (`SETTINGS_KEY`, separate
from the game save) now persists district auto-focus mode, sound on/off, and board zoom level
across reloads — all three previously reset every time. Falls back to today's defaults on a
missing, corrupt, or disabled `localStorage`, the same guard the save already had.
- [x] **The test suite's flakiness under `npm test` — fixed, v0.5.0.** Two changes: (1) a `pretest`
npm script now builds the shared `dist/` once, before `node --test` runs, so every test reading
`dist/` no longer depends on another test in the file having built it first; (2) the one test
that actually exercises the build COMMAND now builds into its own `dist-test/` directory
(`BUILD_DIST_DIR` env var, `scripts/build-web.ts`) instead of rebuilding the shared `dist/` out
from under the tests reading it. `dist/` is now single-writer.
- [x] **Curves and turnout diverging legs now draw as smooth curves, not two straight segments
meeting at a corner — v0.5.0.** `curvedRail` in `board-svg.ts` replaces the old hard-cornered
"run to the frog, then a straight 45° leg" with a sampled cubic-Bezier easement: tangent to
horizontal at the east/west edge (so an abutting straight card's rail still reads as one
unbroken line) and tangent to exactly 45° at the north/south edge (so two stacked curves still
read as one continuous diagonal). Both plain curve cards and a turnout's diverging leg go
through this same code path, so both are fixed by the one change.
- [x] **Wide boards can now be zoomed — v0.5.0.** Discrete zoom presets (75/100/125/150%) for both
the district grid and the Division map, applied by resizing the rendered SVG's own pixel
dimensions (not a CSS `transform`), so the existing `overflow-x:auto` scrollbars keep doing the
panning with no new gesture code. Persisted in the new settings object above.