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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user