Compare commits

..
6 Commits
Author SHA1 Message Date
Jesse bfd2708ecc v0.5.5 — a remembered session for a game that no longer exists
Reported after updating to v0.5.4: clicking Multiplayer went straight into
a game with no lobby and no controls, and the board was blank.

Three things lined up. start() enters a remembered session WITHOUT checking
it still exists — that is what makes reconnection seamless, and it is why
the lobby was skipped. The v0.5.4 update had refused to resume that game,
its save being recorded under v0.5.3 and the engine-version check being
exact (D7). And createRemoteSession had no onerror at all, so EventSource
retried the resulting 404 forever in silence while frame stayed null and
nothing rendered. The only escape was clearing site data, and nothing on
screen said so.

v0.5.3's Manage Game -> End had just widened the same dead end: it closes
every watcher's stream, so a player whose game an administrator ended would
sit frozen on a stale board indefinitely, for exactly the same reason.

GET /api/session?token= is new: a cheap yes/no on whether a token still
names a live game. EventSource fires error identically for a transient blip
— the expected shape of a game idle for minutes (§9) — and for a 404 it
will retry forever, and exposes no status code either way, so the client
asks rather than guessing. Only a definite 404 closes the stream and
reports the game gone; a flaky network still self-heals.

The page then forgets the stored session, says why (ended by an
administrator, or the service was updated, which does not carry games
across), and drops into the lobby. Forgetting the token is what stops the
next load repeating it. It also stops rendering nothing while it waits —
"… connecting to the game" sits in the presence banner until the first push
arrives, because a page showing nothing is indistinguishable from a broken
one, which is what this looked like.

Recorded but NOT fixed, in TODO.md: three releases in a row destroyed every
game in progress, and v0.5.4's changes were rendering only. The refusal is
right, but the test is exact equality against the PACKAGE version, which
moves for reasons unrelated to the rules. Three options costed; the
recommendation is to replay the save and refuse only if an intent actually
rejects — the real question rather than a proxy for it, and a full replay
measures ~100 ms.

Verified live: /api/session answers 200 for a seated token, 404 once an
administrator ends the game, 404 for a garbage token, and /api/stream 404s
in the same state — which is the response EventSource had been retrying
silently. 673 tests pass.
2026-08-21 17:55:59 -04:00
Jesse 689de2ff0f v0.5.4 — the map says whose railroad is whose
Six things found playing the StartOS build, all of them the game telling
you what it already knew.

The lobby's Start button did not look disabled when it was. The reported
symptom was "it says it's waiting for a player but Start is enabled" — it
wasn't: the note and the disabled assignment are two lines apart in the
same block. The page had only `header button:disabled` and `#actions
button:disabled`, and #lb-start is in neither, so a disabled button kept
its normal face AND still lit up under the cursor from the generic
button:hover. It advertised a click it would refuse. The rule is generic
now.

The game code was rendered as "— code TRESTLE-5109" in dim text beside a
heading, reading like a reference number rather than the thing you have to
send somebody. It is a labelled block at 22px with a Copy button, and a
clipboard refusal says the code can be selected instead of failing
silently. The blurb under it was also WRONG — it claimed the chairs were
"in the order everyone joined", which stopped being true in v0.4.1 when the
§4.4 D12 started deciding. It now says what actually happens.

Every Office on the Division map was labelled with its tier, which every
other player's Office also has, so four districts read identically and
"where does Bob sit" had no answer on the one map showing where trains are.
The owner's name takes the headline and the tier moves beside the A/D
count. Amber marks whose move it is — the same "happening here" the action
panel uses — and "(you)" is spelled out on the reader's own district,
because colour alone cannot say which of four railroads is yours. Turn
colour wins over the you-colour when both apply: whose turn it is changes
every few seconds, which railroad is yours never does.

Under the map, the chain in words with the roll behind it: "West to East:
Alice (1) → Bot 2 (5) → Bot 1 (11)". state.openingRolls has been kept for
exactly this since v0.4.1 and nothing had displayed it. It also answers
"is the host always at the eastern end" outright — no. Alice there is the
host, rolled lowest, and sits at the western end.

Supporting: Frame gained viewer and viewerSeat. Every private field on it
was already scoped to one player, but nothing said which player, so a page
could draw a railroad without being able to say whose it was — harmless in
solitaire, the first question at four seats. Frame also gained
openingRolls. Bots are Bot 1 / Bot 2 rather than all Bot, since two of them
are two different railroads. The standalone replay gets all of it: players,
actor and viewer are not delta'd keys in compress, so they ride whole on
every frame and replay.ts passes the same roster.

Verified: 673 tests pass (668 + 5). The new ones were mutation-checked —
removing the (you) suffix, never applying the turn mark, and reinstating
the pre-v0.4.1 identity seating each fail the suite. The seating test
deliberately asserts across six seeds that the eastern end is NOT always
player 0, which is the claim it exists to defend.
2026-08-21 17:12:25 -04:00
Jesse 2fbfe11977 v0.5.3 — a table you size yourself, and games an administrator can see and end
Both halves came out of playing the StartOS build. The wrapper's health
check and admin actions consume this; they land separately.

The host picks the table size (2-4) when creating a game, and the seats
array is built at that length once. Before, it GREW as people joined, so
the four rows on screen were partly fiction — a 2-player game just started
with a 2-long array, while a host who dropped a bot into a later chair
padded it with a null and silently disabled Start behind a one-line note.
A gap can no longer be written down rather than merely being refused.

That also avoided a trap. Compacting seats at Lobby.Start — the obvious
way to support a "closed" chair — would have shifted the player index that
every PlayerSession stamps at join time and that /api/stream and
/api/intent both route by, handing a player somebody else's railroad with
no error anywhere.

And it fixed a live balance bug: minCombinedRevenue is derived from the
player count, but the config was fixed at CREATE while the count wasn't
known until START, so the lobby guessed 4. Every 2-player game ran against
a floor of 60 instead of 30 — and missing the floor means everyone loses,
so a 2-player competitive game was set up to fail for a UI artifact rather
than a rule.

/api/health gained games:{active,lobby}, read from a new cheap summary()
on GameSession rather than exportSave(), which would copy every intent of
every game to answer a question about none of them. Three admin routes are
new behind an ADMIN_SECRET env var in an x-admin-secret header: GET
/api/games, GET /api/games/<id>/save, DELETE /api/games/<id>. Until now a
started game could not be ended by anyone — no route, no player action, no
resignation — so an abandoned game stayed active in the index and was
faithfully resumed on every boot, forever.

Three deliberate choices there: the admin secret is NOT the join secret,
which every player holds and which would therefore let anyone at the table
destroy anyone else's game; unset means the routes 404 exactly as any
unknown path does, with or without a header, so a server never given an
administrator doesn't advertise that it has one; and a delete returns the
deleted game's save, since the intents are the game (D5) — nothing is
destroyed without being handed to whoever destroyed it.

SavedGame gained an optional lastMoveAt (falling back to createdAt) so
"has this stalled?" survives a restart. Kept out of history for the same
reason the turn timings are: a replay must reproduce a game from decisions
alone, and wall-clock is not a decision.

index.ts logs "Resuming N saved games..." before the loop rather than one
line per game after it. Measured a full 4-player game at 100ms to replay,
and only unfinished games are replayed, so listening before loading would
have bought nothing for the cost of a "still loading" state everywhere.

Verified: 667 tests pass (662 + 5), and the new session tests were checked
against two mutations (lastMoveAt never advancing; resume dropping it) to
confirm they fail without the code. Live against a running server: health
counts tracking through the lobby->game transition, admin auth rejecting a
missing and a wrong secret, list/export/delete, the deleted game's files
and index entry actually gone from disk, a second delete 404ing, the admin
routes invisible when ADMIN_SECRET is unset, and a 3-player table refusing
a 4th player and a size of 5 refused at the door.

Also carries the TODO items raised on 2026-08-21: the lobby offering no
game parameters (the floor bug within it now fixed, the form still
missing), and the four optionalRules — of which only reducedVisibility and
emergencyToolbox are read by anything, while sisterTrains and
employeeRotation are declared, defaulted, and consulted nowhere.
2026-08-21 14:53:53 -04:00
Jesse 62b6ed7e1b v0.5.2 — the splash's multiplayer door opens, and knows whether it should
The "Play multiplayer" door on index.html had sat disabled, labelled
"Coming soon", since before the server existed — Phases 2 through 4 built
a working lobby and nothing ever linked to it. Loading the site landed on
the same solitaire splash whether a real multiplayer server was behind it
or not, with no visible way in. Found packaging Phase 6 for StartOS.

The door is now a live link to ./play.html?lobby, and main.ts's start()
routes ?lobby straight to the lobby screen — the same showScreen('lobby');
runLobby(beginRemote) the in-game Multiplayer button already used —
instead of dealing a solitaire game first.

GET /api/health is new, and exists to be failed. The same dist/ ships both
served by src/server/ and uploaded as flat files by deploy-web.ts, and the
bundle is identical either way (D4), so the page cannot know from its own
build which it is; every other route 404s an unknown path exactly as a
static host does, so nothing distinguished them. The splash probes it on
load and closes the door when nothing names itself in reply.

The door starts open and only ever closes, deliberately: a wrong "no
server" is the bug above again — invisible, and it strands a player who
does have one — while a wrong "there is one" costs a click and a lobby
that says it cannot connect. The reply must name itself rather than merely
return 200, or a host answering every path with its index page would pass.

Verified: tsc clean; 659 tests pass (656 + 3); /api/health exercised live
against a running server — 200 with the right body, unauthenticated, while
an unknown path and a wrong method both still 404, which is what makes the
probe discriminate at all.

The probe's own test was vacuous on the first attempt — both its "closes"
cases reached close() through the .catch arm, so deleting the body-naming
check outright still passed. Caught by mutating splash.ts and re-running;
the test now covers all three closing routes and fails without the check.
2026-08-21 11:00:06 -04:00
Jesse e76bd77099 v0.5.1 — multiplayer Phase 4: lobby, sessions, reconnection
A real server existed since v0.5.0 but nobody could reach it without a hand-built ?seat=&secret=
URL. This is what makes it a game you can actually create or join.

The server now hosts more than one game: src/server/lobby.ts (new) is pure logic — creating,
joining, bot seats, host transfer, starting — same split session.ts already draws for a running
game. persistence.ts gained one directory per gameId plus a top-level index so index.ts resumes
every saved game on boot. /api/stream and /api/intent now authenticate by session token instead of
?seat=&secret= — the token alone proves identity (lobby-and-sessions.md §1), so the join secret's
job ends at the lobby door.

Bots fill empty seats at Lobby.Start only, never take over a disconnected human (D8): session.ts
gained driveBots(), playing developerBot forward through consecutive bot seats after every accepted
intent. Disconnect keeps the seat and says so — Push gained an optional presence field, built
entirely by http.ts and never routed through the engine, since a disconnect is transport news, not
a GameEvent. Host rights pass to the earliest-joined remaining player if the host drops before
start.

Client: src/web/lobby.ts adds create/join forms and a live seating screen; localStorage replaces
?seat= for reconnecting straight back into a game already joined. A Multiplayer button sits beside
New game; the New Game dialog itself is untouched.

Found only by the live smoke test, not by typechecking: /api/intent read its token from the JSON
body while the client sends it in the query string (matching /api/stream) — every intent failed
"no such game" until caught by curl-level verification.

Doc fix: multiplayer.md's D18 said the player cap was 6; lobby-and-sessions.md §2 says 2-4 with the
reasoning and the test coverage to back it. The two had drifted apart. D18 now reads 2-4.

Not verified: an actual browser walking through the lobby screens — none available in this
environment, same limitation Phase 2's RemoteSession shipped under. 656 tests, 0 failures.

tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this
release.
2026-08-21 05:25:47 -04:00
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
70 changed files with 10342 additions and 5228 deletions
+1
View File
@@ -15,6 +15,7 @@ Thumbs.db
# "tsc: not found" and 20 tests failed for a reason that had nothing to do with the code.
node_modules
dist/
dist-test/
build/
target/
__pycache__/
+330 -176
View File
@@ -19,223 +19,377 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.4.9f — 2026-08-22
## 0.5.5 — 2026-08-21
Three more from the v0.4.9e gameplay-testing round, filed as Gitea issues: **#4** extras did not start
where the player said, **#6** train cards could be discarded, **#7** four train cards had the wrong
coach counts. Two further bugs were found underneath #4 and are fixed with it. **Gitea#2 is diagnosed
but NOT fixed** — it needs a ruling, and the reasoning is in `TODO.md`.
One bug, found by updating to v0.5.4 and clicking Multiplayer: the page went straight into a game
with no lobby and no controls, and the board was blank.
The same change ships as **0.6.2** on the main line. The full reasoning for every item is in that
line's CHANGELOG; this entry records what reached the playtest build and what differs.
### A remembered session for a game the server no longer has
### Gitea#4 — an Extra starts where the player puts it
Three things lined up. `start()` enters a remembered multiplayer session **without checking it
still exists** — that is what makes reconnection seamless, and it is why the lobby was skipped.
The v0.5.4 update had **refused to resume** that game, because the save was recorded under v0.5.3
and the engine-version check is exact (D7). And `createRemoteSession` had **no `onerror` at all**,
so `EventSource` retried the resulting 404 forever, in silence, while `frame` stayed null and the
page rendered nothing.
Both Division Points are now offered, not the one the train's number dictates, and **the start decides
the direction** rather than the number — the two cannot both hold, since an odd westbound Extra placed
at the WEST end would leave the Division on its first move having crossed nothing and be paid for the
run. At an Interchange or a Control Point, where both ways are real runs, the player picks.
The only escape was clearing site data, and nothing on screen said so.
The Interchange start is a **yard**, off the running line, which is what makes the Superintendent
clause work: placing an Extra there can never force a collision however busy the card is; a guaranteed
collision holds it in the yard for another Stage and it tries again; a potential one is the
Superintendent's to rule on. Those are §8.1's two existing answers, so nothing new decides collisions.
The same dead end had just been widened by v0.5.3's **Manage Game → End**, which closes every
watcher's stream: a player whose game an administrator ended would sit frozen on a stale board
indefinitely, for the same reason.
Where an Extra may start is a **new setting in the New Game dialog** — Division Points and the
Interchange only, plus your own Control Point, or plus any player's — defaulting to what the engine
already did, so this build does not change under its testers mid-line.
**The fix.** `GET /api/session?token=…` is new — a cheap yes/no on whether a token still names a
live game. `EventSource` fires `error` identically for a transient blip (the expected shape of a
game idle for minutes, §9) and for a 404 it will retry forever, and exposes no status code either
way, so the client asks. Only a definite 404 closes the stream and reports the game gone; a flaky
network still self-heals as before.
**Found underneath it, and both had been shipping:** an Extra started anywhere but a Division Point
could never be given a consist and ran empty (so the Control Point start has been broken since it was
added), and `collide` left destroyed trains' transits on the Mainline card, which permanently poisoned
that card for every later train.
The page then forgets the stored session, says why — ended by an administrator, or the service was
updated, which does not carry games across — and drops into the lobby. Forgetting the token is what
stops the next load repeating it.
**The Mainline cards are now dealt from the printed deck without replacement.** They were drawn from
the nine types WITH replacement, so a Division could hold two Interchanges — and "an Extra may start at
the Interchange if one is on the board" only reads as a rule if the board holds at most one.
It also stops rendering nothing while it waits: "… connecting to the game" sits in the presence
banner until the first push arrives, because a page showing nothing is indistinguishable from a
page that is broken, which is precisely what this looked like.
### Gitea#6 — a train card is never discarded
### Recorded, not fixed
Timetabled and Extra alike. Hold one for as many Stages and Days as you like; it never goes onto a
Department pile. **No forcing mechanism was needed:** nothing discardable plus a hand over the limit
leaves exactly one legal way to end the turn, and playing a train is unconditionally legal, so a hand
of four trains cannot trap anyone. The hand panel says so on the train itself, and the blocked
end-turn button changes its wording when every card held is a train.
### Gitea#7 — coach counts
**1/2 Crack Limited 3 coaches → 2. 5/6 The Sparrow 2 → 3.**
### What differs from the main line
- The **car-placement round rotation test** that accompanies #7 on main was not ported: that test
arrived with the 0.6 multiplayer work and does not exist on this line, so there was nothing for the
coach-count change to follow.
- `src/web/main.ts` and `test/web.test.ts` needed hand-resolution — this line's New Game dialog has
neither the mode radios nor the victory dials, so only the Extra-start parts were taken.
- The `content.ts` comment pass came across whole; the `TODO.md` documentation item did not, since
this line keeps its own TODO.
**Both published replays were retired by the rules change and re-recorded.** 623 tests pass.
### The deploy script was broken by a host upgrade
Not part of the playtest report — found trying to publish this build. Every deploy died with
`login failed: 404 404 page not found`, because the File Browser host has been upgraded to
**FileBrowser Quantum**, a fork whose API differs from the v2.63 one `deploy-web.ts` was written
against. Three things moved at once, each fatal on its own:
1. **Auth is a session COOKIE**, not a JWT in the response body sent back as `X-Auth:`.
2. **The password is a header** — `X-Password`, URL-encoded — not a JSON body field.
3. **The path is a query parameter** (`?path=`), and every resource call must also name a
**`source`**: Quantum can serve several named stores and refuses any call that does not say which
("no source provided"). The v2.63 API had no such concept.
Rewritten against the running instance's own bundle rather than guessed — the same discipline the
v2.63 version was written with — and each path confirmed against the live host by response code: an
endpoint that exists answers a bad password with **401**, one that does not answers **404**. The
source is discovered from `GET /api/settings/sources`; one configured source is used silently, and
several makes the script stop and list them rather than deploy the site into the wrong store.
`FB_SOURCE` overrides it, and `FB_OTP` carries a two-factor code.
`TODO.md` now carries the underlying problem: **three releases in a row destroyed every game in
progress, and v0.5.4's changes were rendering only.** The refusal is right — a move legal under old
rules may not be legal under new ones — but the test is exact equality against the *package*
version, which moves for reasons that have nothing to do with the rules. Three options are costed
there; the recommendation is to replay the save and refuse only if an intent actually rejects,
since that answers the real question rather than a proxy for it, and a full replay measures ~100 ms.
---
## 0.4.9e — 2026-08-22
## 0.5.4 — 2026-08-21
Six bugs came back from a gameplay-testing session on 0.4.9d. Five are fixed here; the sixth could not
be reproduced and is written up in `TODO.md` with the two questions that would pin it down. Patched
onto the 0.4.9d line, and applied to the 0.5/0.6 line as the same change.
Six things found by playing the StartOS build, all of them about the game telling you what it
already knows.
### Two trains at one platform answered to one button
### A disabled button that did not look disabled
**REPORTED:** "Operating two trains in a station: the select button does not work. Regardless of which
you pick, it is always one train, not the other."
Reported as "the Start button is enabled when it says it is waiting for a player". It was not — the
note and the `disabled` assignment are two lines apart in the same block, so a lobby waiting on a
chair had a genuinely disabled button. The page had only two `:disabled` rules, `header button` and
`#actions button`, and `#lb-start` is in neither, so it kept its normal face **and** still lit up
under the cursor from the generic `button:hover`. It was advertising a click it would refuse. The
rule is generic now.
It did not work because there was nothing for it to do. `porter.board` and `porter.detrain` carried
**no tray at all** — `{ type, at }` and nothing else — so there was one "board passengers at (0,0)"
button however many trains were standing at the platform, and the reducer walked `adOccupancy` and
filled the first empty coach it met. The roster chip the player clicked chose which crew the board
DREW and nothing else. Two independent things were wrong at once:
### The game code is the invitation
- `check` asked whether SOME train at the Office had an empty coach, skipping any whose card refuses
passenger work (`refusesPassengers`, `refusesThisOffice`). The reducer did not skip those. So with a
Military train and an ordinary one at the same platform, `check` said yes on behalf of the ordinary
one and the reducer boarded the Military.
- The action list collapses identical labels, and "board passengers at (0,0)" describes both trains —
the same trap that once ate a turnout's second rotation and a Department discard.
It was rendered as `— code TRESTLE-5109` beside the "Seating" heading, in dim text, reading like a
reference number rather than the thing you have to send someone. It is now a labelled block —
"Send this code to your players" — at 22px, with a Copy button beside it. Clipboard access is
unavailable on an insecure origin and can be refused outright, so a failure says the code can be
selected instead of silently doing nothing.
Both intents now carry an optional `trayId`, one function (`passengerWork`) resolves which train and
which coach for `check`, `execute` and the reducer alike, `legal.ts` enumerates one candidate per
train standing at the Office, and the label names it: *"board passengers at (0,0) onto Train 9"*.
`trayId` is OPTIONAL for the reason `switch.move`'s `via` is — intents are the canonical record every
save and undo replay against, and absent still means "the first eligible train".
The blurb under it was also **wrong**: it said the chairs were "West to East, in the order everyone
joined", which has not been true since v0.4.1. §4.4's D12 decides, at start, and the lobby now says
so rather than claiming the opposite.
The events carry `trayId` and `coachIndex` rather than leaving the reducer to find them again, which
is the lesson `unloadBegan`'s `carIndex` already taught: a reducer that re-derives the target is a
second implementation of the rule, and it disagreed with the first.
### The Division map names its districts
### A load could be made and broken without going anywhere
Every Office was labelled with its tier, which every other player's Office also has, so four
districts read identically and "where does Bob sit?" had no answer on the only map that shows where
trains are. The owner's name takes the headline and the tier moves down beside the A/D count,
because the name is what is being looked for and the tier is what it is called once found.
**REPORTED, twice over:** "Freight House: boxcars loaded cannot be immediately unloaded. In the game
we'll put the chip upside down in the tray to indicate." And: "Passenger stations: passengers just
boarded cannot be immediately unloaded."
Two marks on top of that: **amber for whose move it is**, the same "it is happening here" the
action panel uses, and **"(you)"** spelled out on the reader's own district. Colour alone cannot
say which of four railroads is yours, and that is the first thing you want at a table you have just
sat down at. Where both apply, the turn colour wins — whose turn it is changes every few seconds
and which railroad is yours never does.
They could. A Freight House permits both directions, so the boxcar its own Laborers had just loaded
was standing on its own industry track, loaded, with an empty of that type in the Division Yard and a
free red box — every gate said yes. Passengers were worse: `porter.board` filled a coach and
`porter.detrain` looked for "a loaded coach on a train at the Office", which is the coach that had
just been filled. Full Revenue at both ends of a movement that never happened, for one Porter action.
Underneath the map, the chain in words with the roll that decided it: *West to East: Alice (1) →
Bot 2 (5) → Bot 1 (11)*. That is what `state.openingRolls` has been kept for since v0.4.1 and
nothing had yet displayed — and it answers "is the host always at the eastern end" outright. No:
Alice there is the host, rolled lowest, and sits at the western end.
**Jesse's rule, and it is wider than the report:** freight or passengers loaded anywhere in an Office
Area may not be unloaded anywhere in that same Office Area — not at another facility, not in a later
Stage. A train has to carry them to a different district. So a load carries a stamp naming the SEAT
that made it (`RollingStock.origin`), and the stamp never expires; `laborer.beginUnload` and
`porter.detrain` refuse a car stamped with the district they are standing in, with a rejection code of
its own (`LOADED_IN_THIS_DISTRICT`) because "the car is loaded, the Laborer is free, the boxes are
clear, and the only thing wrong is where it came from" deserves better than "wrong car".
### Supporting changes
**A seat, not a player**, because Employee Rotation moves players between chairs and the district
stays with the chair. **Undefined, not −1**, for "no origin": the Division Yard opens with loaded cars
and loaded coaches out of the common supply, and those are exactly the inbound traffic a solitaire
district lives on — a sentinel inside `SeatIndex`'s own range is not a sentinel. And `pooled` strips
the stamp at every yard push, because the stamp belongs to the LOAD: a train can retire at a Division
Point with freight still aboard, and that car must not carry a district it left three Days ago into
whatever train is made up from it next.
`Frame` gained `viewer` and `viewerSeat`. Every private field on it was already scoped to one
player — hand, Office Area, `revenue`, `option`, `movesLeft` — but nothing said which player, so a
page rendering a Frame could draw a railroad without being able to say whose it was. Harmless in
solitaire; the first question at four seats. It also gained `openingRolls`.
**Measured: −0.60 ± 0.10 Revenue a game** (t = −6.1) over 400 paired deals — 78 deals worse, 3 better,
319 unchanged. That shape is the point. This is not a nerf spread across the game; it is a narrow
piece of free Revenue coming off the board, and on four deals in five the bot never took it.
Bots are named `Bot 1`, `Bot 2` rather than all being `Bot`: two of them at one table are two
different railroads, and a map labelling both the same cannot say which is which.
The screen's version of the upside-down chip: a car or coach loaded by this district reads *"loaded
boxcar (loaded here)"* on the card, in the tray and in the facility panel.
The standalone replay gets all of this too — `players`, `actor` and `viewer` are not among the
delta'd keys in `compress`, so they ride whole on every frame and `replay.ts` passes the same
roster the live page does.
### The Grocer's Warehouse shipped, and the Refinery received
---
**REPORTED:** "Grocer's warehouse should be receive only, does not ship anything out." And:
"Refinery: only ships out tanks, does not receive anything."
## 0.5.3 — 2026-08-21
Both were `flow: 'both'` in `content.ts`, put there deliberately and for a reason that has since
collapsed. `card-reference.md` read: *"'Freight House' is not a card. It is the collective term for a
freight facility that loads and unloads — the Grocer's Warehouse and the Oil Refinery."* If that were
true, §9.3's "Passenger Facilities and Freight Houses permit cars to move each direction" named
exactly those two, and they had to be two-way. But the engine has dealt a `freightHouse` card since
before v0.4.9 — 6 copies, one slot each direction — so §9.3 names it, and the argument evaporates.
Everything a StartOS administrator needs to see and manage a server full of games, plus the seat
control that came out of the first real multiplayer session.
The card set says the same thing without needing the rules text. All three Refinery modifiers —
Pipelines, Oil Depot, Viscosity Breakers — grant **+1 outbound**; a two-way Refinery would be the only
industry in the game with no card able to raise one of its two directions.
`StationMaster-Home-Deck-v0.4.5.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
Warehouse · Inbound · 0 out / 1 in".
### The host picks the table size, and a gap stops being expressible
So the Refinery ships and the Grocer's receives, and the **Freight House is the one two-way industry**
— which also means the only same-district load-and-unload the district rule above has to refuse is a
Freight House unloading its own work. The two fixes meet exactly where the report said they would.
The seats array used to GROW as people joined, which made the four rows on screen partly fiction:
a 2-player game just started with a 2-long array, while a host who dropped a bot into a later chair
padded the array with a `null` and silently disabled Start behind a one-line note. The host now
chooses 2, 3 or 4 when creating the game and the array is built at that length once. A gap cannot
be written down rather than merely being refused.
A consequence worth naming rather than discovering: an **Ice House beside a Grocer's Warehouse is now
a dead card**, its +1 outbound dropped on a direction the host does not have. That is the design, not
an oversight — the Home Deck sheet says so outright, and names the Truck Dock's inbound grant beside
the outbound-only Packing Sheds as the other example. `suppressedGrants` already reports it on the
page. It does mean the v0.4.7 note in `TODO.md` that opened these two facilities up was half wrong,
and it is annotated there rather than deleted: the *machinery* it built (an industry's printed flow is
absolute; drop the grant, never open the direction) is exactly what makes this correction land.
That also removed a trap nobody had sprung yet. Compacting seats at `Lobby.Start` — the obvious way
to support a "closed" chair — would have shifted the `player` index that every `PlayerSession`
stamps at join time and that `/api/stream` and `/api/intent` both route by, handing a player
somebody else's railroad without an error anywhere.
### Not reproduced: cars left behind when backing up over them
**And it fixed a live balance bug.** `minCombinedRevenue` is derived from the player count, but the
config was fixed at CREATE while the count was not known until START, so the lobby guessed 4. Every
2-player game was playing against a floor of 60 instead of 30 — and missing the floor means
everyone loses, so a 2-player competitive game was set up to fail for a reason that was a UI
artifact rather than a rule. The real count now reaches `defaultMultiplayerConfig`.
**REPORTED:** "When I back up to collect standing cars and, further down the tracks, the caboose, I
get the caboose but the cars remain. I can later drive right through them."
### Administration: what is running, and how to end it
Not found, and not for want of looking. Cars on plain track on the way; cars spotted at an INDUSTRY on
the way (Jesse's own guess at the shape); the train's own cut on the square it is pulling out of; a
stale `standingWest`; an industry locked by MEN AT WORK. Every one couples the lot, and the last
correctly blocks the whole route rather than letting the crew past. Three of them are now pinned in
`apply.test.ts` so the case, when it is found, is somewhere none of them cover.
`/api/health` gained `games: { active, lobby }`, which is what the StartOS package's health check
reports as "3 games in progress, 1 waiting to start". It reads `summary()` — a new, cheap
`GameSession` accessor — rather than `exportSave()`, which would copy every intent of every game to
answer a question about none of them.
The reason it is hard to make happen is structural: coupling is mandatory (§A.4) and `exploreMoves`
accumulates what it meets card by card, so a route that reaches the caboose has already met everything
between. `carsOn` is the single answer to "what is standing here", and the movement walk, the sweep in
`carsCoupled` and every renderer all ask it — so cars a train can drive through would have to be cars
that are on screen and not in `carsOn`, and there is no such place.
Three administrative routes are new, gated by an `ADMIN_SECRET` env var in an `x-admin-secret`
header: `GET /api/games` (every game and lobby, summarised — players, names, started-at,
last-move-at, Day/Stage/phase, and who it waits on), `GET /api/games/<id>/save`, and
`DELETE /api/games/<id>`. Until this, a started game could not be ended by anybody: no route, no
player action, no resignation. An abandoned game stayed `active` in the index and was faithfully
resumed on every boot, forever.
There was one way to MAKE such a place, and it is closed: `flyingSwitch`'s reducer wrote the cut
straight into `industryTrack`, which is not where `carsOn` looks on a Passenger Facility. `check`
refuses a non-freight target so it never fired, but a trap that needs another rule to stay unsprung is
still a trap; it goes through `carsOn` now.
Three deliberate choices in that:
`TODO.md` carries the two questions that would settle it: was there a second route to the caboose, and
what did the move button say it would couple. The label names every car, so "couples caboose" and
"couples 2 boxcars, caboose" are different bugs — the first is route selection, the second the sweep.
- **The admin secret is not the join secret.** Every player holds the join secret, so gating a
delete with it would let anyone at the table destroy anyone else's game.
- **Unset means the routes are not there** — 404, the same answer as any unknown path, with or
without a header. A server never given an administrator does not advertise that it has one.
- **A delete returns the deleted game's save.** The intents are the game (D5), so that is the whole
thing and not a summary: nothing is destroyed without being handed to whoever destroyed it.
### Also
`SavedGame` gained `lastMoveAt` so "has this stalled?" survives a restart. It is optional and falls
back to `createdAt`, and it is kept out of `history` for the same reason the turn timings are — a
replay must reproduce a game from decisions alone, and wall-clock is not a decision.
- **An unload never checks the facility's commodity** — found reading `laborer.beginUnload` for the
district rule, not from play. It gates on `allows.inbound`, a loaded car, a matching empty in the
yard and room in the red box, but never on `facilityCarTypes`, which `freightAgent.stockOutbound`
does check. So a Freight House will unload a hopper. Left alone and logged: the district rule now
refuses the one same-Office pairing that made it easy to reach, and the fix is a rule question about
what an industry will accept, not a one-line guard.
- **Both published replays that had gone dead were re-recorded** (`save-replay.ts 400 --top 3`). A save
is a save from a particular ruleset, so a rules change retires the files that no longer replay —
`harness.test.ts` catches it, which is what that test is for.
### Boot
`Resuming N saved games…` is logged *before* the replay loop rather than one line per game after
it, so the pause before the port opens has a reason on screen while it is happening. Measured at
**100 ms** for a full 4-player game, and only unfinished games are replayed — so the pause is
tenths of a second in practice, and listening before loading would have bought nothing for the cost
of a "still loading" state on every route.
---
## 0.5.2 — 2026-08-21
Found packaging Phase 6 for StartOS: the splash's "Play multiplayer" door had sat `disabled`,
labelled "Coming soon," since before the server existed — Phases 2 through 4 built a working
lobby and nobody ever pointed a link at it. Loading the site landed on the exact same solitaire
splash whether a real multiplayer server was behind it or not, with no visible way in.
`index.html`'s door is now a real link to `./play.html?lobby`, matching the other two doors.
`main.ts`'s `start()` checks for `?lobby` and routes straight into the lobby screen — the same
`showScreen('lobby'); runLobby(beginRemote)` the in-game Multiplayer button already used — instead
of dealing a solitaire game first and leaving the player to find that button themselves.
### The page can now tell whether a server is behind it
The same `dist/` ships two ways — served by `src/server/`, or uploaded as flat files by
`scripts/deploy-web.ts` with no server at all — and the bundle is byte-identical in both, because
there is one client and the mode is decided at runtime (D4). So the splash could not know from its
own build which it was, and nothing else distinguished them either: every route in `http.ts`
answers a 404 for a path it does not have, exactly as a static host does.
`GET /api/health` is new, and exists to be failed: `{ ok, service, engineVersion }`, no
authentication (it says only that a Station Master server is answering, which is what the door is
about to offer anyway — no game, no seat). The splash probes it on load and closes the door when
nothing names itself in reply.
**The door starts open and only ever closes**, deliberately. A wrong "no server here" is the bug
above all over again — invisible, and it strands a player who *does* have a server. A wrong "there
is one" costs a click and a lobby that says it cannot reach a server, which is legible and
recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it. The
reply has to name itself rather than merely return 200, since a host that answers every path with
its own index page would otherwise pass.
Bug fix: the lobby machinery was already complete and tested (Phase 4, v0.5.1); this only
re-enables the door to it, and teaches the splash when to.
Worth recording about the tests: the first version of the probe's test passed with the naming
check deleted outright. Both of its "door closes" cases happened to reach `close()` through the
`.catch` arm, so the branch that actually reads the body was never run — and the comment claimed
otherwise. Caught by mutating `splash.ts` and re-running rather than by reading it.
---
## 0.5.1 — 2026-08-21
Multiplayer Phase 4 — lobby, sessions, reconnection (`docs/architecture/multiplayer.md` §12 steps
17-20, fully specified in `docs/architecture/lobby-and-sessions.md`). Phases 0-3 shipped in v0.4.0
and v0.5.0; a real server existed but nobody could reach it without a hand-built URL. This is what
makes it a game you can actually create or join.
### The server hosts more than one game, and knows who you are across a reconnect
`src/server/lobby.ts` is new: pure logic, no sockets, no filesystem, the same split `session.ts`
draws for a running game. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, plus a
speakable game code (`RAIL-4471` style) and the player cap.
`persistence.ts` gained one directory per `gameId` and a top-level index, so `index.ts` resumes
every saved game on boot, not just one. `/api/stream` and `/api/intent` now authenticate by session
**token** instead of `?seat=&secret=` — `lobby-and-sessions.md` §1: the token alone proves identity,
so the join secret's job ends at the lobby door (`/api/lobby/create`/`/api/lobby/join`).
### Bots fill empty seats, never take over a disconnected human (D8)
`session.ts` gained `driveBots()` — after any accepted intent, and once at construction for a
resume that lands exactly on a bot's turn, it plays `developerBot` forward through every
consecutive bot seat before the push goes out. Reuses `legalActions`/`developerBot` wholesale.
`SavedGame` gained `botSeats` so a bot seat survives a restart. Bots are assigned once, at
`Lobby.Start`, and never afterward — a disconnected human's seat waits, exactly as before.
### Disconnect keeps the seat and says so; reconnect gets a full view, not a tail
`Push` gained an optional `presence` field — connection news about another seat, built entirely by
`http.ts` (which owns the connection table) and never routed through `session.ts` or the engine: a
disconnect is transport news, not a `GameEvent`, and the engine must stay replayable from a seed.
The page shows a small banner naming who has dropped and clears it the moment they reconnect.
Host rights pass to the earliest-joined remaining player if the host's own connection drops before
`Lobby.Start` — tracked by join order rather than seat, since a bot-filled seat never joined at all.
### The client: an actual lobby, not a URL you hand-build
`src/web/lobby.ts`, wired from `main.ts`: create-or-join forms, a live seating screen (host-only bot
toggles and Start button, updated over its own SSE stream), and `localStorage` in place of `?seat=`
for "was I already in a game" — found on load, it reconnects straight through and skips the lobby
screen entirely. A `Multiplayer` button sits beside `New game`; the New Game dialog itself is
untouched and still solitaire-only, its old "needs a server" note repointed at the new button.
### Found only by the live smoke test, not by typechecking
`/api/intent` read its token from the JSON body; `web/session.ts`'s `submit()` — unchanged since
Phase 2 — sends it in the query string, the same as `/api/stream`. Every intent failed `no such
game`. Both sides typecheck cleanly on their own (an HTTP body is `unknown` on the wire), which is
exactly the gap a curl-level smoke test exists to catch: create a lobby, join a second player,
start, submit from both seats (including a wrong-actor rejection and an idempotent resend),
kill and restart the server and reconnect both tokens, start a bot-filled coop lobby and confirm it
never stalls waiting on the bot, and watch a live stream receive a disconnect/reconnect presence
notice for another seat.
### Doc fix
`multiplayer.md`'s decision table (D18) said the player cap was 6; `lobby-and-sessions.md` §2 —
more detailed, and what `test/multiplayer.test.ts` actually exercises — says 2-4 with the reasoning
for it. The two had quietly drifted apart; "6" was never implemented or tested anywhere. D18 now
reads 2-4.
**Not verified: an actual browser** walking through the lobby screens — none is available in this
environment, the same limitation Phase 2's `RemoteSession` shipped under. 656 tests, 0 failures.
---
## 0.5.0 — 2026-08-21
Multiplayer Phases 2 and 3 (`docs/architecture/multiplayer.md` §12): a real server exists now, one
game at a time, and it survives being restarted mid-game. Phases 0 and 1 shipped in v0.4.0; Phases
4–6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Also
folds in the three playtest fixes already released as v0.4.9b/c/d on the patch line, plus two rules
bugs and a victory-condition redesign found along the way.
### Phase 2 — server core, one game, no lobby
- `src/server/session.ts` — the game session host: pure logic, no sockets, built entirely on
`game.ts`'s existing `Game`/`submit`/`currentActor`/`actionMenu` rather than re-deriving intent
application or narration. **Found while building it:** `submit()` derives the acting player from
`currentActor(game)` and never checks who is actually calling it — harmless for `LocalSession`
(only one possible caller) but not safe for a server, so the session host now verifies `seat ===
currentActor(game)` itself before calling `submit`, rejecting with `NOT_YOUR_TURN` otherwise.
Idempotent resend (a repeated `seq`) and the illegal-intent path (checked via `check()` directly,
so a rejection never pollutes the shared narration log with text meant only for the submitter) are
both handled here.
- `src/server/http.ts` / `src/server/index.ts` — plain `node:http`, no framework: `POST /api/game`,
`GET /api/stream` (SSE, per-seat, 20s heartbeat, `id:` line per push), `POST /api/intent`, and
static serving of `dist/` so the server is same-origin with itself.
- `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a smaller
replacement purpose-built for a single live push rather than reusing `replay.ts`'s `compress()`,
which interns strings across a whole recorded array with nothing here to intern against; only its
one-step-back "null if unchanged" idea carried over.
- **Found and fixed:** `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 with a guard in `game.ts`; tested in `multiplayer.test.ts`.
- The redaction test (§7, `test/redaction.test.ts`) passed on the first run against the existing
`snapshot()`, confirming it was already correct rather than just apparently so.
- `src/web/session.ts` gained `createRemoteSession`; `main.ts`'s `start()` switches on `?seat=`
presence (one bundle, unchanged). Every `LocalSession`-only call site in `main.ts` now goes through
an `isLocal()` type guard instead of assuming.
- Verified two ways: `test/server/session.test.ts` exercises the session host directly, and a live
end-to-end 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, the board correctly nulled on the second push). **Not verified: an
actual browser** — no browser binary in this environment, so `RemoteSession`'s DOM-facing code
compiled and typechecks but was never clicked through visually.
### Phase 3 — persistence and resumption
- `src/server/persistence.ts` — `game.json` (`{engineVersion, seed, config, playerNames, history,
status, createdAt}`) and `turn-timings.json`, both atomic-rewrite-then-rename.
- `game.ts` gained `fromMultiplayerSave`, `fromSave`'s multi-player sibling. **Found while testing
it:** `fromSave`'s replay loop calls `record(game, result.events)` without the `actor` argument
`submit()` always passes, so every replayed line loses its "Player X" attribution — invisible for
solitaire, 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, deliberately untouched here since it's used far more widely (undo, save/restore, the replay
viewer) and deserves its own pass.
- `session.ts` gained `exportSave()`, `resumeSession()`, and turn-timing tracking — a span (player,
phase, day, stage, start/end wall-clock) that closes and reopens whenever the acting player, phase,
Day or Stage changes, recorded entirely in the session host and never inside `history` (a replay
must reproduce a game from decisions alone).
- `index.ts` loads `game.json` on boot before starting the listener: version match → resumed and
replayed straight through; mismatch → refused explicitly and loudly, file left untouched, server
starts with no active game rather than replaying under the wrong rules.
- Verified live: 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,
correct narration attribution. Separately confirmed the version-mismatch path with a hand-edited
`engineVersion`.
- **Found and fixed an infrastructure bug along the way:** adding `test/server/` broke `npm test`'s
glob. `"test": "node --test test/**/*.test.ts"` relied on the shell passing the literal,
unexpanded pattern through whenever it matched no files at the shell level — the moment a
subdirectory existed, the shell expanded it to just that one file, and `npm test` silently ran only
the new suite. Fixed by listing both depths explicitly.
### Two rules bugs found while building this
- **New Train phase car-placement is one player's job even in competitive mode — it should be a
round.** §7 is explicit: starting with the Superintendent and working left, each player places one
car, and the round repeats until the consist is full. `newTrainPhase` never implemented the round —
`enterPhase` resets `actorOffset` to 0 on entry and nothing ever incremented it the way Local Ops
does — so the actor was always the Superintendent alone, for every car of every train, in every
mode. Fixed by reading the round position off `tray.consist.length`, which already counts
placements toward that tray and resets per train with no new state needed.
- **Victory conditions unified across solitaire, competitive and coop.** One shared, configurable
`GameConfig` set (`days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
`pvpCardsAllowed`) replaces the old fixed `LENGTH_PROFILES.target`, a dead `firstToTarget` victory
condition, and a flat collision-floor constant. Collision caps stay flat rather than scaling with
player count — 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. One New
Game dialog now covers all three modes, with fields greyed out wherever a mode forces a value.
### The three v0.4.9b/c/d playtest fixes, folded in
Already shipped on the patch line — see 0.4.9d below for the full writeup of each. Summarized: a
switching train's crew badge failed to draw once it left the Office square (display only, game state
was never affected); unloading a freight car always took the westmost one regardless of which car
was picked; and a legal decision (`newTrain.startExtra`) could render with zero buttons, which was
indistinguishable from a hang. 635 tests, 0 failures.
---
## 0.4.9d — 2026-08-21
-8
View File
@@ -121,14 +121,6 @@ is the thing this machinery exists to prevent.
it was worth more than the entire freight and passenger economy put together, for traffic nobody
has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside
it, and every save records the rules it was dealt under.
- **A load may not be broken in the district that made it.** Freight or passengers loaded anywhere in
an Office Area cannot be unloaded anywhere in that same Office Area — not at another facility, not
in a later Stage. A train has to carry them to a different district first. The printed game turns
the chip upside down in the tray; here the load carries the seat that made it (`RollingStock.origin`
in `src/engine/state.ts`) and it never expires. Without it a Freight House could unload the boxcar
its own Laborers had just loaded and a platform could detrain the passengers it had just boarded,
each paying Revenue at both ends for a load that went nowhere: worth **0.60 ± 0.10 Revenue a game**
to the developer bot over 400 paired deals, on 78 of them.
- **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or
a curve whose arc matches its own diverging leg — both strict port supersets of what they replace,
so an upgrade can never sever an existing join. Without it a district could only hang off track that
+448 -173
View File
@@ -14,22 +14,24 @@ to Rules Questions.
## Next
Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issues.
Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplayer below), in order:
1. ~~**Gitea#4 — confirm that extra trains start properly**~~ — done in v0.4.9f. Either Division
Point, the Interchange with a direction chosen there, a Control Point gated by a new `extraStart`
setting, and the Superintendent's hold on the way out of the Interchange yard. Reasoning in
`docs/rules/implications.md` §7.
2. ~~**Gitea#6 — players may not discard train cards**~~ — done in v0.4.9f. Timetabled and Extra
alike; the forced play falls out of the hand limit rather than needing a mechanism of its own.
Reasoning in `docs/rules/implications.md` §6.2.
3. ~~**Gitea#7 — coach counts on four train cards**~~ — done in v0.4.9f. 1/2 Crack Limited 3 → 2,
5/6 The Sparrow 2 → 3.
4. **Gitea#2 — four porters, two passengers on the platform, and only one may be worked.**
DIAGNOSED, AWAITING JESSE'S RULING — see Play Balance below.
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.
The rest of 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 2026-08-21, from playing the StartOS build:
4. **The lobby must offer every game parameter the solitaire New Game dialog does** — and it
currently offers none of them. Reasoning in Multiplayer below; carries a live balance bug with
it (the combined-Revenue floor is sized for four players whatever the table's real size), so
this is not purely a UI job.
5. **Decide what the four `optionalRules` are** before either dialog offers them — two are live,
two are read by nothing at all. Reasoning in Multiplayer below.
6. **Stop every release destroying every game in progress** — the check is exact equality against
the package version, and most releases do not touch the rules. Reasoning in Multiplayer below;
the recommendation is to replay-and-see rather than to guess from a version number.
---
@@ -72,6 +74,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,
@@ -222,32 +240,6 @@ not read that drop as a deck problem.
## Play Balance
- [ ] **Gitea#2 — passenger operations starve themselves of coaches, and the game says nothing.**
Reported from v0.4.9e play: the Sparrow pulls into the Terminal with two loaded coaches, two
passengers wait on the platform, four porters are unused, and only ONE of the four intended
actions can be taken. Reproduced from the save: the Division Yard holds **1 empty coach and 0
loaded**, while the Classification Yard holds **4 loaded and 4 empty** that cannot come back.
**The engine is not deviating from the rules.** §9.2 discards the white coach into the
Classification Yard on boarding, draws the white coach from the Division Yard on de-training,
and §2.2 returns the Classification Yard only when the Division Yard is empty — all three
implemented exactly. The problem is the interaction: a single global refill condition over a
pile holding six commodities with very different demand, where coaches are consumed by BOTH
halves of every passenger cycle. Traced over the reported game, the coach pool runs from 8+/8−
to 0+/1− by Day 5.
**Three ways out, and it is Jesse's call which:** (a) refill when the Division Yard is dry of
the type-and-state being asked for rather than dry of everything; (b) the same trigger but
return only the cars of that type; (c) leave the rules alone and raise the coach count in
`ROLLING_STOCK_SUPPLY`. Measure (a) or (b) over 400 paired seeds before shipping.
- [ ] **A blocked PASSENGER facility produces no impediment at all.** `impediments()`
(`src/sim/narrate.ts`) opens with `if (!f || f.kind !== 'freight') continue`, so the "why
nothing is moving" panel has never had anything to say about a platform. That is the second
half of Gitea#2 and the half that is unambiguously a bug: the player was not merely blocked,
he was given no reason — the button simply was not there. Worth fixing whichever way the
supply question is settled.
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
target is settled and freight carries its intended share; read no balance conclusion from a revenue
number until the rules stop moving.
@@ -268,7 +260,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.
@@ -277,7 +272,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.
@@ -333,8 +331,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 |
@@ -400,19 +405,195 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
at a real table; the reasoning worth keeping is that **deny** is the safe default, since a
held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes
it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain
rather than only the result (`lobby-and-sessions.md` §4).
v0.4.1**, and **displayed in v0.5.4**. It orders the whole chain, west to east by ascending
roll; `openingRolls` is on the `Frame` now and the play page prints the chain under the
Division map — *West to East: Alice (1) → Bot 2 (5) → Bot 1 (11)* — so the rolls that formed
it are visible rather than only their result (`lobby-and-sessions.md` §4).
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
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.
- [x] **~~The lobby's seat controls could not express "nobody in this chair"~~ — done in v0.5.3.**
Raised by Jesse 2026-08-21. The seats array grew as people joined, so the four rows on screen
were partly fictional: a 2-player game simply started with a 2-long array, and a host who
added a bot to a later chair padded the array with a `null` that silently disabled Start
behind a one-line note. **The host now picks the table size (2-4) when creating the game**
and the array is built at that length once, so a gap cannot be expressed rather than merely
being rejected. That also removed the need to compact seats at `Lobby.Start` — which would
have shifted the `player` index every `PlayerSession` records at join time and that
`/api/stream` and `/api/intent` route by, quietly handing a player somebody else's railroad.
Tested in `test/server/lobby.test.ts` ("seat index is player index, with no compaction to
shift it", "never grows the table, whoever asks", "refuses a chair that is not at the
table").
- [ ] **EVERY RELEASE DESTROYS EVERY GAME IN PROGRESS, AND MOST RELEASES DO NOT CHANGE THE RULES.**
Raised 2026-08-21 after v0.5.2, v0.5.3 and v0.5.4 each killed the games on the StartOS box in
turn — v0.5.4's changes were *rendering only*, and it still refused two saved games.
**Why it happens, and why the design is right as far as it goes.** A save is a seed plus a
list of intents (D5), so loading one means replaying those intents through the current engine.
A move that was legal under the old rules may be rejected under the new ones, and a
half-replayed game is worse than no game — so `loadGame` refuses on any `engineVersion`
mismatch and `index.ts` logs it and carries on (D7). Nothing is deleted; rolling the version
back makes the games loadable again. That is all correct. The problem is only that the test is
**exact equality against the package version**, which moves for reasons that have nothing to
do with the rules.
**Why it is getting worse rather than better.** It was harmless while Jesse was the only
player. It stops being acceptable the moment other people are seated: their game is destroyed
because somebody shipped a CSS fix. It also interacts badly with the stranded-session bug
fixed in v0.5.5 — the refusal is precisely what stranded a browser on a blank page.
Three ways out, cheapest first:
1. **A separate rules version, bumped by hand.** `RULES_VERSION` in `content.ts`, stamped into
the save instead of `package.json`'s version, and raised only when a change can alter
whether an intent is legal. v0.5.4 would not have touched it and both games would have
survived. Cheapest and the least clever, but it is a judgement call on every release, and
getting it wrong silently corrupts a game rather than refusing it — the failure is worse
than the one it replaces.
2. **A declared compatibility floor.** The save records the version that wrote it; the engine
declares the oldest save it will accept. Loading checks `saved >= floor` rather than
`saved === current`. Same judgement call as (1), but expressed as a range, which makes
"this release breaks saves" an explicit act rather than the default.
3. **Verify rather than assume — replay and see.** Load the save, replay it, and refuse only
if an intent actually rejects. This is the honest test and needs no judgement at all: it
answers the real question ("does this game still replay?") instead of a proxy for it. It
costs a full replay per game on boot, which is ~100 ms per finished game (measured
2026-08-21) and only unfinished games are loaded — so at any realistic table count it is
free. The work is in reporting a partial failure well: the game is intact up to the
rejected intent, and a player would probably rather resume there than lose it entirely.
**(3) is the one worth doing**, and (1)/(2) are what to reach for only if a replay ever
becomes too slow to do on boot. Decide before the next release that changes a rule, not after.
- [ ] **THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING.** Split out
at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby
item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`,
`sisterTrains`, `employeeRotation` and `emergencyToolbox`. Neither the solitaire New Game
dialog nor the lobby exposes any of them, and every construction site in the codebase
hardcodes all four to `false` (`web/game.ts`, `sim/harness.ts`, `sim/replay.ts`,
`sim/compare.ts`), so no game has ever been played with one on.
**Check what is real before building a form for it.** Only two are wired:
| rule | status |
| --- | --- |
| `reducedVisibility` | **live** — read at `advance.ts:53`, gates on `NIGHT_STAGES` |
| `emergencyToolbox` | **live** — read at `setup.ts:374`, seeds each player's Red Flags |
| `sisterTrains` | **nothing reads it.** Declared, defaulted, never consulted — and §9a Q9 records that the Second Section card *supersedes* the Sister Trains optional rule, so this flag is most likely dead rather than unbuilt. Decide whether to implement or delete it |
| `employeeRotation` | **nothing reads it.** Declared, defaulted, never consulted. Note the seat/player split (Phase 0, D9) was built specifically so this rule *could* exist — the groundwork is there, the rule is not |
So a dialog listing all four would offer two working toggles beside two that silently do
nothing — the exact failure `checkPlay`'s `NOT_IMPLEMENTED` and `enhancementText`'s
live/dormant/unbuilt table exist to prevent. Either implement the two dead ones, delete
them, or label them on screen the way an unbuilt Enhancement already labels itself. Doing
that is what decides whether this is a UI job or a rules job.
- [ ] **THE LOBBY OFFERS NO GAME PARAMETERS AT ALL, AND THE ONE IT INFERS IS WRONG.** Raised by
Jesse 2026-08-21 after playing the StartOS build. Creating a multiplayer game asks for a
display name and a mode, and nothing else — every other dial comes from
`defaultMultiplayerConfig(mode)` (`web/game.ts`), hardcoded, with no way to change it.
Solitaire's New Game dialog (`play.html`, `#ng-*`) asks for all of it: seed, starting hand
(`ng-hand` — three random / six random / three track + three other), the three revenue rates
(`ng-passenger` / `ng-freight` / `ng-transit`), `days`, `minCombinedRevenue`,
`maxCollisionsPerDay`, `maxCollisionsTotal` and `pvpCardsAllowed`. Multiplayer should ask for
the same set. Note that `GameConfig.optionalRules` (reduced visibility, sister trains,
employee rotation, emergency toolbox) is exposed by NEITHER dialog and is hardcoded false in
both — worth deciding on separately rather than folding in silently.
**~~The bug this hid~~ — fixed in v0.5.3.** `defaultMultiplayerConfig` defaults to
`players = 4` and `lobby.ts` called it without the argument, so `minCombinedRevenue` was
always `collectiveRevenueFloor(4, 5)` = 60 whatever the table's real size — a 2-player game
played against a floor meant for four (60 rather than 3x2x5 = 30), and missing that floor
means *everyone loses*. It fell out of the seat-control change: the host now picks the table
size when creating the game, so the real count reaches `defaultMultiplayerConfig` and the
ordering problem that caused this (config fixed at CREATE, seat count unknown until START)
no longer exists. **The form itself is still missing** — that is what this item is now.
- [ ] **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
@@ -422,107 +603,149 @@ 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-4 done (v0.4.0 through v0.5.1), Phases 5-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.
- `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).
- [ ] **NOT REPRODUCED: "when I back up to collect standing cars and, further down the tracks, the
caboose, I get the caboose but the cars remain. I can later drive right through them."**
Reported against v0.4.9d by a playtester (not Jesse, who forwarded it and could not add detail;
his guess was that the cars were spotted at an industry).
**Phase 4 (lobby, sessions, reconnection) done, 2026-08-21 — v0.5.1.** Per §12 steps 17-20 and
`lobby-and-sessions.md` in full:
**What was tried, all of which works.** Cars on plain track on the way to the caboose; cars
SPOTTED AT AN INDUSTRY on the way; the train's own cut standing on the square it is pulling out
of; a stale `standingWest` on the intermediate card; the industry locked by MEN AT WORK (which
correctly blocks the whole route rather than letting the crew past). Every one couples the lot.
The first three are pinned in `apply.test.ts` — "backing up over a cut to something beyond it
takes both" — so if the case is found later it is somewhere none of them cover.
**Why it is hard to make happen.** Coupling is mandatory (§A.4) and `exploreMoves` accumulates
what it meets card by card, so a route that reaches the caboose has already met everything
between. `carsOn` (`state.ts`) is the SINGLE answer to "what is standing here", and the movement
walk, the sweep in `carsCoupled` and every renderer all ask it — so cars a train can drive
through would have to be cars that are on screen and not in `carsOn`, and there is no such
place. (One route to one was closed anyway: `flyingSwitch`'s reducer wrote the cut straight into
`industryTrack`, which for a Passenger Facility is not where `carsOn` looks. `check` refuses a
non-freight target, so it never fired.)
**The two questions that would settle it**, for whoever has the board: was there a SECOND route
to the caboose — a parallel row, or a turnout — so the move could have gone round the cars? And
what did the move button say it would couple? The label names every car (`describeIntent`), so a
button that read "couples caboose" and one that read "couples 2 boxcars, caboose" are different
bugs: the first is route selection, the second is the sweep.
- [ ] **An unload does not check the facility's commodity.** `laborer.beginUnload` gates on
`allows.inbound`, a loaded car, an empty of that type in the Division Yard and room in the red
box — but never on `facilityCarTypes(f)`, which `freightAgent.stockOutbound` does check. So a
Freight House (boxcars) will unload a hopper. Found reading the code for the v0.4.9e district
rule, not from play. Low impact today because the district rule now refuses the only same-Office
pairing that made it easy to hit, and because the bot spots matching cars — but it is a rule the
engine states in one direction and not the other.
- [ ] **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/lobby.ts` — pure logic, no sockets, no filesystem, same split `session.ts`
already draws. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, a
speakable game code (`RAIL-4471` style, from a small railroad-word list rather than a
dictionary — read aloud across a table, not typed from memory), and the 2-4 player cap
(`playerCountAllowed`) — see the doc-fix note below.
- **The server now holds more than one game.** `persistence.ts` gained one directory per
`gameId` (`games/<gameId>/`) plus a top-level `index.json` naming every game, so `index.ts`
can resume all of them on boot rather than the one `game.json` Phase 3 assumed.
`writeGame`/`loadGame`/`appendTiming` needed no signature change — they already took a
directory directly.
- **Session tokens replace `?seat=&secret=` on the running-game routes.**
`lobby-and-sessions.md` §1: the token alone proves identity, so `/api/stream` and
`/api/intent` now read `?token=` and the join secret's job ends at the lobby door
(`/api/lobby/create`/`/api/lobby/join`). `web/session.ts`'s `createRemoteSession` takes
`(token, seat)` — `seat` still passed in rather than learned from a push, since it has to
answer before any push necessarily arrives, and the caller already has it from the
join/create/start response.
- **Bots fill empty seats at `Lobby.Start` only (D8)**, never mid-game. `session.ts` gained
`driveBots()`: after any accepted intent (and once at construction, for a resume that lands
exactly on a bot's turn), it plays `developerBot` forward through every consecutive bot seat
before the push goes out — reuses `legalActions`/`developerBot` wholesale, no new bot logic.
`SavedGame` gained `botSeats: PlayerIndex[]` so a bot seat survives a restart.
- **Host rights pass to the earliest-joined remaining player** if the host's LOBBY connection
closes before start (`lobby-and-sessions.md` §2) — tracked via `Lobby.joinOrder`, a token
list rather than seat order, since a bot-filled seat has no join time of its own.
- **Disconnect/reconnect** (§5): `Push` gained an optional `presence` field — connection news
about ANOTHER seat, built entirely by `http.ts` (which owns the connection table) and never
routed through `session.ts` or the engine, since a disconnect is transport news about a
connection, not a `GameEvent`. The page shows a banner naming who has dropped
(`renderPresence`, `main.ts`) and clears it the moment they reconnect. Reconnect itself needed
no new engine-side work: `session.connect(seat)` already sent a full un-delta'd `Frame`.
- **The client lobby** (`src/web/lobby.ts`, wired from `main.ts`'s `start()`): create-or-join
forms, a live seating screen (host-only bot toggles and Start button, updated over a new
`/api/lobby/stream` SSE), and `localStorage` in place of `?seat=` for "was I already in a
game" — found on load, reconnects straight to `createRemoteSession` and skips the lobby
entirely. A `Multiplayer` button beside `New game` is the entry point; the New Game dialog
itself is untouched, still solitaire-only, its old "needs a server" note repointed at the
new button.
- **Found and fixed while running the live smoke test, not by typechecking:** `/api/intent`
read its token from the JSON body, but `web/session.ts`'s `submit()` — unchanged from Phase
2 — sends it in the query string, same as `/api/stream`. Every request failed `no such
game`. Both sides independently typecheck fine (an HTTP body is `unknown` on the wire), which
is exactly why the curl-level smoke test exists rather than stopping at `tsc --noEmit`.
- **Doc fix:** `multiplayer.md`'s D18 said "player cap 6", citing `lobby-and-sessions.md` §2 —
which actually specifies 2-4 and gives the reasoning (what `test/multiplayer.test.ts` exercises).
The two had drifted apart; "6" was never implemented or tested anywhere. D18 now says 2-4.
- Verified: `test/server/lobby.test.ts` (pure logic — creating, joining, capacity, bot seats,
host transfer, starting) plus new coverage in `session.test.ts` (bot-driving, including two
bots in one game) and `web.test.ts`. A live smoke test through `curl`: create a lobby, join a
second player, start, submit intents from both (including the wrong-actor rejection and an
idempotent resend), reconnect after a real server kill-and-restart, a bot-filled coop lobby
starting and never stalling on the bot's seat, and a disconnect/reconnect presence notice
observed on an open stream. **Not verified: an actual browser** walking through the lobby
screens — none is available in this environment, the same limitation Phase 2's `RemoteSession`
shipped under.
---
@@ -530,12 +753,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.
@@ -548,9 +765,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,
@@ -566,22 +792,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.
---
@@ -843,3 +1065,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.
+2 -9
View File
@@ -98,18 +98,11 @@ Playing a timetabled train card rolls the seeded D12 and places its number in th
The listed consist is a maximum, not a minimum: a train may depart with fewer cars, but must not exceed the listed categories, put a car behind a caboose, or leave with the engine buried among cars. A Crew Tray holds no more than four rolling-stock cars.
> **Changed 2026-08-22 (Gitea#7), Jesse's call:** the coach counts on **1/2 Crack Limited** and
> **5/6 The Sparrow** were swapped — the Limited drops from three coaches to two, the Sparrow rises
> from two to three. This is a change to the CARDS, not a correction to this table: `Trains3.pdf` and
> the transcription in [`rules/implications.md`](rules/implications.md) §5 still show the original
> numbers, and are right about what the printed cards said. `src/engine/content.ts` and this table
> carry what the game plays.
| Train | Speed | Direction | Listed maximum consist | Implemented special rule |
| --- | --- | --- | --- | --- |
| 1/2 Crack Limited | Fast | 1 west / 2 east | **2 coaches** | No switching; passenger work only at Terminals; expedited. |
| 1/2 Crack Limited | Fast | 1 west / 2 east | 3 coaches | No switching; passenger work only at Terminals; expedited. |
| 3/4 Express | Fast | 3 west / 4 east | 2 freight | May exchange at most one freight car at each grid location during its switching turn; expedited. |
| 5/6 The Sparrow | Fast | 5 west / 6 east | **3 coaches** | No switching; expedited. |
| 5/6 The Sparrow | Fast | 5 west / 6 east | 2 coaches | No switching; expedited. |
| 7/8 Local | Slow | 7 west / 8 east | 1 freight, 1 coach | Its coach may not be set out during switching. |
| 9/10 Heavy Freight | Slow | 9 west / 10 east | 3 freight, 1 caboose | — |
| 11/12 Drag Freight | Slow | 11 west / 12 east | 2 freight, 1 caboose | — |
+2 -6
View File
@@ -45,13 +45,9 @@ The PDF art labels this card “Yard”; this reference uses the implementation
| Trestle | 60 mph. |
| Interchange | 60 mph. A train may be reordered there only through the card’s printed “sort cars” concept; the current engine does **not** provide a Mainline sorting action for it. |
### PDF/code mismatch — CORRECTED
### PDF/code mismatch requiring correction
**Was:** `src/engine/content.ts` defines nine `MAINLINE_PROFILES` types: one Plains entry plus the eight other terrain types above. `setup.ts` selected uniformly from that nine-type list, **with replacement**. The second Plains card shown in `Mainline Cards.pdf` was therefore not represented as a duplicate card or as extra Plains weight in setup — and, worse than a weighting error, a Division could be dealt two Interchanges, two Tunnels or two Trestles, none of which the deck contains.
**Now:** `MAINLINE_DECK` in `content.ts` is the inventory table above — ten drawable cards, Plains twice and the other eight once each — and `buildDivision` deals from it without replacement. The two Division Point cards are not in that deck: they are the fixed ends of the Division, laid by `buildDivision` itself rather than drawn.
The Interchange is what forced the correction. §7 lets an Extra be started at the Interchange "if one is on the board" (see `docs/rules/implications.md`, §7), which only reads as a rule if the board can hold at most one.
`src/engine/content.ts` defines nine `MAINLINE_PROFILES` types: one Plains entry plus the eight other terrain types above. `setup.ts` selects uniformly from that nine-type list. The second Plains card shown in `Mainline Cards.pdf` is therefore not represented as a duplicate card or as extra Plains weight in setup. If the PDF inventory is authoritative, the setup selection needs a second Plains entry (or an equivalent weighted selection).
The executable state represents East and West Division Points as fixed end nodes, not as card records. They are functionally present at the ends of the Division, but are not represented as the two PDF cards in the deck/state model.
+23 -1
View File
@@ -245,9 +245,31 @@ special handling: `pump` stops, and the next push simply carries a `Menu` contai
POST /api/lobby/create, /api/lobby/join lobby
POST /api/intent { gameId, seq, intent }
GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume
GET /api/health { ok, service, engineVersion, games } — unauthenticated; see below
GET /api/games every game and lobby, summarised ┐
GET /api/games/<id>/save the save, for keeping or replaying ├ ADMIN_SECRET
DELETE /api/games/<id> ends a game, and returns its save ┘
GET / the client
```
`/api/health` exists because the client cannot otherwise tell a server from a static host. The same
`dist/` is served both ways and the bundle is identical (D4), and every other route 404s an unknown
path exactly as a static host does — so the splash asks, and closes its multiplayer door only when
nothing names itself in reply. It is unauthenticated on purpose: it reveals that a Station Master
server is answering and nothing else, no game and no seat. Its `games` field — `{ active, lobby }`
— is what the StartOS package's health check reports as "3 games in progress".
**The three administrative routes are gated by `ADMIN_SECRET`, which is deliberately not the join
secret.** Every player holds the join secret, so gating a delete with it would let anyone at the
table destroy anyone else's game; this one belongs to whoever runs the server. It arrives in an
`x-admin-secret` header rather than the query string, so it stays out of logs and referrers. When
the variable is unset the routes answer 404 exactly as any unknown path does, so a server that was
never given an administrator does not advertise that it has one.
**A delete returns the deleted game's save.** The intents are the game (D5), so what comes back is
the whole thing and not a summary of it — the record survives even though the game does not, and
nothing is destroyed without being handed to whoever destroyed it first.
Chosen over WebSocket because this game is **idle most of the time** — turn-based with human
think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's
reconnection and `Last-Event-ID` resume are handled by the browser, and it needs no `Upgrade` support
@@ -338,7 +360,7 @@ all it needs.
| **D15** | **Build to `deployment.md`'s five portability rules; package as an `.s9pk`** | This is a StartOS packaging workspace and the toolchain is on disk. |
| **D16** | **The server serves the client** | Required for same-origin, which is what makes multiple access addresses work without CORS. |
| **D17** | **Undo is solitaire-only** | Other players have seen the result. |
| **D18** | **Player cap 6** | Per `lobby-and-sessions.md` §2. |
| **D18** | **Player cap 2-4** for competitive/coop (solitaire is 1) | Per `lobby-and-sessions.md` §2 — sized to what is actually exercised (`test/multiplayer.test.ts` plays 2, 3 and 4 to a finish), not to a guess. This entry read "6" until Phase 4 (v0.5.1); that number was never implemented or tested anywhere and the two docs had drifted apart. Raise it once somebody has played a bigger game and reported back, not before. |
| **D19** | **Per-player turn state now; parallel turns deferred** | The model change is behaviour-neutral and cheap today. Whether local work should run off-cursor depends on how often humans choose to switch — 13% for the bot, and the benefit ranges from ~8 minutes to ~27 off an hour-long game between 13% and 50%. Measure with real players, then flip one function. |
| **D20** | **Replay reveals everything once the game ends** | Most useful for learning and for arguing about it afterwards; costs nothing extra to retain. |
+24 -30
View File
@@ -42,33 +42,28 @@ Operational Rail wheel icon, an industry track of the stated length, Laborer ico
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 |
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
| Grocer's Warehouse | Boxcar | Inbound only | 2 | — | 2 | 3 | 2 |
| Oil Refinery | Tank car | Outbound only | 3 | 2 | — | 4 | 2 |
| Grocer's Warehouse | Boxcar | Both | 1 | 1 | 1 | 3 | 3 |
| Oil Refinery | Tank car | Both | 1 | 1 | 1 | 4 | 3 |
| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 |
| Freight House | Boxcar | Both | 1 | 1 | 1 | — | 6 |
Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant;
produce ships out; a warehouse receives. This gives §9 all three of its cases — outbound-only,
inbound-only, and both — with the **Freight House the one card that does both**.
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases —
outbound-only, inbound-only, and both.
**The Grocer's Warehouse and the Oil Refinery are ONE-WAY** (corrected v0.4.9e, from gameplay
testing): "Grocer's Warehouse should be receive only, does not ship anything out"; "Refinery: only
ships out tanks, does not receive anything" (Jesse). The Direction column read "Both" for both, on the
reading below that "Freight House" named exactly those two.
**And that reading is wrong.** §9.3/Appendix A's "Passenger Facilities and Freight Houses permit cars
to move each direction" names a CARD: the engine deals a `freightHouse` industry, 6 copies, one
Laborer and one slot each direction (`content.ts`), and has since before v0.4.9. This paragraph used
to read "'Freight House' is not a card — it is the collective term for a freight facility that loads
*and* unloads, the Grocer's Warehouse and the Oil Refinery", and that premise is what made those two
two-way. `StationMaster-Home-Deck-v0.4.5.md` prints them one-way, and the modifier set agrees: all
three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1 outbound**, which would
be an odd card set for a facility that receives half the time.
<!-- The Laborer and capacity numbers in the rows above were NOT re-verified against the engine in
this pass — only the Direction column was, which is what playtesting reported on. `content.ts`
gives every industry base 1 out / 1 in / 1 Laborer, so the table's 2s and 3s and the "Throughput"
reasoning built on them are likely stale. Flagged rather than silently rewritten: Laborer counts
are a balance question, not only a docs one. -->
**"Freight House" IS a card** (corrected v0.5.0) — a sixth industry, dealt 6 copies, one Laborer and
one slot each direction. An earlier pass here read §9.3/Appendix A's "Passenger Facilities and
Freight Houses permit cars to move each direction" as meaning "Freight House" was only ever a
*collective term* for the Grocer's Warehouse and the Oil Refinery, never a card of its own — that
reading was wrong; the engine deals it as a real sixth industry (`content.ts`'s `freightHouse`
profile) and this table follows the engine.
<!-- TODO v0.5.0: Mine Tipple, Produce Shed and Power Plant above (3/3/4, 2/2/3, 3/3/4) were NOT
re-verified against the engine in this pass — only Grocer's Warehouse, Oil Refinery and Freight
House were. `mineTipple` and `powerPlant` in `content.ts` are also base 1/1/1, same as the three
corrected here, so these three rows and the "Throughput" reasoning below (built on the OLD
2–3-Laborer numbers) may be stale too. Flagged in TODO.md rather than silently rewritten — this
needs the same kind of decision Jesse made for Grocer's/Refinery/Freight House, not an assumption
that the same correction applies. -->
### Throughput — why these Laborer counts
@@ -96,10 +91,9 @@ deliberately slower industries, unable to quite keep up with a dedicated player.
That is why Laborer counts track Outbound capacity: Mine Tipple 3/3, Power Plant 3/3, Produce Shed
2/2, Grocer's Warehouse 2/2. The numbers are derived from the action budget, not chosen freely.
<!-- The paragraph that stood here explained the Oil Refinery's third Laborer as the price of serving
two flows through one pipeline. It serves one flow (v0.4.9e), so the explanation is gone with the
premise; whether the Laborer count is still right is part of the same unverified block flagged
above and in TODO.md. -->
The Oil Refinery is the exception at 3 Laborers against 2+2 capacity, and deliberately so: it serves
two flows through one three-box pipeline, so its pipeline stays fuller than a one-way facility's and
the third Laborer is earning its keep.
Even so, Laborers are rarely what limits a player — spotting the empty car and hauling the loaded one
away both cost switching actions from the same budget. See §7.
@@ -275,12 +269,12 @@ with all boxes full:
| Car | Facility demand | Supply | Headroom |
| --- | ---: | ---: | --- |
| Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met |
| Tank | Oil Refinery 2×2 = 4 | 8 | 4 spare (was "exactly met" while the Refinery was two-way) |
| Boxcar | Grocer's 2×2 = 4 | 12 | 8 spare (same correction) |
| Tank | Oil Refinery (2+2)×2 = 8 | 8 | exactly met |
| Boxcar | Grocer's (2+2)×2 = 8 | 12 | 4 spare |
| Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare |
| Coach | Terminal 4+4, per Office | 16 | scales with Office count |
Hoppers are exactly met in the theoretical worst case, which cannot occur in practice —
Hoppers and tank cars are exactly met in the theoretical worst case, which cannot occur in practice —
only 10 freight facility cards exist across a 52-card deck shared by all players, and the §2.2
Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth
watching in playtesting.
+1 -1
View File
@@ -25,7 +25,7 @@ Every defined term, alphabetized for lookup. The core comes from the Definitions
| **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 |
| **Facility** | A business which loads/unloads cargo and freight. | §2.5 |
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants, Freight Houses. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
| **Freight House** | A sixth Freight Facility card, dealt like any other industry (`content.ts`) — this entry used to read "not a card, the collective term for the Grocer's Warehouse and the Oil Refinery", which the engine never agreed with. **The only industry that permits both directions**: the Grocer's Warehouse receives and the Oil Refinery ships, one way each (v0.4.9e). | §9.3, §12.5 |
| **Freight House** | A sixth Freight Facility card (corrected v0.5.0 — an earlier pass here read it as a collective term for the Grocer's Warehouse and the Oil Refinery rather than a card of its own; it is dealt like any other industry). Permits both directions, the same as a Grocer's Warehouse or Oil Refinery. | §9.3, §12.5 |
| **Highball** | When a train holding at an Office automatically departs. | §2.4 |
| **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 |
| **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 |
+8 -131
View File
@@ -82,10 +82,12 @@ direction. `DivisionNode.gradeUp` records the direction a train travels when **c
going the other way is descending. Brakeman and Airbrakes apply to the descent, Helpers to the
climb, so turning the card around swaps which trains each card helps.
One gap remains, and it is a **setup** gap rather than a rules one: `createGame` is synchronous and
returns a ready state, so there is no point at which a player can be asked. Orientation is currently
**rolled** — deterministic from the seed, and roughly even (51/49 east/west across 400 games) — and
should become a real player choice when setup gains an interactive phase.
**Settled (v0.5.0): orientation is never a player choice, and this overrides the card's own printed
"Player sets orientation."** A Heavy Grade sits on the shared west-to-east Division chain, between
two players (§4.2) or beyond an end Division Point next to one — never inside a single player's own
district — so whichever direction climbs advantages one neighbour over the other, and no one player
has a fair claim to the decision. Orientation is **rolled** from the seed instead — deterministic,
and roughly even (51/49 east/west across 400 games) — identically for solitaire and multiplayer.
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
@@ -486,19 +488,13 @@ Hotel) are what grow them.
| # | Name | Speed | Consist | Rule |
| --- | --- | --- | --- | --- |
| 1/2 | Crack Limited | Fast | 3 coaches † | Stop at Terminals only. No switching. Expedite. |
| 1/2 | Crack Limited | Fast | 3 coaches | Stop at Terminals only. No switching. Expedite. |
| 3/4 | Express | Fast | 2 freight | May drop or pick up one freight car at every location. Expedite. |
| 5/6 | The Sparrow | Fast | 2 coaches † | No switching. Expedite. |
| 5/6 | The Sparrow | Fast | 2 coaches | No switching. Expedite. |
| 7/8 | Local | Slow | 1 freight + 1 coach | Coach must remain on station track if switching. |
| 9/10 | Heavy Freight | Slow | 3 freight + caboose | |
| 11/12 | Drag Freight | Slow | 2 freight + caboose | |
† **The two coach counts have since been swapped by Jesse** (Gitea#7, 2026-08-22): the Crack Limited
now carries **2** coaches and The Sparrow **3**. The table above is left as `Trains3.pdf` prints it,
because that is what this section is for — what the design SAYS. What the game plays is
`src/engine/content.ts`, with the per-card table in
[`../StationMaster-Home-Deck-v0.4.5.md`](../StationMaster-Home-Deck-v0.4.5.md).
### Extras (X13–X22) — ten distinct trains, not four generic ones
Appleseed Extra (MT freight only, may drop but not pick up), Fruit Growers Express (reefers only),
@@ -789,122 +785,3 @@ Crossing is counted in Stages (Q1), so both trains simply run their counters dow
Until this is settled the clearance buttons describe only what the engine actually does — they say
the train "closes up behind" rather than promising a −5 risk that cannot occur. Wording that invents
a consequence is worse than wording that under-sells one.
---
## §7 — where an Extra starts, and which way it runs
**Reported from playing v0.4.9e** (Gitea#4): "When extras are played the player doing so may choose
where the extra starts. They may choose either division point. And if the interchange mainline card
has been played, they may start the extra on that card and choose the direction from there. If there
is potential for conflict with other trains in that area the superintendent may hold the extra."
**This supersedes an earlier ruling**, and the supersession is the interesting part. §2.3 gives every
train its direction from its number — odd runs west, even runs east — and an earlier pass extended
that to Extras explicitly: *"the number decides, like everything else on the timetable."* That reading
cannot survive "either Division Point". An odd Extra placed at the WEST end would run west, leave the
Division on its first move having crossed nothing, and be paid the completion Revenue for the run.
So for **Extras only**, the start decides the direction:
| Start | Direction |
| --- | --- |
| Western Division Point | east |
| Eastern Division Point | west |
| Interchange | player's choice |
| Control Point (any Office above a Whistle Post) | player's choice |
A timetabled train is unchanged: its number still decides. The Extra cards were always printed
`direction: 'playerChoice'` (§5) and the engine had been overriding it; they now mean it.
### The Interchange start is a YARD, not a spot on the running line
§7's last clause — "the superintendent may hold the extra" — is what settles how this is modelled.
An Extra started at an Interchange stands in that card's **yard**, off the running line, and highballs
onto the card itself at a later Mainline Phase. Three things follow, all of them Jesse's rule rather
than an implementation convenience:
1. **Placing it can never force a collision**, however busy the card is. It is not on the road yet.
2. **A guaranteed collision holds it in the yard** for another Stage, and it tries again next Stage.
3. **A potential collision is the Superintendent's to rule on.**
Those last two are exactly §8.1's two answers — an absolute bar against a facing train, a judgment
call against a following one — so the Extra leaves the yard through the same clearance check a train
leaves a Division Point through. Nothing new decides collisions.
The Interchange keeps its printed "sort cars in new order" concept, still unimplemented (§6). Being
the card with a Yard Limit is what makes it the one Mainline card a train can be made up on.
### Which starts are offered is a setting
The Division Points and the Interchange sit on shared ground and belong to nobody; starting an Extra
inside a player's own district does not. That is a table preference rather than a rule, so it is set
when the game is dealt (`extraStart`): Division Points and Interchange only, plus the playing
player's own Control Point, or plus any player's Control Point. A **Whistle Post never qualifies at
any setting** — being a place an Extra can start is part of what upgrading buys (§11).
### Two bugs found underneath it
- **The Mainline cards were rolled, not dealt.** `buildDivision` drew uniformly from the nine card
TYPES **with replacement**, so a Division could be dealt two Interchanges or two Tunnels, and
Plains — printed twice in the deck — carried the same weight as cards printed once.
`docs/StationMaster-Mainline-Deck-v0.4.5.md` had already flagged the mismatch as needing
correction; "an Extra may start at the Interchange if one is on the board" is what forced it, since
that only reads as a rule if the board holds at most one. Now dealt from the printed ten-card deck
without replacement.
- **An Extra started anywhere but a Division Point ran empty.** `isBeingMadeUp` asked only "is this
tray standing at a Division Point", which was the whole truth while that was the only place to
build a train — so the Control Point start had shipped since it was added with a train that could
never be given a consist, and the Interchange start would have shipped the same way. Found by
playing it, not by the tests, which had only ever asserted where the tray landed.
---
## §6.2 — a train card is never discarded
**Jesse's ruling, v0.4.9e playtest** (Gitea#6): "Players are not allowed to discard Train cards. They
may keep the card in their hand for multiple stages and even multiple days, but they may not discard
it. If a player has three train cards in their hand, and they draw a fourth, then they must play one
of those cards."
**Extras count.** An Extra is a train, even though it runs once and ends in the Salvage Yard where a
Timetabled card joins the timetable for the rest of the game.
§6.2 as transcribed says only "the player must reduce his hand to no more than three cards" with no
exception for any card type, so this is a ruling rather than a gap — the prototype rules do not
address it either way.
### It needs no forcing mechanism, and that is the point
The interesting property of this rule is that the forced play falls out of two rules that already
exist rather than needing a third:
1. a train card cannot be discarded, so it is not among the ways to shed a card; and
2. `draw.end` already refuses while the hand is over the limit (§6.2).
A player holding four trains therefore has exactly one legal way to conclude the turn — play one —
without anything in the engine ever computing "you must play a train". The corner cannot lock a
player in, because **playing a train card is unconditionally legal**: `card.play`'s train case
refuses only a board placement, and a train card played when the timetable is full still leaves the
hand (it simply schedules nothing). Confirmed by playing it: a hand of four trains offers zero
discards, no `draw.end`, and four plays.
The bot needed no rule of its own either. `legal.ts` enumerates candidates and filters them through
`check`, so the option stops being offered; and the developer bot already reaches for `card.play`
before it reaches for a discard. Measured over 400 games: 400/400 finished, revenue unmoved, and
**trains scheduled 1.2 → 1.3** — the rule's intended effect, small because a bot rarely held four.
### Consequences
- **Two of the three published replays discarded train cards** (2 and 15 of them) and were retired
and re-recorded. Jesse's call: "I'm okay with retiring the replays that no longer work under those
old rules."
- **The opening six-card hand is not exempt.** Under *six random cards* a player opens holding six
against a limit of three; if four or more are trains, they all go onto the timetable on turn one.
Jesse's call: "If the opening hand has lots of trains, then lots of trains will be placed on the
board." Rare — roughly 1.5% of deals — but deliberate.
- **The player is told, on the card and on the button.** `handDiscardable` on the Frame marks which
cards may be shed, the hand panel says so in the card's own tooltip, and when EVERY card held is a
train the blocked end-turn button changes its text to say a train must be played. That is the
Gitea#2 lesson applied: a rule the player cannot see is a board with nothing to click and no reason
given.
+8 -11
View File
@@ -670,15 +670,10 @@ balance work was possible.
| --- | --- | --- | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
*Amended v0.4.9e.* The warehouse and the refinery were briefly "Both", on the reading that "Freight
House" was a collective term for exactly those two and therefore what §9.3's "permit cars to move
each direction" described. The Freight House turned out to be a card of its own (v0.5.0), and
playtesting confirmed the one-way reading the sheet always printed.
*Rationale.* Directions follow the commodity and give §9 all three of its stated cases. Bulk
industries get more Laborers and a 4-car track so the types feel distinct when choosing what to
build — but the counts stay moderate because Laborers are **not** the binding constraint (see 10e),
@@ -723,10 +718,12 @@ leave no way at all to speed up a slow facility. Section Gang is the sole worker
keeps that power scarce. Capacity modifiers are stronger than they sound, since banking room rather
than worker count is what limits output.
**Sub-decision — what a "Freight House" is.** §9.3 and Appendix A refer to Freight Houses, but §9's
facility list never defines one. It is **not a card**: it is the collective term for a freight
facility that both loads and unloads — under 10a, the Grocer's Warehouse and the Oil Refinery. This
reconciles every reference without adding a card type or disturbing Gap 4a's deck composition.
**Sub-decision — what a "Freight House" is. SUPERSEDED (v0.5.0).** §9.3 and Appendix A refer to
Freight Houses, but §9's facility list never defines one. This originally concluded it was **not a
card** — the collective term for a freight facility that both loads and unloads, under 10a the
Grocer's Warehouse and the Oil Refinery. That reading was wrong: it **is** a card, a sixth Freight
Facility dealt 6 copies (`content.ts`'s `freightHouse` profile), which the engine has implemented
all along. Left here for the reasoning; do not read this entry as current.
### 10e — Revenue/Day revalidated, and the Gap 6 targets revised
+9 -10
View File
@@ -452,9 +452,10 @@ Loading and unloading of Rolling Stock (passengers and freight) takes place in t
(§11). A Whistle Post is not a Passenger Facility, so a player who has not upgraded has no
passenger business at all.
- **Freight Facilities:** Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power
Plants. Per-card values in §12.5.
- **Freight House:** **[Gap 10d]** not a facility type of its own, but the collective term for a
freight facility permitting both directions — the Grocer's Warehouse and the Oil Refinery.
Plants, Freight Houses. Per-card values in §12.5.
- **Freight House:** a Freight Facility card in its own right (corrected v0.5.0 — **[Gap 10d]**
previously read this as a collective term for the Grocer's Warehouse and the Oil Refinery rather
than a card of its own). Permits both directions, the same as those two.
- **Modifier Facilities:** Cards that, placed adjacent (on any of the nine nearby spots), increase a
passenger or freight facility's capacities. If two Facilities are adjacent to the Modifier, its
effects may only be used on one Facility per **Stage** **[Gap 7]**.
@@ -735,8 +736,8 @@ Modifier effects, and track geometries — is catalogued in
| --- | --- | --- | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
| Office | Porters | Green slots | Red slots |
@@ -753,11 +754,9 @@ Modifier effects, and track geometries — is catalogued in
| Extra Platform | +1 green and +1 red slot (passenger) |
| Section Gang | +1 Laborer or +1 Porter |
**"Freight House"** (§9.3, Appendix A) is a Freight Facility card, dealt like any other industry —
this used to read "not a card, only the collective term for a facility that both loads and unloads",
which the engine never agreed with. It is the **only** industry that permits both directions: the
Grocer's Warehouse receives and the Oil Refinery ships, one way each (corrected v0.4.9e from gameplay
testing — the "Both" reading was the other half of the same mistake about what "Freight House" meant).
**"Freight House"** (§9.3, Appendix A) is a Freight Facility card (corrected v0.5.0 — previously
read as not a card, only a collective term for a facility that both loads and unloads). It permits
both directions, the same as the Grocer's Warehouse and the Oil Refinery.
---
+301
View File
@@ -0,0 +1,301 @@
{
"seed": 116956197,
"history": [
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c27"
},
{
"type": "card.play",
"cardId": "c30"
},
{
"type": "card.play",
"cardId": "c4"
},
{
"type": "card.play",
"cardId": "c2"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c197",
"placement": {
"row": 0,
"col": -1
},
"variant": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c163",
"placement": {
"row": 1,
"col": -1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c41",
"placement": {
"row": 1,
"col": 0
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c127",
"placement": {
"row": 0,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c183",
"placement": {
"row": 0,
"col": 2
},
"variant": 1
},
{
"type": "card.play",
"cardId": "c150",
"placement": {
"row": 1,
"col": 2
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c136",
"placement": {
"row": 1,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c77",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c171",
"toSlot": 0
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c153",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c9"
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
}
],
"rules": {
"startingHand": "sixRandom",
"revenue": {
"passengerPerCoach": 1,
"freightPerLoad": 1,
"trainPerTransit": 0
}
}
}
@@ -0,0 +1,714 @@
{
"seed": 116956197,
"history": [
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c27"
},
{
"type": "card.play",
"cardId": "c30"
},
{
"type": "card.play",
"cardId": "c4"
},
{
"type": "card.play",
"cardId": "c2"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c197",
"placement": {
"row": 0,
"col": -1
},
"variant": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c163",
"placement": {
"row": 1,
"col": -1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c41",
"placement": {
"row": 1,
"col": 0
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c127",
"placement": {
"row": 0,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c183",
"placement": {
"row": 0,
"col": 2
},
"variant": 1
},
{
"type": "card.play",
"cardId": "c150",
"placement": {
"row": 1,
"col": 2
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c136",
"placement": {
"row": 1,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c77",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c171",
"toSlot": 0
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c153",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c9"
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 0
},
"reverse": true
},
{
"type": "switch.end"
},
{
"type": "laborer.beginUnload",
"at": {
"row": 1,
"col": 0
},
"carIndex": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c162",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 1
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 1,
"col": 0
},
"carType": "boxcar"
},
{
"type": "laborer.startLoad",
"at": {
"row": 1,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "caboose",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 1,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 1
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c50",
"toSlot": 0
},
{
"type": "draw.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": -2
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray2",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false,
"via": {
"row": 0,
"col": 0
}
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 1
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray2",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray2",
"count": 3
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": -2
},
"reverse": true,
"via": {
"row": 0,
"col": 1
}
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 0
},
"reverse": false
},
{
"type": "switch.end"
}
],
"rules": {
"startingHand": "sixRandom",
"revenue": {
"passengerPerCoach": 1,
"freightPerLoad": 1,
"trainPerTransit": 0
}
}
}
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
# Station Master 0.5.0 — Test Plan
Written 2026-08-21, against an uncommitted working tree. Covers only the changes made in this
session's work — victory-condition unification, the New Train phase fix, and the multiplayer server
(Phases 2-3). Nothing here is committed yet; see the Coordination Note below before running any of it
against a tree that has since moved.
## Coordination note — shared working tree
Another thread is bug-fixing the 0.4.9 series **in this same uncommitted working tree**, concurrently.
`git status` shows changes I did not make in: `apply.ts`, `events.ts`, `track.ts`, `board-svg.ts`,
`docs/rules/*.md`, `package-lock.json`, `scripts/build-web.ts`, `.gitignore`, plus new
`docs/station-master-seed*.json` files still appearing as of this writing. **This plan does not cover
any of that** — it is scoped to the files below, which are the ones I actually touched.
Practical implications:
- Before running this plan, confirm the files under "Files this plan covers" still read the way this
document describes them — the other thread's commits could in principle touch the same functions
(`apply.ts`'s `check()` is used directly by `src/server/session.ts`, for instance).
- Run the full regression gate (`npm run typecheck && npm test`) fresh, not from a memory of an earlier
green run — both threads are editing live.
- The two bodies of work are unrelated in intent (0.4.9 patch fixes vs. 0.5.0 multiplayer groundwork)
but share a tree, so a clean split into separate commits before either is finalized is worth doing
deliberately rather than by accident.
### Files this plan covers
```
Engine: src/engine/advance.ts, src/engine/content.ts, src/engine/setup.ts, src/engine/state.ts
Web: src/web/game.ts, src/web/main.ts, src/web/play.html, src/web/session.ts
Sim: src/sim/view.ts, src/sim/frame-delta.ts (new), src/sim/compare.ts, src/sim/harness.ts,
src/sim/replay.ts
Server: src/server/ (new — index.ts, http.ts, session.ts, persistence.ts)
Tests: test/advance.test.ts, test/multiplayer.test.ts, test/web.test.ts, test/setup.test.ts,
test/redaction.test.ts (new), test/frame-delta.test.ts (new), test/server/ (new)
Docs: TODO.md, package.json (test script only)
```
---
## 0. Automated regression gate — run first, every time
```
npm run typecheck
npm test
```
Expected: typecheck clean, **all tests passing** (632 at the time this plan was written — the exact
number will drift as the other thread's work lands; what matters is zero failures). If either command
is red, stop and diagnose before doing anything below — a red gate means the manual steps are testing
against a broken build.
**If you only have time for one thing before the break ends, run this.** Everything else in this
document is either already covered by it, or is the manual/live verification that can't be — flagged
per-section below.
---
## 1. Regression baseline — solitaire must play exactly as before
The engine and dialog changes touch shared code paths solitaire also uses. None of this should have
changed solitaire's actual behavior.
| # | Test | Steps | Expected |
|---|---|---|---|
| 1.1 | Fresh deal | `npm run serve:web`, open `/play.html`, no URL params | A new random-seed solitaire game deals normally, board and hand render |
| 1.2 | Full game to completion | Play (or let a bot/harness play) a solitaire game to Day 5 | Game ends with a win/loss exactly as it would have before — `minCombinedRevenue` defaults to `collectiveRevenueFloor(1,5)=15`, not the old fixed `20`; a game scoring 15-19 that used to lose now wins (**intentional** — see §3, not a bug) |
| 1.3 | Save / restore | Play a few turns, reload the page (auto-restores from `localStorage`) | Game resumes exactly where it left off, house rules and victory dials intact |
| 1.4 | Save file download | Click "Save replay" mid-game | Downloads a small JSON; re-opening it in the replay viewer plays back identically |
| 1.5 | Undo | Play a few turns, click Undo repeatedly to the start | Each step back is clean; log never shows a move that "un-happened" |
| 1.6 | Existing published replays | Open each file under `public/replays/` in the replay viewer | All three still play back — `LEGACY_HOUSE_RULES` fallback for saves with no `houseRules` field is unaffected by this session's changes |
Automated coverage: `test/advance.test.ts`, `test/web.test.ts`, `test/session.test.ts` already assert
most of this at the unit level. §1 is about confirming nothing *visibly* changed for a solitaire
player, which only a live playthrough shows.
---
## 2. New Train phase car-placement rotation (engine fix)
**What changed:** the Superintendent used to place every car of every train alone, even in
competitive mode, against §7's written rule. Now the round rotates Superintendent-then-left, one car
per player, per `tray.consist.length`.
Automated: `test/multiplayer.test.ts`, "the New Train phase car-placement round rotates (§7, Gap 9)" —
asserts the exact actor sequence for 2p (wraps: 0,1,0) and 3p (no wrap: 0,1,2) against a real train
profile.
| # | Test | Steps | Expected |
|---|---|---|---|
| 2.1 | Manual multi-seat check | Drive a 3-4 player competitive game via the bot harness (`node src/sim/harness.ts`) or by hand through `src/server/`, and watch a Timetabled train get made up | Car-placement decisions visibly move between seats rather than one player filling the whole consist |
| 2.2 | Solitaire unaffected | Play any solitaire game where a train is made up | No visible change — one player, nothing to rotate, `tray.consist.length % 1 === 0` always |
Not covered, flagged in `TODO.md` (do not re-test, it's expected to be unreachable): `newTrain.passCar`
still has a dormant `check()`/`reduce()` gap, harmless because `trainNeedingCars` never offers a tray
with nothing suitable in the yard.
---
## 3. Victory conditions unified (`GameConfig` dials)
**What changed:** `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
`pvpCardsAllowed` replace the old `length`-preset/`target`/flat collision constant, across all three
modes. Automated coverage: `test/advance.test.ts`'s "victory conditions (§3, Gap 10e) — unified
2026-08-20" describe block (8 tests) covers every case below at the engine level already. This section
is for confirming the *dialog* actually produces the numbers the engine then honors.
| # | Test | Steps | Expected |
|---|---|---|---|
| 3.1 | `minCombinedRevenue` — loss | New Game, set it to a number higher than achievable, play to Day N | Loss, reason `revenueFloor`, regardless of individual score |
| 3.2 | `minCombinedRevenue` — win | Set it to 0, play to Day N with any score | Win — `0` genuinely disables the floor |
| 3.3 | `maxCollisionsPerDay` — **new solitaire behavior** | Set to 1, deliberately cause 1 collision (e.g. leave every A/D track occupied on an arrival) | Game ends **immediately**, mid-Stage, loss, reason `collisionFloor` — solitaire could never lose this way before this change |
| 3.4 | `maxCollisionsTotal` | Set `maxCollisionsPerDay=0` (off), `maxCollisionsTotal=2`, cause 2 collisions across different Days | Game ends on the 2nd collision, whichever Day it falls on |
| 3.5 | Both collision caps at 0 | Set both to 0, cause several collisions | Game does **not** end early — same as pre-change solitaire (collisions still cost Revenue, just don't end the game) |
| 3.6 | Mode radio defaults | Open New Game, click Competitive | `minCombinedRevenue` suggests `3 × 4 × days` (nominal 4-player assumption — no lobby yet to ask real seat count), `pvpCardsAllowed` checks and enables, Deal disables with a "needs a server" note |
| 3.7 | Mode radio — Co-op | Click Co-op | `pvpCardsAllowed` unchecks and **greys out** (forced off, not just defaulted off — no valid target when everyone's on one side), Deal disables |
| 3.8 | Mode radio — back to Solitaire | Click Solitaire again | Deal re-enables, `pvpCardsAllowed` unchecks and greys, all four dials show the numbers the *currently playing* game actually has (not the preset) |
| 3.9 | URL round-trip | Deal a game with non-default `days`/`minrev`/`colday`/`coltotal`, copy the URL, open it in a new tab | Identical settings — all four now travel via `?days=&minrev=&colday=&coltotal=` alongside the existing `?hand=&passenger=&freight=&transit=` |
**Not yet clicked through in a real browser** (no browser binary in the sandbox this was built in —
verified only via a simulated-DOM test harness, `test/web.test.ts`'s "the New Game dialog" describe
block, which drives the actual compiled bundle). §3.6-3.9 specifically should get a real click-through
before calling this done.
---
## 4. Multiplayer server — Phase 2 (session core)
New in this release: `src/server/` (session host, HTTP/SSE, static serving), `src/web/session.ts`'s
`createRemoteSession`, `src/sim/frame-delta.ts`. Automated: `test/server/session.test.ts` (12 cases,
pure logic, no sockets), `test/redaction.test.ts` (4 cases — the exhaustive "no seat sees another
seat's secrets" check), `test/frame-delta.test.ts` (5 cases).
| # | Test | Steps | Expected |
|---|---|---|---|
| 4.1 | Boot | `JOIN_SECRET=x PORT=8081 node src/server/index.ts` | Starts, logs the bind address and `dist/` path |
| 4.2 | Join secret gate | `curl -X POST localhost:8081/api/game` (no `?secret=`) | `403 bad or missing secret` |
| 4.3 | Create game | `POST /api/game?secret=x` with `{config, playerNames}` | `200 {ok:true, playerCount:N}`; a second call while a game exists → `409` |
| 4.4 | Per-seat SSE | Open `/api/stream?seat=0` and `?seat=1` for a 2-player game | Only the current actor's push has a non-null `menu`; the other seat's is `null` |
| 4.5 | Turn enforcement | `POST /api/intent` as the **non-acting** seat | `{ok:false, code:"NOT_YOUR_TURN"}`, no broadcast, no state change |
| 4.6 | Accept + broadcast | `POST /api/intent` as the acting seat, a legal intent | `{ok:true}`; **both** SSE streams receive a push with fresh narration |
| 4.7 | Idempotent resend | Submit the same `{seq, intent}` twice | Second call: `{ok:true}`, **no** new SSE push (already applied, silently ignored) |
| 4.8 | Illegal intent | Submit something `check()` would reject | `{ok:false, code:"<real RejectionCode>"}`; the rejection text does **not** appear in the *other* seat's narration (private feedback, §session.ts's design note) |
| 4.9 | Board delta | Submit an intent that doesn't move anything on the board (e.g. `localOps.choose`) | The next push's `frame.division`/`frame.cells` are `null` (unchanged-since-last-push-to-this-seat) |
| 4.10 | Heartbeat | Hold an SSE connection open >20s idle | A `: ping` comment line appears (EventSource ignores it; keeps a proxy from reaping the socket) |
| 4.11 | **Real browser — not yet done** | Two actual browser tabs, `?seat=0` and `?seat=1`, playing a shared game by clicking | Confirm the whole loop works through the actual UI, not just curl: turn indicator, disabled controls for the non-acting seat, board updates on both sides, a rejected action shows *something* sensible on screen rather than silently doing nothing |
§4.1-4.10 were run live against a real server process during this session (not just unit-tested) — see
the session transcript for the actual curl commands and captured output. §4.11 genuinely was not done;
no browser was available in the environment this was built in.
---
## 5. Multiplayer server — Phase 3 (persistence and resumption)
New: `src/server/persistence.ts`, `game.ts`'s `fromMultiplayerSave`, `session.ts`'s `exportSave`/
`resumeSession`/turn-timing tracking. Automated: `test/server/persistence.test.ts` (6 cases),
`test/server/session.test.ts`'s "turn timings" and "persistence hooks" describe blocks.
| # | Test | Steps | Expected |
|---|---|---|---|
| 5.1 | Immediate persistence | `POST /api/game`, then check `DATA_DIR/game.json` | Exists immediately, empty `history`, correct `engineVersion` (= `package.json`'s `version`) |
| 5.2 | History grows | Submit a few intents | `game.json`'s `history` array grows by one entry per accepted intent, in order |
| 5.3 | Turn timings recorded | Submit enough intents to cross a full turn (player/phase/day/stage change) | `DATA_DIR/turn-timings.json` gains an entry: `{player, phase, day, stage, startedAt, endedAt}`, `endedAt >= startedAt` |
| 5.4 | **Restart and resume** (the actual "done when" for Phase 3) | Kill the server process mid-game, restart it against the same `DATA_DIR` | Log line: `"Resumed a saved game... N intents replayed"`; reconnecting both `?seat=` streams shows the exact same Day/Stage/phase/whose-turn as before the kill, correct narration attribution ("Player X chose to...", not anonymous) |
| 5.5 | Version-mismatch refusal | Hand-edit `game.json`'s `engineVersion` to a bogus value, restart | Log line refusing to load, naming both versions; server starts with **no** active game (confirm via `POST /api/game` succeeding, not `409`ing); the file is **not** deleted or modified |
| 5.6 | Atomic writes, no stray temp files | After several intents, check `DATA_DIR` | Only `game.json`/`turn-timings.json` present — no leftover `.tmp` files from an interrupted write |
§5.1-5.6 were all run live during this session (kill-and-restart included) — see the transcript. This
is the most thoroughly live-verified section of the whole plan.
---
## 6. Things this session found but did *not* fix — verify they're still correctly deferred
These are logged in `TODO.md`, not bugs to chase here — listed so a tester doesn't rediscover them and
assume something regressed.
| Item | Where | Current state to confirm |
|---|---|---|
| `fromSave`'s replay loses "Player X" narration attribution | `src/web/game.ts` | Still present in `fromSave` (untouched, out of scope); **fixed** in the new `fromMultiplayerSave` — §5.4's narration check is what confirms the fix landed where it needed to |
| `newTrain.passCar`'s `check()`/`reduce()` gap | `src/engine/apply.ts` | Still dormant/unreachable — confirm no new caller has started exercising it (would only matter if the *other* thread's 0.4.9 work touches this area) |
| No real browser click-through | This session | §3.6-3.9, §4.11 — the two gaps a human still needs to close |
---
## Summary checklist
- [ ] §0 automated gate green
- [ ] §1 solitaire regression (six checks)
- [ ] §2 New Train rotation, manual multi-seat check
- [ ] §3 victory conditions, especially §3.6-3.9 (dialog, not yet browser-verified)
- [ ] §4 server core, especially §4.11 (real browser, not yet done)
- [ ] §5 persistence — already live-verified this session; worth a second independent run
- [ ] §6 confirm the three known-and-deferred items are still exactly as described
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "station-master",
"version": "0.4.8",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "station-master",
"version": "0.4.8",
"version": "0.5.0",
"devDependencies": {
"@types/node": "^26.1.2",
"typescript": "^7.0.2"
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.4.9f",
"version": "0.5.5",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
@@ -9,7 +9,8 @@
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "node --test test/**/*.test.ts",
"pretest": "node scripts/build-web.ts",
"test": "node --test test/*.test.ts test/**/*.test.ts",
"build:web": "node scripts/build-web.ts",
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
"deploy:web": "node scripts/deploy-web.ts"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -15,7 +15,13 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const dist = join(root, 'dist');
/**
* Overridable so a test can point the build at an isolated directory instead of the shared
* `dist/` the rest of the suite reads — see `test/web.test.ts`'s "builds, and needs nothing but a
* static host". Must be a sibling of `dist/`, not nested inside it: this script's own `rmSync`
* below wipes the whole directory it is given, so nesting one inside the other would still race.
*/
const dist = join(root, process.env['BUILD_DIST_DIR'] ?? 'dist');
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
+32 -93
View File
@@ -1,29 +1,11 @@
/**
* Build the solitaire site and push it to a FileBrowser instance.
* Build the solitaire site and push it to a File Browser instance.
*
* REWRITTEN FOR **FileBrowser Quantum** (2026-08-23). The host was upgraded from File Browser v2.63
* to the Quantum fork, whose API is different in three ways at once, and every deploy failed with
* `login failed: 404 404 page not found` — the old `/api/login` simply is not there any more.
* Written against File Browser v2.63's REST API, read from its own bundle rather than guessed:
*
* Read from the running instance's own bundle rather than guessed, the same way the v2.63 version
* was (`/public/static/assets/index-*.js`, gzipped — pipe it through `gunzip` before grepping), and
* each path confirmed against the live host by the response code: an endpoint that exists answers a
* bad password with **401**, one that does not answers **404**.
*
* POST /api/auth/login?username=<u>&recaptcha=
* headers X-Password: <urlencoded>, X-Secret: <otp or empty> -> sets a session COOKIE
* GET /api/settings/sources -> the named sources
* POST /api/resources?path=<p>&source=<s>&isDir=true -> create a directory
* POST /api/resources?path=<p>&source=<s>&override=true body=bytes -> upload
*
* THREE THINGS MOVED, and each would break on its own:
* 1. AUTH IS A COOKIE, not an `X-Auth: <jwt>` header. Login returns no usable token in its body;
* the session arrives in `Set-Cookie` and every later request has to carry it back.
* 2. THE PASSWORD IS A HEADER, `X-Password`, URL-encoded — not a JSON body field.
* 3. THE PATH IS A QUERY PARAMETER, `?path=`, not part of the URL, and every resource call also
* needs a **`source`** naming which configured store to write to. Quantum throws "no source
* provided" without it. `FB_SOURCE` names it; left unset, the sole configured source is used,
* and if there is more than one this stops and lists them rather than guessing.
* POST /api/login {username, password, recaptcha} -> JWT as plain text
* POST /api/resources/<dir>/ X-Auth: <jwt> -> create a directory
* POST /api/resources/<file>?override=true X-Auth: <jwt>, body = bytes -> upload
*
* File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site.
* So the job here is simply to land the built files in the right folder, intact.
@@ -36,8 +18,6 @@
* Optional:
* FB_URL default https://phoenix.local:58157
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
* FB_SOURCE which configured source to write to; discovered automatically when there is one
* FB_OTP the one-time code, if the account has two-factor enabled
* FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
* SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at
* --dry-run list what would be sent, contact nothing
@@ -64,8 +44,6 @@ const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replac
const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`;
const USER = process.env['FB_USER'] ?? '';
const PASS = process.env['FB_PASS'] ?? '';
const OTP = process.env['FB_OTP'] ?? '';
const SOURCE_ENV = process.env['FB_SOURCE'] ?? '';
const DRY = process.argv.includes('--dry-run');
/**
@@ -97,80 +75,37 @@ const CONTENT_TYPES: Record<string, string> = {
'.txt': 'text/plain',
};
/**
* Log in and return the session cookie every later request must carry.
*
* The password goes in a HEADER and URL-encoded, which is Quantum's own client does
* (`X-Password: encodeURIComponent(password)`). The body carries nothing useful on success — the
* session is in `Set-Cookie`, so a deploy that ignored the cookie would authenticate and then be
* rejected by every upload.
*/
async function login(): Promise<string> {
const url = `${URL_BASE}/api/auth/login?username=${encodeURIComponent(USER)}&recaptcha=`;
const res = await fetch(url, {
const res = await fetch(`${URL_BASE}/api/login`, {
method: 'POST',
headers: { 'X-Password': encodeURIComponent(PASS), 'X-Secret': OTP },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }),
});
const body = await res.text();
if (!res.ok) {
// 401 here is a wrong username/password; 404 would mean this build has moved the API again.
throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
}
const cookies = res.headers.getSetCookie();
if (cookies.length === 0) throw new Error('login succeeded but set no session cookie');
return cookies.map((c) => c.split(';')[0]).join('; ');
if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
if (!body.trim()) throw new Error('login returned an empty token');
return body.trim();
}
/**
* WHICH STORE TO WRITE TO. Quantum can serve several named sources and refuses any resource call
* that does not name one ("no source provided"), which is the parameter the v2.63 API had no
* concept of. One configured source is the normal case and is used without asking; more than one is
* ambiguous, and guessing would silently deploy the site into the wrong store.
*/
async function resolveSource(cookie: string): Promise<string> {
if (SOURCE_ENV) return SOURCE_ENV;
const res = await fetch(`${URL_BASE}/api/settings/sources`, { headers: { cookie } });
if (!res.ok) throw new Error(`could not list sources: ${res.status} ${await res.text()}`);
const names = Object.keys((await res.json()) ?? {});
if (names.length === 1) return names[0]!;
if (names.length === 0) throw new Error('the server reports no sources at all');
throw new Error(`several sources configured (${names.join(', ')}) — pick one with FB_SOURCE=<name>`);
}
function resourceUrl(source: string, path: string, extra: Record<string, string>): string {
const params = new URLSearchParams({ path, source, ...extra });
return `${URL_BASE}/api/resources?${params}`;
}
async function makeDir(cookie: string, source: string, path: string): Promise<void> {
const res = await fetch(resourceUrl(source, path, { isDir: 'true' }), {
async function makeDir(jwt: string, path: string): Promise<void> {
// Trailing slash is what marks a directory in this API. A 409 means it already exists, which is
// the normal case on every deploy after the first.
const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, {
method: 'POST',
headers: { cookie },
headers: { 'X-Auth': jwt },
});
if (res.ok) return;
/**
* "Already there" is the normal case on every deploy after the first, and Quantum is not
* consistent about which code it reports it with. So the two failures worth stopping for are
* named — a rejected session, and a server that broke — and every other 4xx is treated as the
* directory already existing. A directory that genuinely is not there fails loudly at the upload
* a moment later, which is a better place to find out than a guess here.
*/
const fatal = res.status === 401 || res.status === 403 || res.status >= 500;
if (fatal) throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
if (!res.ok && res.status !== 409) {
throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
}
}
async function upload(
cookie: string,
source: string,
localPath: string,
remotePath: string,
): Promise<void> {
async function upload(jwt: string, localPath: string, remotePath: string): Promise<void> {
const bytes = readFileSync(localPath);
const ext = remotePath.slice(remotePath.lastIndexOf('.'));
const res = await fetch(resourceUrl(source, remotePath, { override: 'true' }), {
const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, {
method: 'POST',
headers: {
cookie,
'X-Auth': jwt,
'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
'Content-Length': String(bytes.byteLength),
},
@@ -179,6 +114,11 @@ async function upload(
if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`);
}
/** Encode each segment but keep the separators, so a path stays a path. */
function encodePath(p: string): string {
return p.split('/').map(encodeURIComponent).join('/');
}
// ---------------------------------------------------------------------------
console.log('building…');
@@ -207,16 +147,15 @@ if (DRY) {
);
}
const cookie = await login();
const source = await resolveSource(cookie);
console.log(`logged in — writing to source "${source}"`);
const jwt = await login();
console.log('logged in');
await makeDir(cookie, source, DEST);
for (const d of dirs) await makeDir(cookie, source, `${DEST}/${d}`);
await makeDir(jwt, DEST);
for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`);
let done = 0;
for (const f of files) {
await upload(cookie, source, join(dist, f), `${DEST}/${f}`);
await upload(jwt, join(dist, f), `${DEST}/${f}`);
done++;
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
}
+66 -116
View File
@@ -27,9 +27,7 @@ import {
MOVES_PER_LOCAL_OPS_NIGHT,
STAGES_PER_DAY,
STAGES_PER_SHIFT,
collectiveRevenueFloor,
houseRules,
lengthProfile,
officeProfile,
REGIONS_PER_MAINLINE_CARD,
mainlineProfile,
@@ -41,7 +39,7 @@ import type { GameEvent } from './events.ts';
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
import { legalActions } from './legal.ts';
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts';
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, subdivisions, totalRevenue, turnOf } from './state.ts';
export type AdvanceResult = {
events: GameEvent[];
@@ -273,10 +271,23 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
return { events, needsInput: true };
}
// Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains.
/**
* Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains,
* cycling Superintendent-then-left one car at a time (§7).
*
* `tray.consist.length` IS the round position: a freshly made-up tray always starts with
* `consist: []`, and each `newTrain.placeCar` appends exactly one car to it (`apply.ts`'s
* `carPlacedOnTrain` reducer), so it counts placements toward THIS tray without any new state —
* and resets to 0 naturally for the next train made up, which a phase-wide `actorOffset` cannot
* do. Reproduces the rulebook's worked example exactly: 2 players, 4-coach Limited → seats
* 0, 1, 0, 1.
*/
const filling = trainNeedingCars(s);
if (filling) {
s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length);
const tray = s.trays.get(filling)!;
const nextActor = actorAt(s, tray.consist.length % s.players.length);
if (s.clock.currentActor !== nextActor) events.push({ type: 'actorChanged', player: nextActor });
s.clock.currentActor = nextActor;
return { events, needsInput: true };
}
@@ -445,9 +456,6 @@ function enterMainline(
);
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
tray.position = { at: 'mainline', index };
// It is running now, so it is no longer being assembled (state.ts). A train at a Division Point
// needs no equivalent: leaving one changes its position, which is what that case reads.
delete tray.beingMadeUp;
/**
* OUT OF THE DISTRICT, AND THE SPUR PORT GOES WITH IT.
@@ -645,43 +653,6 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
const node = s.division.nodes[index];
if (!node || node.kind !== 'mainline') return 'held';
/**
* §7/§8.1 — AN EXTRA HIGHBALLING OUT OF THE INTERCHANGE'S YARD.
*
* Standing in `holding` rather than crossing in `transits` (state.ts), which is the position an
* Extra started at an Interchange begins in. It leaves exactly the way a train at a Division
* Point does — clearance first, then onto the running line — except that the card it enters is
* the one it is already standing beside rather than the next one along.
*
* That reuse is the whole point of modelling the yard separately: Jesse's rule is "a guaranteed
* collision holds it at the Interchange for another Stage and it tries again; a potential one is
* the Superintendent's to hold", and those are precisely `evaluateClearance`'s `blocked` and
* `ask`. Nothing new decides collisions here.
*/
if (node.holding?.includes(id)) {
const clearance = evaluateClearance(s, id, tray, index, events);
if (clearance === 'blocked') {
events.push({
type: 'trainHeld',
trainNumber: tray.trainNumber ?? 0,
reason: 'held in the Interchange — the Subdivision ahead is occupied',
});
return 'held';
}
if (clearance === 'ask') return 'needsClearance';
node.holding = node.holding.filter((t) => t !== id);
enterMainline(s, node, id, tray, index);
events.push({
type: 'trainHighballed',
trainNumber: tray.trainNumber ?? 0,
from: 'the Interchange',
to: 'the Mainline',
why: 'it was made up in the yard and the Subdivision was clear, so its run begins',
});
return 'moved';
}
const transit = node.transits.find((t) => t.tray === id);
if (!transit) return 'held';
@@ -933,8 +904,13 @@ function arriveAtOffice(
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
// is not expecting them (§A.4), so this is a collision too, not a coupling.
//
// A coach is the one exception (v0.5.0, §A.4's Office carve-out): it may be legally, deliberately
// parked at the Office while its engine switches, so it must not become a hazard to the next
// arrival. Anything else standing there is still illegal to have dropped in the first place —
// `canDropCarsAt` already refuses it — so this filter only ever excludes a coach in practice.
const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard && officeCard.standing.length > 0) {
if (officeCard && officeCard.standing.some((c) => c.type !== 'coach')) {
collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the Running Track', 'the Running Track');
return 'moved';
}
@@ -983,33 +959,12 @@ function collide(
consist: [...tray.consist],
});
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
// `pooled` because a car reaching a yard is back in the common supply: the load's origin stamp
// (state.ts) belongs to the load, not to the car that happened to be carrying it.
for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
else s.yards.classificationYard.push(pooled(car));
if (car.type === 'caboose') s.yards.divisionYard.push(car);
else s.yards.classificationYard.push(car);
}
s.trays.delete(id);
s.freeTrays.push(id);
/**
* TAKE THE WRECK OFF THE CARD.
*
* Nothing did. `s.trays.delete` removed the train and left its `Transit` sitting on the Mainline
* card it died on, and `evaluateClearance` counts every transit as an occupant — so a rear-end
* collision (the caller at "ran into the train ahead") permanently poisoned that card: every
* later train was either held against a ghost or put to the Superintendent about one. The only
* other place a transit is removed is a train rolling off the far end, which a destroyed train
* never does.
*
* Found while adding the Interchange start, which clears onto the running line through that
* same occupant list.
*/
for (const n of s.division.nodes) {
if (n.kind !== 'mainline') continue;
n.transits = n.transits.filter((t) => t.tray !== id);
if (n.holding) n.holding = n.holding.filter((t) => t !== id);
if (n.redFlagged) n.redFlagged = n.redFlagged.filter((t) => t !== id);
}
}
if (lost.length > 0) {
@@ -1017,6 +972,7 @@ function collide(
}
s.collisionsToday += 1;
s.collisionsTotal += 1;
const player = s.players[faultPlayer];
if (player) {
player.revenue -= COLLISION_PENALTY;
@@ -1075,10 +1031,9 @@ function retireTrain(
side: Direction,
events: GameEvent[],
): void {
// `pooled` — see `trainsDestroyed` above; a load's origin stamp does not survive the yard.
for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
else s.yards.classificationYard.push(pooled(car));
if (car.type === 'caboose') s.yards.divisionYard.push(car);
else s.yards.classificationYard.push(car);
}
s.trays.delete(id);
s.freeTrays.push(id);
@@ -1129,63 +1084,58 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
}
// §3.4 — Competitive only: three collisions in one Day and everyone loses.
if (s.config.mode === 'competitive' && s.collisionsToday >= 3) {
s.status = 'finished';
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
return { events, needsInput: false };
// §3.4 — every mode but competitive-and-coop-only: a Day's collisions against `maxCollisionsPerDay`
// and the game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not
// scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to
// collide, not a bigger shared budget.
if (s.config.mode === 'competitive' || s.config.mode === 'coop') {
const perDayBreach =
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
const totalBreach =
s.config.maxCollisionsTotal > 0 && s.collisionsTotal >= s.config.maxCollisionsTotal;
if (perDayBreach || totalBreach) {
s.status = 'finished';
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
return { events, needsInput: false };
}
}
return { events: [...events, ...enterPhase(s, 'localOps')], needsInput: false };
}
/** §3.3 — evaluated at the end of a Day. */
/**
* §3.3 — evaluated at the end of a Day.
*
* Unified 2026-08-20 across all three modes: play `config.days`, then whoever has the most Revenue
* wins — unless the table's combined Revenue missed `config.minCombinedRevenue`, in which case
* everyone loses. Solitaire is "everyone" with one player, so this is the same win/lose shape it
* always had, just against a configured floor instead of a `length`-preset `target`. Coop keeps its
* "the table's score is everyone's Revenue summed" model — winner stays null, the achievement is
* shared — now against the same configurable floor.
*/
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
const profile = lengthProfile(s.config.length);
const daysElapsed = s.clock.day - 1;
if (s.config.victory === 'firstToTarget') {
const target =
s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
const score = s.config.mode === 'coop' ? totalRevenue(s) : Math.max(...s.players.map((p) => p.revenue));
if (score >= target) {
const winner =
s.config.mode === 'coop'
? null
: s.players.findIndex((p) => p.revenue === score);
s.status = 'finished';
s.outcome = { result: 'win', winner: winner === -1 ? null : winner, reason: 'targetReached' };
return true;
}
return false;
}
if (daysElapsed < profile.days) return false;
if (daysElapsed < s.config.days) return false;
s.status = 'finished';
if (s.config.mode === 'competitive') {
// §3.5 — all players' Revenue combined must clear the floor, or everyone loses.
if (totalRevenue(s) < collectiveRevenueFloor(s.players.length, profile.days)) {
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
return true;
}
const best = Math.max(...s.players.map((p) => p.revenue));
s.outcome = {
result: 'win',
winner: s.players.findIndex((p) => p.revenue === best),
reason: 'daysElapsed',
};
const combined = totalRevenue(s);
if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) {
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
return true;
}
// Solitaire and Co-op: the target doubles as a MINIMUM. Below it you lose regardless of score.
const target = s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
const score = s.config.mode === 'coop' ? totalRevenue(s) : (s.players[0]?.revenue ?? 0);
s.outcome =
score >= target
? { result: 'win', winner: null, reason: 'daysElapsed' }
: { result: 'loss', winner: null, reason: 'revenueFloor' };
if (s.config.mode === 'coop') {
s.outcome = { result: 'win', winner: null, reason: 'daysElapsed' };
return true;
}
const best = Math.max(...s.players.map((p) => p.revenue));
s.outcome = {
result: 'win',
winner: s.players.findIndex((p) => p.revenue === best),
reason: 'daysElapsed',
};
return true;
}
+101 -321
View File
@@ -33,9 +33,9 @@ import {
officeProfile,
trainProfile,
} from './content.ts';
import type { CarType, Direction, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts';
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts';
import type { GameEvent } from './events.ts';
import type { ExtraStart, Intent, RejectionCode } from './intents.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
CardId,
CrewTray,
@@ -59,7 +59,6 @@ import {
cutTowards,
isOperationalRail,
playerAtSeat,
pooled,
railFacingOf,
seatOf,
spaceOn,
@@ -389,11 +388,17 @@ export function canStartLoad(f: Facility): boolean {
}
/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */
export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean {
export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false;
return passengerWork(s, player, 'board', trayId) !== null;
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
// train these passengers can board however many empty coaches it is carrying.
return trainAtOfficeWith(
s, player,
(c) => c.type === 'coach' && !c.loaded,
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
);
}
/**
@@ -408,131 +413,29 @@ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord, trayI
* de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the
* train. Measured at 1.29 cars a game created out of nothing across the two inbound paths.
*/
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean {
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
if (f.inboundBox.length >= f.capacity.inbound) return false;
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
return passengerWork(s, player, 'detrain', trayId) !== null;
return trainAtOfficeWith(
s, player,
(c) => c.type === 'coach' && c.loaded,
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
);
}
/**
* A Timetabled or Extra train card (§6.2, Gitea#6) — the one place that decides what "a train card"
* means, so the rule, the UI's reason text and any test all ask the same question.
*/
export function isTrainCard(s: GameState, cardId: CardId): boolean {
const kind = s.cards.get(cardId)?.kind.kind;
return kind === 'timetabledTrain' || kind === 'extraTrain';
}
/**
* WHERE AN EXTRA STARTS AND WHICH WAY IT RUNS — the one answer `check`, `execute` and the reducer
* all use, so a placement can never be checked against one square and made on another.
*
* §7 lets the player who played the card choose the start, and Jesse's ruling makes the start
* decide the direction rather than the number (`runDirection`'s comment carries the supersession):
*
* - a DIVISION POINT runs the train away from itself — the west end runs east, the east end west.
* `direction` on the intent is ignored rather than refused, because there is only one answer;
* - an INTERCHANGE or a CONTROL POINT sits in the middle of the railroad, where both ways are real
* runs, so the intent must say which.
*
* Which of those are on offer is the `extraStart` house rule. The two that belong to nobody — the
* Division Points and the Interchange — are always available; an Office is a seat's own ground and
* is gated, to `ownOffice` (the player who played the card) or `anyOffice`.
*
* Returns a refusal code rather than throwing, so `check` can hand it straight back.
*/
export function resolveExtraStart(
function trainAtOfficeWith(
s: GameState,
player: PlayerIndex,
i: { trainNumber: number; atSeat?: SeatIndex | null; start?: ExtraStart; direction?: Direction },
): { at: ExtraStart; direction: Direction } | RejectionCode {
/**
* A save written before the choice existed. `atSeat` null meant the Division Point the NUMBER
* sent the train to, a seat meant that Office, and both ran in the number's direction — so that
* is what these replay as, whatever the rules say today.
*/
if (!i.start) {
const direction = runDirection(i.trainNumber);
if (i.atSeat === null || i.atSeat === undefined) {
return { at: { kind: 'divisionPoint', side: startingDivisionPoint(i.trainNumber) }, direction };
}
return { at: { kind: 'office', seat: i.atSeat }, direction };
}
const start = i.start;
if (start.kind === 'divisionPoint') {
if (!s.division.nodes.some((n) => n.kind === 'divisionPoint' && n.side === start.side)) {
return 'NO_SUCH_DIVISION_POINT';
}
// Away from the end it is standing at. Nothing else is a run.
return { at: start, direction: start.side === 'west' ? 'east' : 'west' };
}
if (i.direction === undefined) return 'NO_DIRECTION_CHOSEN';
if (start.kind === 'mainline') {
const node = s.division.nodes[start.node];
if (!node || node.kind !== 'mainline') return 'NO_SUCH_CARD';
// The Interchange is the one Mainline card an Extra may be made up on — it is the one with a
// yard. `sortsCars` is what the card prints and the only thing that distinguishes it.
if (!mainlineProfile(node.card).sortsCars) return 'NOT_AN_INTERCHANGE';
return { at: start, direction: i.direction };
}
const rule = houseRules(s.config).extraStart;
if (rule === 'divisionPointsOnly') return 'OFFICE_STARTS_NOT_ALLOWED';
if (rule === 'ownOffice' && start.seat !== seatOf(s, player)) return 'NOT_YOUR_OFFICE';
const area = s.officeAreas.get(start.seat);
if (!area) return 'NO_SUCH_FACILITY';
// A Control Point is any Office above a Whistle Post (§8). A Whistle Post is not one, which is
// the whole reason upgrading buys a place for an Extra to start — and no setting of the house
// rule lets one in.
if (!officeProfile(area.tier).isControlPoint) return 'NOT_A_CONTROL_POINT';
return { at: start, direction: i.direction };
}
/**
* WHICH TRAIN, AND WHICH COACH ON IT — the one answer `check`, `execute` and the reducer all use.
*
* TWO PLAYTEST BUGS SHARED ONE CAUSE HERE. Reported against v0.4.9d: "operating two trains in a
* station, the select button does not work — regardless of which you pick, it is always one train,
* not the other". `porter.board` carried no tray at all, so `check` asked whether SOME train at the
* Office had an empty coach and the reducer then walked `adOccupancy` and filled the first one it
* found. The two were not even asking the same question: `check` skipped a train whose card refuses
* passenger work and the reducer did not, so a Military train could be boarded as long as some other
* train at the platform was eligible. The intent now names its tray (`intents.ts`) and this is the
* one place that resolves it.
*
* And "passengers just boarded cannot be immediately unloaded": a coach carries the district that
* filled it (`RollingStock.origin`), and a homegrown coach is not a coach these passengers may
* alight from — they have to be carried to a different Office Area first.
*
* `trayId` absent means "any eligible train", which is what every intent recorded before this
* existed meant, so an old save replays unchanged.
*/
function passengerWork(
s: GameState,
player: PlayerIndex,
dir: 'board' | 'detrain',
trayId?: TrayId,
): { trayId: TrayId; coachIndex: number } | null {
pred: (c: RollingStock) => boolean,
trayOk: (t: CrewTray) => boolean = () => true,
): boolean {
const area = areaOf(s, player);
const seat = seatOf(s, player);
const wanted = (c: RollingStock): boolean =>
c.type === 'coach' && (dir === 'board' ? !c.loaded : c.loaded && c.origin !== seat);
for (const id of area.adOccupancy) {
if (trayId !== undefined && id !== trayId) continue;
const tray = s.trays.get(id);
if (!tray) continue;
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
// train these passengers can board however many empty coaches it is carrying.
if (refusesPassengers(tray) || refusesThisOffice(s, player, tray)) continue;
const coachIndex = tray.consist.findIndex(wanted);
if (coachIndex >= 0) return { trayId: id, coachIndex };
}
return null;
return area.adOccupancy.some((id) => {
const t = s.trays.get(id);
return !!t && trayOk(t) && t.consist.some(pred);
});
}
// ---------------------------------------------------------------------------
@@ -667,30 +570,12 @@ function passengerRefusal(
player: PlayerIndex,
at: GridCoord,
dir: 'board' | 'detrain',
trayId?: TrayId,
): RejectionCode {
const area = areaOf(s, player);
const trains = area.adOccupancy
.filter((id) => trayId === undefined || id === trayId)
.map((id) => s.trays.get(id))
.filter((t): t is CrewTray => !!t);
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t);
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE';
/**
* EVERY LOADED COACH ABOARD BOARDED HERE — so the refusal is the district rule, not "no loaded
* coach". Told apart because the two read as opposite situations to a player: one is an empty
* train, the other is a train full of passengers who have not been anywhere yet.
*/
if (
dir === 'detrain' &&
trains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded)) &&
trains.every((t) =>
t.consist.every((c) => !(c.type === 'coach' && c.loaded) || c.origin === seatOf(s, player)),
)
) {
return 'LOADED_IN_THIS_DISTRICT';
}
/**
* A TRAIN IS STANDING THERE, so say what is actually missing.
@@ -812,25 +697,24 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
const dropRules = rulesOf(tray);
const dropArea = areaOf(s, player);
const atOffice = here.row === dropArea.officeCoord.row && here.col === dropArea.officeCoord.col;
/**
* Trains 7/8 Local — "coach must remain on station track if switching", i.e. the coach is
* never set out during switching at all.
*
* The intended reading was "set out only at the Office", but §A.4 makes that unimplementable:
* `canDropCarsAt` refuses the Office square outright — "the Office track is Operational Rail,
* but Rolling Stock may not be left there" — so "only at the Office" and "nowhere" are the same
* rule. What is left is the effect that matters: the Local may shunt its freight car around the
* district, and may not abandon its coach at an industry or on a siding while it does.
* Flagged in `TODO.md` in case the station track is meant to become a real place to leave one.
* Trains 7/8 Local — "coach must remain on station track if switching" — the coach may never
* be set out anywhere ELSE while switching. §A.4 now carries an exception for the Office
* square (v0.5.0, Jesse's call): any train may cut a coach loose there, which is exactly what
* "station track" meant on the card all along. So the coach is refused everywhere except the
* Office, rather than everywhere.
*/
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach')) {
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach') && !atOffice) {
return 'COACH_MUST_STAY';
}
const droppedFreight = cut.filter(isFreight).length;
if (droppedFreight > 0 && !freightBudgetLeft(s, player, tray, here, droppedFreight)) {
return 'FREIGHT_WORKED_HERE';
}
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
const coachesOnly = cut.every((c) => c.type === 'coach');
return canDropCarsAt(dropArea, here, i.count, coachesOnly) ? null : 'CANNOT_DROP_HERE';
}
case 'switch.sortConsist': {
@@ -881,31 +765,11 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node);
}
/**
* §6.2, AS RULED BY JESSE (Gitea#6): A TRAIN CARD MAY NOT BE DISCARDED. EVER.
*
* It may be held for as many Stages and Days as the player likes — the hand limit is the only
* pressure on it — but it never goes onto a Department pile. The consequence is the point of the
* rule and needs no machinery of its own: a player holding four train cards has nothing
* discardable, and `draw.end` already refuses while the hand is over the limit, so the only way
* to conclude the turn is to PLAY one. Playing a train card is unconditionally legal (see
* `card.play`'s `timetabledTrain` case, which refuses only a board placement), so that corner
* can never lock a player in.
*
* Extras count. They are trains — Jesse's ruling in the same breath — even though an Extra runs
* once and ends in the Salvage Yard while a Timetabled card joins the timetable for the rest of
* the game.
*
* `legal.ts` enumerates candidates and filters them through here, so the discard option simply
* stops being offered for these cards; the bot needs no separate rule and already reaches for
* `card.play` before it reaches for a discard.
*/
case 'card.discard': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
const hand = s.decks.hands.get(player) ?? [];
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
if (isTrainCard(s, i.cardId)) return 'TRAINS_ARE_NEVER_DISCARDED';
return null;
}
@@ -924,9 +788,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
// restriction exists to prevent. A train standing in the Interchange's yard counts: it is on
// the card, and it is about to pull out onto the very rail being relaid.
if (node.transits.length > 0 || (node.holding?.length ?? 0) > 0) return 'TRAIN_ON_CARD';
// restriction exists to prevent.
if (node.transits.length > 0) return 'TRAIN_ON_CARD';
if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) {
return 'NO_PLACEMENT';
}
@@ -1053,16 +916,12 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
if (!s.pendingExtras.includes(i.trainNumber)) return 'NO_EXTRA_PENDING';
if (s.freeTrays.length === 0) return 'NO_FREE_TRAY';
const where = resolveExtraStart(s, player, i);
if (typeof where === 'string') return where;
if (where.at.kind === 'divisionPoint') return null;
if (where.at.kind === 'mainline') {
// Nothing to refuse. The train is made up in the Interchange's yard, off the running line,
// so however busy the card is this cannot be the collision §7 says it must not force. What
// it may not do is get OUT — that is §8.1's question, asked at the Mainline Phase.
return null;
}
const area = s.officeAreas.get(where.at.seat)!;
if (i.atSeat === null) return null;
// A Control Point is any Office above a Whistle Post (§8). A Whistle Post is not one, which is
// the whole reason upgrading buys a place for an Extra to start.
const area = s.officeAreas.get(i.atSeat);
if (!area) return 'NO_SUCH_FACILITY';
if (!officeProfile(area.tier).isControlPoint) return 'NOT_A_CONTROL_POINT';
// It still has to fit: an Extra starting here takes an A/D track like any other arrival.
return area.adOccupancy.length >= officeProfile(area.tier).adTracks ? 'NO_FREE_AD_TRACK' : null;
}
@@ -1122,8 +981,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
return canBoard(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'board', i.trayId);
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'board');
}
case 'porter.detrain': {
@@ -1132,8 +990,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
return canDetrain(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'detrain', i.trayId);
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'detrain');
}
case 'laborer.startLoad': {
@@ -1169,19 +1026,6 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
const car = f.industryTrack.cars[i.carIndex];
if (!car || !car.loaded) return 'WRONG_CAR_TYPE';
/**
* A LOAD MAY NOT BE BROKEN IN THE DISTRICT THAT MADE IT (Jesse's ruling, v0.4.9e).
*
* Reported from playtesting v0.4.9d: "Freight House: boxcars loaded cannot be immediately
* unloaded." They could — a Freight House permits both directions, so the car its own Laborers
* had just loaded was standing on its own track, loaded, with an empty of that type in the
* yard, and every gate below said yes. Full Revenue at both ends for a load that never moved.
*
* The rule is district-wide and permanent, not "not at this facility" and not "not this
* Stage": the stamp says which Office Area made the load, and it never expires. Traffic runs
* BETWEEN districts, which is what the lockout pairs in `content.ts` exist to force.
*/
if (car.origin === seatOf(s, player)) return 'LOADED_IN_THIS_DISTRICT';
/**
* §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
@@ -1740,13 +1584,8 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
}
case 'newTrain.startExtra': {
// `check` has already accepted this, so the resolve cannot fail here.
const where = resolveExtraStart(s, player, i) as { at: ExtraStart; direction: Direction };
return [
{ type: 'extraStarted', player, trainNumber: i.trainNumber, at: where.at, direction: where.direction },
];
}
case 'newTrain.startExtra':
return [{ type: 'extraStarted', player, trainNumber: i.trainNumber, atSeat: i.atSeat }];
case 'newTrain.placeCar':
return [
@@ -1778,23 +1617,17 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
* `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate
* is named per COACH because a Porter handles exactly one coach per action.
*/
case 'porter.board': {
// `check` has already established there is one; resolving it HERE, once, is what stops the
// reducer from finding a different train than the one the rules were tested against.
const work = passengerWork(s, player, 'board', i.trayId)!;
case 'porter.board':
return [
{ type: 'passengersBoarded', player, at: i.at, ...work },
{ type: 'passengersBoarded', player, at: i.at },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
];
}
case 'porter.detrain': {
const work = passengerWork(s, player, 'detrain', i.trayId)!;
case 'porter.detrain':
return [
{ type: 'passengersDetrained', player, at: i.at, ...work },
{ type: 'passengersDetrained', player, at: i.at },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
];
}
case 'laborer.startLoad': {
const f = facilityAt(s, player, i.at)!;
@@ -2123,14 +1956,9 @@ export function reduce(s: GameState, e: GameEvent): void {
const card = area.grid.get(coordKey(e.to));
if (tray && card) {
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
// `carsOn` is the one function that knows WHERE cars stand on a given card — an industry
// track for a freight facility, the card itself for everything else. Written out longhand
// here it was a second copy of that rule, and the copy was wrong for a Passenger Facility:
// it has an `industryTrack` too (an empty one, `setup.ts`), so a cut pushed into an Office
// would have landed somewhere `carsOn` cannot see — cars on the board that no train can
// couple and no walk is blocked by. `check` refuses a non-freight target, so this never
// fired; a trap that needs another rule to stay unsprung is still a trap.
carsOn(card).push(...e.stock);
const track = card.facility?.industryTrack;
if (track) track.cars.push(...e.stock);
else card.standing.push(...e.stock);
}
turnOf(s, e.player).movesRemaining -= 1;
spendCard(s, e.player, e.cardId);
@@ -2221,8 +2049,7 @@ export function reduce(s: GameState, e: GameEvent): void {
const f = facilityAt(s, e.player, e.at)!;
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
if (idx >= 0) f.inboundBox.splice(idx, 1);
// `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts).
s.yards.classificationYard.push(pooled(e.stock));
s.yards.classificationYard.push(e.stock);
turnOf(s, e.player).freightAgentUsed = true;
break;
}
@@ -2237,7 +2064,7 @@ export function reduce(s: GameState, e: GameEvent): void {
const idx = box.findIndex((c) => c.type === e.stock.type);
if (idx >= 0) box.splice(idx, 1);
}
s.yards.classificationYard.push(pooled(e.stock));
s.yards.classificationYard.push(e.stock);
turnOf(s, e.player).freightAgentUsed = true;
break;
}
@@ -2279,82 +2106,45 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
/**
* THE THREE PLACES AN EXTRA MAY BE MADE UP (§7, Jesse's ruling) — and all three leave it
* STANDING somewhere it must later highball out of, never already running.
*/
case 'extraStarted': {
const trayId = s.freeTrays.pop()!;
s.pendingExtras = s.pendingExtras.filter((n) => n !== e.trainNumber);
const { direction } = e;
const base = {
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0,
consist: [] as RollingStock[], direction, movesUsed: 0,
};
// Which way the tray physically points, for the cards it will pick up. A Division Point start
// does not set it: those trays have never carried a facing and `enterMainline` does not read
// one, so writing it here would be inventing state the DP path has always done without.
const facing = { facing: direction === 'west' ? ('w' as const) : ('e' as const),
railFacing: direction === 'west' ? ('w' as const) : ('e' as const) };
if (e.at.kind === 'divisionPoint') {
const side = e.at.side;
s.trays.set(trayId, { ...base, position: { at: 'divisionPoint', side } });
const direction = runDirection(e.trainNumber);
if (e.atSeat === null) {
const side = startingDivisionPoint(e.trainNumber);
s.trays.set(trayId, {
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0, consist: [],
direction, position: { at: 'divisionPoint', side }, movesUsed: 0,
});
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side);
if (dp?.kind === 'divisionPoint') dp.holding.push(trayId);
break;
}
if (e.at.kind === 'mainline') {
/**
* IN THE INTERCHANGE'S YARD, NOT ON THE MAINLINE — the distinction §7 turns on.
*
* `holding` rather than `transits`, so the card can be nose to tail with traffic and this
* placement still forces no collision. It joins the running line at a later Mainline Phase
* through `evaluateClearance`, which is what gives the Superintendent the hold Jesse asked
* for: an automatic one against a facing train, a ruling against a following one.
*/
const node = s.division.nodes[e.at.node];
} else {
// Starting at a Control Point: it stands on the Office card and takes an A/D track, exactly
// as though it had arrived there.
const area = areaAtSeat(s, e.atSeat);
s.trays.set(trayId, {
...base, ...facing, beingMadeUp: true, position: { at: 'mainline', index: e.at.node },
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0, consist: [],
direction, facing: direction === 'west' ? 'w' : 'e', railFacing: direction === 'west' ? 'w' : 'e',
position: { at: 'grid', seat: e.atSeat, coord: area.officeCoord }, movesUsed: 0,
});
if (node?.kind === 'mainline') (node.holding ??= []).push(trayId);
break;
area.adOccupancy.push(trayId);
}
// Starting at a Control Point: it stands on the Office card and takes an A/D track, exactly
// as though it had arrived there.
const area = areaAtSeat(s, e.at.seat);
s.trays.set(trayId, {
...base, ...facing, beingMadeUp: true,
position: { at: 'grid', seat: e.at.seat, coord: area.officeCoord },
});
area.adOccupancy.push(trayId);
break;
}
/**
* THE TRAIN AND THE COACH THE PLAYER PICKED, not "the first one on the A/D tracks".
*
* This used to walk `adOccupancy` and fill the first empty coach it met, which is why two trains
* standing at one station both answered to whichever chip was clicked (v0.4.9d playtest), and
* why it could fill a coach on a train whose card refuses passenger work — `check` skipped such
* a train and the reducer did not. `e.trayId`/`e.coachIndex` are exactly what `passengerWork`
* resolved for `check`, carried on the event rather than looked up again here.
*/
case 'passengersBoarded': {
const f = facilityAt(s, e.player, e.at)!;
const area = areaOf(s, e.player);
const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
const loaded = f.outboundBox.splice(idx, 1)[0]!;
const tray = s.trays.get(e.trayId);
if (tray && tray.consist[e.coachIndex]) {
s.yards.classificationYard.push(pooled(tray.consist[e.coachIndex]!));
/**
* Stamped with the district that filled it — the chip turned upside down in the tray. These
* passengers may not alight anywhere in this Office Area; the train has to carry them to a
* different one. See `RollingStock.origin` in state.ts.
*/
tray.consist[e.coachIndex] = { ...loaded, origin: seatOf(s, e.player) };
for (const id of area.adOccupancy) {
const tray = s.trays.get(id);
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1;
if (tray && ci >= 0) {
s.yards.classificationYard.push(tray.consist[ci]!);
tray.consist[ci] = loaded;
break;
}
}
f.usedThisStage.porters += 1;
break;
@@ -2362,18 +2152,21 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'passengersDetrained': {
const f = facilityAt(s, e.player, e.at)!;
const tray = s.trays.get(e.trayId);
if (tray && tray.consist[e.coachIndex]) {
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
// which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`.
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
refillDivisionYardIfEmpty(s);
// The arriving coach goes into the red box carrying nothing: the journey it was stamped for
// is over, and the box feeds straight back to a yard through the Freight Agent.
f.inboundBox.push(pooled(tray.consist[e.coachIndex]!));
tray.consist[e.coachIndex] = empty;
const area = areaOf(s, e.player);
for (const id of area.adOccupancy) {
const tray = s.trays.get(id);
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1;
if (tray && ci >= 0) {
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
// which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`.
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
refillDivisionYardIfEmpty(s);
f.inboundBox.push(tray.consist[ci]!);
tray.consist[ci] = empty;
break;
}
}
f.usedThisStage.porters += 1;
break;
@@ -2409,14 +2202,8 @@ export function reduce(s: GameState, e: GameEvent): void {
workTrack(f)[workTrack(f).length - 1] = null;
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
if (ci >= 0) {
s.yards.classificationYard.push(pooled(f.industryTrack.cars[ci]!));
/**
* THE LOAD IS STAMPED WITH THE DISTRICT THAT MADE IT — the chip turned upside down in the
* tray. `laborer.beginUnload` refuses a car stamped with the district it is standing in, so
* this load now has to leave the Office Area on a train before anyone can break it. See
* `RollingStock.origin` in state.ts for the rule and why it is a seat.
*/
f.industryTrack.cars[ci] = { type: e.carType, loaded: true, origin: seatOf(s, e.player) };
s.yards.classificationYard.push(f.industryTrack.cars[ci]!);
f.industryTrack.cars[ci] = { type: e.carType, loaded: true };
}
f.usedThisStage.laborers += 1;
break;
@@ -2901,16 +2688,9 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
/**
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
*
* A train is made up where it is built and only until its consist matches its card. Everything else
* with a Crew Tray — a train working your district, a train halfway across the Division — is
* running, not being assembled.
*
* WHERE that is used to be the whole test: "standing at a Division Point". True while a Division
* Point was the only place to build one, and wrong once an Extra could be started at a Control Point
* or in an Interchange's yard (§7) — those trains were never offered a car and ran empty. The extra
* clause is `beingMadeUp` (state.ts), a flag set on exactly those trays and cleared when they start
* running, rather than a second positional rule: a train STANDING at an Office is usually one that
* arrived, and must not be fillable from the yard.
* A train is made up where it is built, standing at a Division Point, and only until its consist
* matches its card. Everything else with a Crew Tray — a train working your district, a train
* halfway across the Division — is running, not being assembled.
*
* SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be:
* `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard
@@ -2920,7 +2700,7 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
*/
export function isBeingMadeUp(tray: CrewTray): boolean {
if (tray.trainNumber === null) return false;
if (tray.position.at !== 'divisionPoint' && !tray.beingMadeUp) return false;
if (tray.position.at !== 'divisionPoint') return false;
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
if (!profile) return false;
return tray.consist.length < consistSize(profile.consist);
+123 -212
View File
@@ -3,8 +3,8 @@
*
* TRANSCRIBED FROM THE RECOVERED DESIGN FILES (2026-07-30):
* docs/Deck cards2.xlsx — the complete card list and counts
* docs/Trains3.pdf — the train cards
* docs/Mainline Cards.pdf — the Mainline card types, and the two Division Points
* docs/Trains3.pdf — all 22 train cards
* docs/Mainline Cards.pdf — the ten Mainline card types
* docs/tracks.png — card art
*
* This replaced an invented 52-card placeholder. See docs/rules/implications.md for what changed
@@ -60,17 +60,13 @@ export type TrackProfile = {
/**
* TRACK IS IN THE HOME OFFICE DECK, and is drawn and played like any other card.
*
* Every row's dealt count is in `copiesInDeck` below; the sharp curves are dealt zero, see below.
* How many that comes to is `TRACK_IN_DECK`, computed rather than written down — it moves with
* balance work, so a number in this comment would be wrong before long.
*
* An earlier reading made track a separate per-player supply, sitting outside the deck and laid one
* a turn. That came from misreading the sheet's LAST column, headed "Track Per Player" —
* 8/4/4/1/1/4/4 = 26, which is a note about each player's likely share across four players, not a
* second stack of cards. The sheet's own totals settle it: "Sum other 115", "Total track 104", and a
* grand total of 231 once the 12 start cards are counted. 115 + 104 + 12 = 231. Those are figures
* from `docs/Deck cards2.xlsx`, a fixed document, and stay here as the audit trail for the
* transcription — they are not claims about what the game deals today.
* 96 dealt of the sheet's 104 (column B of `docs/Deck cards2.xlsx`) — the 8 sharp curves are dealt
* zero, see below. An earlier reading made track a
* separate per-player supply of 26 pieces, sitting outside the deck and laid one a turn. That came
* from misreading the sheet's LAST column, headed "Track Per Player" — 8/4/4/1/1/4/4 = 26, which is
* a note about each player's likely share of 104 across four players, not a second stack of cards.
* The sheet's own totals settle it: "Sum other 115", "Total track 104", and a grand total of 231
* once the 12 start cards are counted. 115 + 104 + 12 = 231.
*
* It matters well beyond bookkeeping. Track competes for the draw with industry, trains and
* enhancements, so building a district is paid for in cards you did not draw instead — and the
@@ -93,10 +89,10 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
* SHARP CURVES ARE DEALT ZERO COPIES — Jesse's call, and the same treatment as Poling.
*
* The only thing that made them different from an ordinary curve was `moveCost: 2`, and nothing
* ever charged it: every switching move costs exactly 1, hard-coded. So they were geometric
* duplicates of the curves, taking draws from a deck the rebalance already thinks is too diluted.
* They come out rather than having the Move cost built, because a per-card movement cost is a
* change to the Move model and the rebalance can wait.
* ever charged it: every switching move costs exactly 1, hard-coded. So the 8 cards in the deck
* were geometric duplicates of the curves, taking 8 draws from a deck the rebalance already thinks
* is too diluted. They come out rather than having the Move cost built, because a per-card movement
* cost is a change to the Move model and the rebalance can wait.
*
* The rows stay in the catalogue at zero, exactly as Poling does, so the design is still visible
* and the geometry still works if they are ever dealt again.
@@ -107,10 +103,10 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
];
/** Summed from `copiesInDeck` above, never written down — it moves whenever the deck is retuned. */
/** 96 — the sheet's "Total track" of 104, less the 8 sharp curves now dealt at zero. */
export const TRACK_IN_DECK = TRACK_CARDS.reduce((n, t) => n + t.copiesInDeck, 0);
/** Start cards, placed at setup and never shuffled. */
/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */
export const WHISTLE_POST_SUPPLY = 4;
export const LIMITS_SUPPLY = 8;
@@ -120,7 +116,7 @@ export function trackProfile(geometry: TrackGeometry, hand: Hand): TrackProfile
}
// ---------------------------------------------------------------------------
// Offices — the upgrade ladder: Whistle Post, Depot, Station, Terminal
// Offices — Depot 4, Station 2, Terminal 1
// ---------------------------------------------------------------------------
export type OfficeProfile = {
@@ -143,12 +139,13 @@ export type OfficeProfile = {
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
*/
/**
* Office cards. Every tier's `copiesInDeck` was **doubled** against the recovered design — Q12.
* Office cards. `copiesInDeck` was **doubled** (Depot 4→8, Station 2→4, Terminal 1→2) — Q12.
*
* Players always start at a Whistle Post, which has ONE A/D track, so a second arrival is an
* automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew
* a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post.
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post. Escaping needed one of
* 4 Depot cards in 111, roughly a 59% chance across a game's draws.
*
* Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than
* their raw counts imply — Terminal needs all three cards in order. Station and Terminal were
@@ -156,10 +153,9 @@ export type OfficeProfile = {
*
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
* dilutes every other category slightly. Revisit once the victory target is settled and freight is
* carrying its intended share; the right answer may instead be fewer Terminals, a cheaper first
* upgrade, or more A/D capacity at Whistle Post. The counts themselves are in the rows below, which
* is the only place they should be read from.
* dilutes every other category slightly (deck 133 → 140). Revisit once the victory target is
* settled and freight is carrying its intended share; the right answer may instead be fewer
* Terminals, a cheaper first upgrade, or more A/D capacity at Whistle Post.
*/
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
@@ -203,14 +199,13 @@ export type IndustryProfile = {
/**
* Industry density (Gap 12). The recovered sheet lists 9 industries in a 115-card deck; the
* prototype ran 10 in 52. At the sheet's density a game saw 1.6 Freight Facilities, freight was 10%
* of gross revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the
* point of the game, effectively never ran.
* prototype ran 10 in 52. At 9-in-115 a game saw 1.6 Freight Facilities, freight was 10% of gross
* revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the point of
* the game, effectively never ran.
*
* Each industry's `copies` is TRIPLED against the sheet, which restores roughly the prototype's
* Each industry's `copies` is TRIPLED, giving 27 in 133. That restores roughly the prototype's
* ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the
* lockout structure are unchanged, because every kind scales by the same factor. The multiplier is
* the decision; the resulting totals are in the rows below and move with every retune.
* lockout structure are unchanged, because every kind scales by the same factor.
*/
/**
* LOCKOUTS, from the sheet's "Lockouts" column verbatim:
@@ -236,51 +231,42 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
/**
* OUTBOUND ONLY. A Refinery ships oil out and receives nothing; reported from playtesting and
* confirmed by Jesse (v0.4.9e): "only ships out tanks, does not receive anything".
* BOTH DIRECTIONS, per the card reference — this was `outbound` and it contradicted the rules.
*
* It was briefly `flow: 'both'`, on the reading that "'Freight House' is not a card — it is the
* collective term for a freight facility that loads *and* unloads, the Grocer's Warehouse and the
* Oil Refinery", which made §9.3's "Passenger Facilities and Freight Houses permit cars to move
* each direction" name exactly those two. That premise is dead: `glossary.md` and
* `rules-v0.2.md` corrected the Freight House to a card of its own, dealt like any other industry,
* so §9.3 names the Freight House and nothing else, and card-reference.md's "Both" column loses
* the only argument it had.
* `card-reference.md`: "Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4", and in prose — "'Freight
* House' is not a card. It is the collective term for a freight facility that loads *and* unloads
* — the Grocer's Warehouse and the Oil Refinery." §9.3's "Passenger Facilities and Freight Houses
* permit cars to move each direction" therefore names exactly these two, and the engine had both
* of them one-way.
*
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
* the game with no way to raise the direction it is supposed to use half its capacity on.
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
* The consequence was silent: `usableGrant` drops a Modifier's grant on a direction its host
* cannot use, so every +1 inbound beside a Refinery went nowhere.
*
* The base numbers stay at the engine's own scale (1 per direction it allows) rather than the card
* reference's 2/2 — every industry here is scaled down the same way, Mine Tipple included, and
* raising one of them alone would be a balance change rather than a correction. Flagged in TODO.
*/
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
/**
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
* BOTH DIRECTIONS — see the Refinery above; "Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3".
*
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
* Home Deck sheet says so outright — "a bonus beside a facility that cannot use its direction is
* not usable", naming the Truck Dock's inbound bonus beside the outbound-only Packing Sheds as the
* other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two
* that do work here.
* Reported from play: "grocer's warehouse didn't get extra outbound slot for truck dock." It could
* not: the Truck Dock printed +1 outbound at the time and this was `flow: 'inbound'`, so the grant
* was dropped on a direction the facility did not have. The same trap still swallows an Ice House
* set beside a Grocer's that has been left one-way.
*
* `TODO.md` had previously recorded this as "checked, and there is no bug" on the reasoning that a
* Grocer's is inbound-only. That premise was the bug.
*/
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
];
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
export const FREIGHT_PROFILES = INDUSTRY_PROFILES;
/**
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction".
*
* ONE CARD ANSWERS TO THIS NOW: the Freight House itself. It was briefly three, while the Refinery
* and the Grocer's Warehouse were also `both` on a reading of the term the glossary has since
* corrected — a Freight House is a card, not a collective noun. Kept as a predicate on `flow`
* rather than a comparison against the kind, because it is the DIRECTION §9.3 is talking about.
*/
/** §9.3 — the collective term for an industry that both loads and unloads. */
export function isFreightHouse(p: IndustryProfile): boolean {
return p.flow === 'both';
}
@@ -292,7 +278,7 @@ export function industryProfile(kind: FreightKind): IndustryProfile {
}
// ---------------------------------------------------------------------------
// Modifiers — passenger and industry, each tied to specific hosts
// Modifiers — 17 industry + 6 passenger, each tied to specific hosts
// ---------------------------------------------------------------------------
export type ModifierKind =
@@ -361,7 +347,7 @@ export function modifierProfile(kind: ModifierKind): ModifierProfile {
}
// ---------------------------------------------------------------------------
// Trains — named, with a speed class and individual printed rules
// Trains — 22 cards, named, with speed class and individual rules
// ---------------------------------------------------------------------------
export type TrainSpeed = 'fast' | 'slow';
@@ -425,18 +411,11 @@ function pair(odd: number, name: string, speed: TrainSpeed, consist: ConsistSpec
}
export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
/**
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
* still print the old numbers, so `docs/rules/card-reference.md` is the place that now carries
* what the cards say.
*/
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 },
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 3, caboose: 0 },
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
...pair(3, 'Express', 'fast', { freight: 2, coach: 0, caboose: 0 },
{ oneFreightPerLocation: true, expedite: true, note: 'May drop or pick up one freight car at every location.' }),
...pair(5, 'The Sparrow', 'fast', { freight: 0, coach: 3, caboose: 0 },
...pair(5, 'The Sparrow', 'fast', { freight: 0, coach: 2, caboose: 0 },
{ noSwitching: true, expedite: true }),
...pair(7, 'Local', 'slow', { freight: 1, coach: 1, caboose: 0 },
{ coachStaysOnStationTrack: true, note: 'Maximum one freight, one coach.' }),
@@ -464,18 +443,11 @@ export const EXTRA_TRAINS: readonly TrainProfile[] = [
export const ALL_TRAINS: readonly TrainProfile[] = [...TIMETABLED_TRAINS, ...EXTRA_TRAINS];
/**
* §2.3 — ODD RUNS WEST, EVEN RUNS EAST. The number is the direction for a TIMETABLED train.
* §2.3 — ODD RUNS WEST, EVEN RUNS EAST. The number is the direction, for an Extra as much as for a
* timetabled train, and the Division Point it starts at is therefore the one it runs away from.
*
* NOT for an Extra any more. Extras print `direction: 'playerChoice'` and now mean it (Jesse's
* ruling, superseding "the number decides, like everything else on the timetable"): the player who
* played the card picks where it starts, and the start decides the direction — a Crew Tray placed
* at the Western Division Point runs east and one at the Eastern runs west, because the alternative
* is a train that leaves the Division on its first move having crossed nothing. Where the start is
* NOT an end of the Division — an Interchange, or a Control Point — both ways are real runs and the
* player says which. `resolveExtraStart` in apply.ts is where that happens.
*
* Still read for an Extra in one place: replaying a save written before the choice existed, where
* `newTrain.startExtra` carries only the legacy `atSeat` field.
* Extras print `direction: 'playerChoice'`, which the engine read as "always eastbound from the West
* Division Point". Jesse's ruling: the number decides, like everything else on the timetable.
*/
export function runDirection(trainNumber: number): Direction {
return trainNumber % 2 === 0 ? 'east' : 'west';
@@ -546,29 +518,6 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
{ kind: 'interchange', name: 'Interchange', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
];
/**
* THE MAINLINE DECK AS PRINTED, dealt WITHOUT replacement.
*
* `MAINLINE_PROFILES` above is a list of card TYPES, one entry each. It is not the deck, and setup
* used it as one: `buildDivision` drew uniformly from those types with replacement, which made two
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
* inventory, transcribed in `docs/StationMaster-Mainline-Deck-v0.4.5.md`, which had already flagged
* the mismatch as needing correction.
*
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
* Interchange is on the board" has to mean a card there is at most one of, not a type the deal can
* hand out twice.
*
* The two Division Point cards in that inventory are not here — they are the fixed ends of the
* Division, laid by `buildDivision` itself rather than drawn. Plains appears twice because the deck
* prints it twice; every other type once. The list below is the deck, so it is not counted here.
*/
export const MAINLINE_DECK: readonly MainlineKind[] = [
'plains', 'plains', 'curves', 'hilly', 'heavyGrade',
'doubleTrack', 'uncontrolledSiding', 'tunnel', 'trestle', 'interchange',
];
/**
* How many Stages a train needs to cross a Mainline card.
*
@@ -735,9 +684,9 @@ export type SimpleCard = {
* The opponent-directed card this exists SOLELY to answer.
*
* A defence with nothing to defend against is a dead draw, exactly as the attack itself would be.
* The Space-use and Action cards are held out of every deck until they are implemented (Q6), and
* these go with them — named here rather than in a list somewhere else so the pairing is visible
* on the card, and so they come back together when their attacker does.
* The 22 Space-use and Action cards are held out of every deck until they are implemented (Q6),
* and these go with them — named here rather than in a list somewhere else so the pairing is
* visible on the card, and so they come back together when their attacker does.
*/
answers?: string;
};
@@ -783,48 +732,39 @@ export type EnhancementRule = {
* opponent-directed card that a solitaire deck does not contain (Q6).
* - `unbuilt` — nothing reads it at all. The effect is recorded here and not yet written.
*
* Most are live. Each row below cites the file that reads it — the row is the answer, and no
* tally is kept here, because a tally is one more thing to forget when a card is implemented.
*
* The first attempt at this table got FIVE of the ten wrong: it was filled in by grepping for four
* helper function names and reading "no match" as "no implementation", when Interlocking, Yard
* Office, Small Yard and ABS Signals are all read directly by key — and all four are covered by
* tests in `enhancements.test.ts` that were passing the whole time. The result was a tooltip
* telling players that four working cards did nothing, which is worse than the bare label it
* replaced.
* Seven of the ten are live. Each row below cites the file that reads it, because the first
* attempt at this table got FIVE of the ten wrong: it was filled in by grepping for four helper
* function names and reading "no match" as "no implementation", when Interlocking, Yard Office,
* Small Yard and ABS Signals are all read directly by key — and all four are covered by tests in
* `enhancements.test.ts` that were passing the whole time. The result was a tooltip telling players
* that four working cards did nothing, which is worse than the bare label it replaced.
*
* KEEP THIS HONEST, AND CHECK THE CITATION. Implementing one of these means changing its value in
* the same commit; otherwise the card goes on apologising for something it now does. It is data
* rather than something derived because "is this key read anywhere" is not a question the type
* system can answer — but a claim here without a reference beside it is a claim nobody checked.
*
* CITE THE FUNCTION, NEVER THE LINE NUMBER. Every one of these rows once carried a `file.ts:NNN`
* and every one of them had rotted — `apply.ts:405` for Small Yard was pointing four hundred lines
* short by the time anyone looked. A line number is a citation that decays silently on the next
* edit anywhere above it, which is the opposite of what this note is for.
* system can answer — but a claim here without a file reference beside it is a claim nobody checked.
*/
effect: 'live' | 'dormantSolo' | 'unbuilt';
};
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
// Holds an arrival at the Limits instead of colliding when the Office is full — `arriveAtOffice`.
// Holds an arrival at the Limits instead of colliding when the Office is full — advance.ts:770.
{ key: 'interlocking', placement: 'runningTrackStraight', effect: 'live' },
// Wired in `isProtectedFromDerail`, but it answers Derail, an Action card the solo deck omits (Q6).
// Wired at apply.ts:1738, but it answers Derail, an Action card the solitaire deck omits (Q6).
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking', effect: 'dormantSolo' },
// Diverts a coachless arrival away from the Train Order Office — `arriveAtOffice`.
// Diverts a coachless arrival away from the Train Order Office — advance.ts:750.
{ key: 'yardOffice', placement: 'secondaryTrackStraight', effect: 'live' },
// Lets a consist be re-ordered for one Move — `check`'s `switch.sortConsist` case.
// Lets a consist be re-ordered for one Move — apply.ts:405.
{ key: 'smallYard', placement: 'secondaryTrackStraight', effect: 'live' },
// Wired in `watertowersRemovable`, but it removes a Watertower, a Space-use card the solo deck omits.
// Wired at apply.ts:1743, but it removes a Watertower, a Space-use card the solo deck omits.
{ key: 'waterColumn', placement: 'runningTrackStraight', effect: 'dormantSolo' },
// The only one with NO code path at all: nothing anywhere reads `overpass`.
{ key: 'overpass', placement: 'onCard', effect: 'unbuilt' },
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4, effect: 'live' },
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8, effect: 'live' },
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12, effect: 'live' },
// Stored on the Mainline node rather than in `enhancements[]` — written by `reduce`'s
// `enhancementPlaced`, read in `moveTrain` (no rear-ending) and `evaluateClearance` (the follower
// holds instead of being ruled on).
// Stored on the Mainline node rather than in `enhancements[]` — apply.ts:1461, read at
// advance.ts:599 (no rear-ending) and advance.ts:721 (the follower holds instead of being ruled on).
{ key: 'absSignals', placement: 'mainlineCard', effect: 'live' },
];
@@ -915,15 +855,11 @@ export type StockSupply = { type: CarType; loaded: number; empty: number };
/**
* NOT in the recovered files — still the provisional figure.
*
* Scaled up alongside the Gap 12 industry increase. The rule it was set by, rather than the numbers
* it produced: worst-case demand for a car type is every copy of every industry that uses it, in
* play at full capacity, and the supply must cover that — a Division Yard that runs dry starves the
* freight loop the increase exists to feed. Lockouts and district size mean the worst case cannot
* actually occur, so this carries deliberate headroom. RE-DERIVE IT FROM `INDUSTRY_PROFILES`
* whenever industry copies move; a figure written here would not survive the next retune.
*
* Coaches are the type this rule does NOT cover, because no industry asks for one — their demand
* comes from passenger work, and Gitea#2 is the open report that the supply is short (see TODO.md).
* Scaled up alongside the Gap 12 industry increase. Worst-case demand (every copy of every
* industry in play at full capacity) is boxcar 15, hopper 12, tank 9, reefer 6; the supply must
* cover that, since a Division Yard that runs dry starves the freight loop the increase exists to
* feed. Lockouts and district size mean the worst case cannot actually occur, so this carries
* deliberate headroom.
*/
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
{ type: 'coach', loaded: 8, empty: 8 },
@@ -940,7 +876,13 @@ export function mainlineCardCount(players: number): number {
return players + 1;
}
/** Provisional; not in the recovered files. */
/**
* Sourced, not provisional: `rules-v0.2.md`:339, "There are only a limited number of Crew Trays
* and engine pieces [Gap 4b: player count + 3]." The rules tie Crew Trays and engine pieces
* together as ONE combined resource, not two independently-tracked supplies — an engine is never
* conjured separately from the tray it rides in, and the game has no state for "an engine with no
* tray" or "a tray with no engine". `state.ts`'s `freeTrays` pool already IS the engine supply.
*/
export function crewTrayCount(players: number): number {
return players + 3;
}
@@ -951,11 +893,11 @@ export const STAGES_PER_SHIFT = 3;
export const HAND_LIMIT = 3;
/**
* The split opening deal — track cards and others, from two separately shuffled piles (`setup.ts`).
* One of the three `StartingHand` options below, not the only one any more.
* The split opening deal: 3 track cards and 3 others, from two separately shuffled piles
* (`setup.ts`). One of the three `StartingHand` options below, not the only one any more.
*
* Deliberately over the hand limit: the first turn is spent choosing which district you can afford
* to build. The two constants below are the deal.
* Six against a limit of three on purpose — the first turn is spent choosing which district you can
* afford to build.
*/
export const OPENING_TRACK = 3;
export const OPENING_OTHER = 3;
@@ -989,16 +931,10 @@ export const OPENING_DEALS: Readonly<Record<StartingHand, { any: number; track:
/**
* WHAT THE THREE WORKING ECONOMIES PAY.
*
* Balance is the open problem in this game, and the way to settle it is to play it at several
* settings rather than to keep re-deriving it. So the three rates are dials, set when the game is
* dealt and fixed for its duration.
*
* DO NOT WRITE A CURRENT BALANCE FIGURE HERE. This said "the developer bot averages 7.0 Revenue
* against a target of 20" long after that stopped being true: 7.0 was measured while
* `trainPerTransit` still defaulted to 1, and the paragraph below explains that this very setting
* was then defaulted to 0 for being worth ~5.4 of it. Re-measured 2026-08-22 over 400 games at the
* current defaults, the developer bot means about ZERO. Run `node src/sim/harness.ts 400 standard`
* for today's number rather than trusting one written here.
* Balance is the open problem in this game — the developer bot averages 7.0 Revenue against a target
* of 20, of which most came from traffic nobody had to work — and the way to settle it is to play it
* at several settings rather than to keep re-deriving it. So the three rates are dials, set when the
* game is dealt and fixed for its duration.
*
* `passengerPerCoach` and `freightPerLoad` each pay on BOTH halves of their cycle: a coach pays when
* it is boarded and again when it is detrained, a load pays when it is made up outbound and again
@@ -1015,30 +951,10 @@ export type RevenueRules = {
trainPerTransit: number;
};
/**
* WHERE AN EXTRA MAY BE STARTED — a setting, because the table disagrees about it.
*
* §7 gives an Extra "either Division Point", and Jesse's ruling adds the Interchange to that base
* set: both are places on the shared Division that belong to nobody, so neither favours a seat.
* Starting one inside a player's own district is the part that does, which is what this dials.
*
* - `divisionPointsOnly` — the Division Points and the Interchange. No Office start at all.
* - `ownOffice` — those, plus a Control Point in the district of the player who played the card.
* - `anyOffice` — those, plus a Control Point in ANY player's district.
*
* A Whistle Post never qualifies however this is set: an Office has to be a Control Point to start
* an Extra, which is part of what upgrading buys (§11).
*/
export type ExtraStartRule = 'divisionPointsOnly' | 'ownOffice' | 'anyOffice';
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules; extraStart: ExtraStartRule };
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules };
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
export type HouseRuleOverrides = {
startingHand?: StartingHand;
revenue?: Partial<RevenueRules>;
extraStart?: ExtraStartRule;
};
export type HouseRuleOverrides = { startingHand?: StartingHand; revenue?: Partial<RevenueRules> };
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
export const REVENUE_MIN = 0;
@@ -1047,10 +963,6 @@ export const REVENUE_MAX = 5;
export const DEFAULT_HOUSE_RULES: HouseRules = {
startingHand: 'threeRandom',
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 },
// `anyOffice` is what the engine did before the setting existed, so a game dealt without naming
// it plays the way it always has. Jesse's call, so the 0.4.9 playtest line does not change under
// its testers in the middle of a bugfix release.
extraStart: 'anyOffice',
};
/**
@@ -1065,8 +977,6 @@ export const DEFAULT_HOUSE_RULES: HouseRules = {
export const LEGACY_HOUSE_RULES: HouseRules = {
startingHand: 'threeTrackThreeOther',
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
// An Extra could always be started at a Control Point in these games, in any district.
extraStart: 'anyOffice',
};
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
@@ -1085,7 +995,6 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
freightPerLoad: clamp(rev.freightPerLoad, d.revenue.freightPerLoad),
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
},
extraStart: given.extraStart ?? d.extraStart,
};
}
@@ -1095,18 +1004,16 @@ export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string
{ value: 'sixRandom', label: 'Six random cards' },
{ value: 'threeTrackThreeOther', label: 'Three random track and three random non-track cards' },
];
/** What the dialog calls each `ExtraStartRule`, in the order it offers them. */
export const EXTRA_START_LABELS: readonly { value: ExtraStartRule; label: string }[] = [
{ value: 'divisionPointsOnly', label: 'Division Points and the Interchange only' },
{ value: 'ownOffice', label: 'Also the playing player\u2019s own Control Point' },
{ value: 'anyOffice', label: 'Also any player\u2019s Control Point' },
];
export const MAX_CONSIST = 4;
export const MOVES_PER_LOCAL_OPS = 6;
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
export const LABORER_ACTIONS_PER_LOAD = 4;
export const COLLISION_PENALTY = 5;
export const COLLISION_FLOOR_PER_DAY = 3;
/** Suggested defaults for `GameConfig`'s collision floors — see `state.ts`'s `GameConfig` doc. */
export const DEFAULT_MAX_COLLISIONS_PER_DAY = 3;
export const DEFAULT_MAX_COLLISIONS_TOTAL = 5;
/** Suggested default for `GameConfig.days`, all modes. */
export const DEFAULT_DAYS = 5;
/**
* Q3 — an expedited train left parked off the Office when a Mainline Phase begins is a Station
* Master fault: the train was not kept ready to highball the moment the Subdivision allowed it.
@@ -1114,18 +1021,27 @@ export const COLLISION_FLOOR_PER_DAY = 3;
*/
export const EXPEDITE_FAULT_PENALTY = 1;
/**
* The suggested default for `GameConfig.minCombinedRevenue` — a caller computes this before
* submitting a config; the engine itself never calls it (`state.ts`'s `GameConfig` doc explains why).
*/
export function collectiveRevenueFloor(players: number, days: number): number {
return 3 * players * days;
}
/**
* `GameLength` IS NOT PART OF `GameConfig` ANY MORE (2026-08-20) — `days` is a free variable there
* now, replacing the old `target`-bearing preset. This survives only as a convenience for sim/CLI
* tooling (`harness.ts`, `compare.ts`, `replay.ts`) that still wants a `short`/`standard`/`campaign`
* argument instead of a raw day count; `lengthProfile()` just resolves that argument to `days`.
*/
export type GameLength = 'short' | 'standard' | 'campaign';
export type LengthProfile = { length: GameLength; target: number; days: number };
export type LengthProfile = { length: GameLength; days: number };
/** Provisional and now unvalidated — the balance run they came from used a placeholder ruleset. */
export const LENGTH_PROFILES: readonly LengthProfile[] = [
{ length: 'short', target: 10, days: 3 },
{ length: 'standard', target: 20, days: 5 },
{ length: 'campaign', target: 45, days: 10 },
{ length: 'short', days: 3 },
{ length: 'standard', days: 5 },
{ length: 'campaign', days: 10 },
];
export function lengthProfile(length: GameLength): LengthProfile {
@@ -1140,8 +1056,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
/**
* Cards that can only be played AT another player (Q6). In a solitaire game they have no legal
* target, so they are removed from the deck rather than sitting in hand as dead draws — which, when
* this was written, was very nearly a fifth of the deck.
* target, so they are removed from the deck rather than sitting in hand as 19% dead draws.
*/
export const OPPONENT_ONLY_CATEGORIES: readonly string[] = ['spaceUse', 'action'];
@@ -1149,10 +1064,8 @@ export function isOpponentOnly(category: string): boolean {
return OPPONENT_ONLY_CATEGORIES.includes(category);
}
/**
* How many cards `DEFENCE_ONLY_CARDS` accounts for. Summed from the rows rather than written down:
* which cards qualify is `SimpleCard.answers`, and the total moves whenever one is added.
*/
/** How many cards `DEFENCE_ONLY_CARDS` accounts for — 7: two Facing Point Locks of each kind, two
* Water Columns and one Overpass. */
export const DEFENCE_ONLY_COPIES =
ENHANCEMENT_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0) +
MAINLINE_MODIFIER_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0);
@@ -1175,19 +1088,17 @@ export function deckComposition(): { category: string; count: number }[] {
/**
* The whole CATALOGUE, including cards not currently dealt. Not the size of any deck in play — see
* `SOLITAIRE_DECK_SIZE` below, which is what `buildDeck` actually deals. (This pointed at a
* `DEALT_DECK_SIZE` that has never existed.)
* `DEALT_DECK_SIZE`, which is what `buildDeck` actually returns.
*/
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
/**
* The deck actually dealt, in every mode: the catalogue less the opponent-directed cards, less the
* defensive cards that exist only to answer them.
* The deck actually dealt, in every mode: the catalogue less the 22 opponent-directed cards.
*
* Named for solitaire because Q6 dropped them there first, and kept under that name because the
* number is the same either way. They are out of the competitive deck too until they are
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make a
* meaningful share of draws reject outright. See `buildDeck`.
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make ~9%
* of draws reject. See `buildDeck`.
*/
export const SOLITAIRE_DECK_SIZE =
deckComposition()
+5 -24
View File
@@ -19,8 +19,8 @@
* reconstruct the whole board to draw one frame.
*/
import type { CarType, Direction, OfficeTier } from './content.ts';
import type { ExtraStart, LocalOpsOption } from './intents.ts';
import type { CarType, OfficeTier } from './content.ts';
import type { LocalOpsOption } from './intents.ts';
import type { CardId, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
export type GameEvent =
@@ -154,34 +154,15 @@ export type GameEvent =
*/
| { type: 'trainCompleted'; trainNumber: number; isExtra: boolean; side: 'east' | 'west'; consist: RollingStock[] }
/** An Extra took a Crew Tray and started its run — at a Division Point, or at a Control Point. */
/**
* Carries the RESOLVED start and direction (`resolveExtraStart`), not the raw intent fields, so
* the reducer never re-answers a question `check` already answered — the same shape as
* `passengersBoarded` carrying its tray and coach index.
*/
| {
type: 'extraStarted';
player: PlayerIndex;
trainNumber: number;
at: ExtraStart;
direction: Direction;
}
| { type: 'extraStarted'; player: PlayerIndex; trainNumber: number; atSeat: SeatIndex | null }
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
// -- load / unload
/**
* `trayId` and `coachIndex` name the TRAIN and the COACH the Porter worked, rather than leaving the
* reducer to find them again — the same lesson as `unloadBegan`'s `carIndex` below. Re-deriving
* "the first empty coach on the first train at the Office" is how two trains standing at one
* station both answered to one roster chip (v0.4.9d playtest), and how a coach the player had not
* chosen got filled. Required, not optional: an event is a fact, and a fact that has to be looked
* up against live state cannot render standalone in a replay.
*/
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord }
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord }
| { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType }
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
+8 -74
View File
@@ -13,17 +13,6 @@ import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.
export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent';
/**
* Where an Extra is placed when it is started (§7).
*
* `mainline` names a node index in `division.nodes` and is only ever an Interchange; `office` names
* a SEAT, which is what an Office Area belongs to, and only ever a Control Point.
*/
export type ExtraStart =
| { kind: 'divisionPoint'; side: Direction }
| { kind: 'mainline'; node: number }
| { kind: 'office'; seat: SeatIndex };
export type Intent =
| { type: 'localOps.choose'; option: LocalOpsOption }
// -- switch (§6.1, Appendix A)
@@ -85,31 +74,12 @@ export type Intent =
| { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean }
| { type: 'newTrain.passCar'; trayId: TrayId }
/**
* §7 — "the player who played the card may place the Crew Tray at EITHER Division Point for
* immediate departure", plus Jesse's ruling on where else and which way.
*
* The number does not decide an Extra's direction — the START does. Either Division Point may be
* chosen and the train runs away from it (west end runs east, east end runs west, since the other
* reading is a train that leaves the Division having crossed nothing). At an Interchange or a
* Control Point, which are in the middle of the railroad, both ways are real runs and `direction`
* says which; it is required there and ignored at a Division Point.
*
* WHICH STARTS ARE OFFERED is the `extraStart` house rule (content.ts) — Division Points and the
* Interchange always, Offices by setting.
*
* `atSeat` IS LEGACY AND WRITE-ONCE. Saves written before this choice existed carry only that
* field: `null` meant "the Division Point this train's number sends it to" and a seat meant that
* Office, both running in the number's direction. `start` absent is exactly what those saves said,
* so they replay unchanged; everything written from now on carries `start` and `atSeat` is
* omitted. `resolveExtraStart` (apply.ts) is the single place that reads either.
* §7 — "the player who played the card may place the Crew Tray in either division point for
* immediate departure", extended by Jesse: an Extra starts at the Division Point its NUMBER sends
* it to (odd runs west, even east, exactly as a timetabled train), or at any Control Point — any
* Office above a Whistle Post — at the player's choice. `atSeat` null means the Division Point.
*/
| {
type: 'newTrain.startExtra';
trainNumber: number;
atSeat?: SeatIndex | null;
start?: ExtraStart;
direction?: Direction;
}
| { type: 'newTrain.startExtra'; trainNumber: number; atSeat: SeatIndex | null }
/** Q9 — run a second, identical section behind a train that is due out this Stage. */
| { type: 'newTrain.secondSection'; trainNumber: number }
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
@@ -131,18 +101,8 @@ export type Intent =
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
| { type: 'redFlag.play' }
// -- Load/Unload Phase (§9)
/**
* `trayId` names the train the Porter works — reported from playtesting v0.4.9d as "operating two
* trains in a station, the select button does not work: regardless of which you pick, it is always
* one train, not the other". It was: neither intent carried a train, so the reducer took the first
* one on the A/D tracks and the roster chip the player had clicked changed nothing but the drawing.
*
* OPTIONAL, like `switch.move`'s `via` and for the same reason: intents are the canonical record
* `undo` and every save replay against, and absent means what it has always meant — the first
* eligible train at the Office.
*/
| { type: 'porter.board'; at: GridCoord; trayId?: TrayId }
| { type: 'porter.detrain'; at: GridCoord; trayId?: TrayId }
| { type: 'porter.board'; at: GridCoord }
| { type: 'porter.detrain'; at: GridCoord }
/** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */
| { type: 'laborer.startLoad'; at: GridCoord }
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
@@ -239,17 +199,6 @@ export type RejectionCode =
* the Laborers can move it out of the box.
*/
| 'NO_EMPTY_CAR_SPOTTED'
/**
* §9 (Jesse's ruling, v0.4.9e) — freight or passengers loaded anywhere in an Office Area may not
* be unloaded anywhere in that same Office Area. The load has to be carried out of the district by
* a train first; a Freight House may not break the load it just made, and passengers may not
* detrain at the platform they boarded from.
*
* Distinct from the other refusals because the car IS loaded, the Laborer IS free and the boxes
* ARE clear: the only thing wrong with it is where it came from, and a player looking at a loaded
* boxcar standing on their own industry track deserves to be told that rather than "wrong car".
*/
| 'LOADED_IN_THIS_DISTRICT'
| 'NO_PORTERS_HERE'
| 'NO_PASSENGERS_WAITING'
| 'NO_EMPTY_COACH'
@@ -259,22 +208,7 @@ export type RejectionCode =
| 'NOT_A_CONTROL_POINT'
| 'NO_EXTRA_PENDING'
| 'NO_FREE_TRAY'
| 'NO_FREE_AD_TRACK'
// -- §7, where an Extra may be started (`resolveExtraStart`)
| 'NO_SUCH_DIVISION_POINT'
/** Only the Interchange has a yard an Extra can be made up in. */
| 'NOT_AN_INTERCHANGE'
/** In the middle of the railroad both ways are real runs, so the intent has to say which. */
| 'NO_DIRECTION_CHOSEN'
/** The `extraStart` house rule is `divisionPointsOnly`. */
| 'OFFICE_STARTS_NOT_ALLOWED'
/** The `extraStart` house rule is `ownOffice` and this is somebody else's district. */
| 'NOT_YOUR_OFFICE'
/**
* §6.2, Jesse's ruling (Gitea#6) — a train card is never discarded. Hold it as long as you like;
* the only way it leaves your hand is onto the timetable.
*/
| 'TRAINS_ARE_NEVER_DISCARDED';
| 'NO_FREE_AD_TRACK';
export type Rejection = { code: RejectionCode; message: string };
+8 -34
View File
@@ -13,7 +13,7 @@
*/
import type { CarType, Hand, TrackGeometry } from './content.ts';
import { enhancementRule, mainlineProfile } from './content.ts';
import { enhancementRule } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
@@ -271,28 +271,14 @@ function newTrainCandidates(s: GameState): Intent[] {
out.push({ type: 'newTrain.secondSection', trainNumber: due });
}
/**
* WHERE A PENDING EXTRA MAY START (§7, Jesse's ruling) — every candidate offered, with `check`
* doing the filtering, so "is this a Control Point" and "does the house rule allow it" have one
* implementation each rather than two.
*
* BOTH Division Points, not the one the number dictates: an Extra's direction comes from where it
* is placed. In the middle of the railroad — an Interchange, a Control Point — both ways are real
* runs, so those are offered twice, once per direction.
* Where a pending Extra starts: its own Division Point, decided by its number, or any Control
* Point. `check` refuses a Whistle Post and a full Office, so every seat is offered and the rules
* do the filtering — one implementation of "is this a Control Point", not two.
*/
for (const trainNumber of s.pendingExtras) {
for (const side of ['west', 'east'] as const) {
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'divisionPoint', side } });
}
for (const [node, n] of s.division.nodes.entries()) {
if (n.kind !== 'mainline' || !mainlineProfile(n.card).sortsCars) continue;
for (const direction of ['west', 'east'] as const) {
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'mainline', node }, direction });
}
}
out.push({ type: 'newTrain.startExtra', trainNumber, atSeat: null });
for (const seat of s.officeAreas.keys()) {
for (const direction of ['west', 'east'] as const) {
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'office', seat }, direction });
}
out.push({ type: 'newTrain.startExtra', trainNumber, atSeat: seat });
}
}
return out;
@@ -302,21 +288,9 @@ function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
const out: Intent[] = [];
const area = areaOf(s, player);
/**
* ONE OPTION PER TRAIN STANDING AT THE OFFICE, not one per square.
*
* Reported from playtesting v0.4.9d: "operating two trains in a station, the select button does
* not work — regardless of which you pick, it is always one train, not the other". There was only
* ever ONE `board passengers` button, because the intent carried no train; the roster chip chose
* what the board drew and nothing else. Now each eligible train is its own candidate, and `check`
* filters the ones whose card, consist or passengers rule them out.
*/
const traysHere = area.adOccupancy.filter((id) => s.trays.has(id));
for (const coord of facilityCoords(s, player)) {
for (const trayId of traysHere) {
out.push({ type: 'porter.board', at: coord, trayId });
out.push({ type: 'porter.detrain', at: coord, trayId });
}
out.push({ type: 'porter.board', at: coord });
out.push({ type: 'porter.detrain', at: coord });
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
if (f) {
out.push({ type: 'laborer.startLoad', at: coord });
+19 -32
View File
@@ -17,7 +17,7 @@ import {
MODIFIER_PROFILES,
OFFICE_PROFILES,
OPENING_DEALS,
MAINLINE_DECK,
MAINLINE_PROFILES,
houseRules,
mainlineProfile,
TRACK_CARDS,
@@ -53,13 +53,16 @@ export type SetupOptions = {
};
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
export function buildDeck(mode: GameConfig['mode'] = 'competitive', pvpCardsAllowed = false): Card[] {
/**
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK FOR NOW, not just the solitaire one.
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK REGARDLESS OF `pvpCardsAllowed`, for now.
*
* Q6 took them out of solitaire because they have no legal target in a one-player game. They are
* out of the competitive deck too because `checkPlay` answers both categories `NOT_IMPLEMENTED`:
* leaving them in would make ~9% of draws reject outright, which is worse than not dealing them.
* `pvpCardsAllowed` (`GameConfig`, wired 2026-08-20) is the player-facing toggle, but ANDing it
* with `cardsImplemented` keeps it inert until Phase 5 actually builds the cards — flipping
* `pvpCardsAllowed` on today must not turn a working game into one where ~9% of draws reject.
*
* AND SO ARE THE SEVEN CARDS THAT ANSWER THEM — see below. They used to be dealt and sit dormant,
* which is the same dead draw by another name. Recorded in TODO.md as multiplayer work.
@@ -68,7 +71,8 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
* for these two categories today.
*/
void mode;
const opponentCardsInDeck = false;
const cardsImplemented = false;
const opponentCardsInDeck = pvpCardsAllowed && cardsImplemented;
const cards: Card[] = [];
let n = 0;
const push = (kind: Card['kind']): void => {
@@ -220,39 +224,21 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
* placed between each player"), which is what gives the Division its terrain and therefore its
* crossing times.
*/
/**
* THE MAINLINE CARDS ARE DEALT FROM A DECK, NOT ROLLED.
*
* `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so
* a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the
* deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory
* (`docs/StationMaster-Mainline-Deck-v0.4.5.md`, which flagged this as needing correction), and the
* deal is now a deal: take cards out of it and do not put them back.
*
* The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is
* on the board" only reads as a rule if the board can hold at most one.
*
* A Division needs `players + 1` cards, so four players draw five from ten and the deck is never
* close to exhausted; the throw is there because a silent short Division would be very hard to see.
*/
function buildDivision(players: number, rng: Rng): DivisionNode[] {
const nodes: DivisionNode[] = [];
const deck = [...MAINLINE_DECK];
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
const mainline = (): DivisionNode => {
if (deck.length === 0) throw new Error('the Mainline deck ran out — too many players for it');
const card = deck.splice(rng.nextInt(deck.length), 1)[0]!;
const card = kinds[rng.nextInt(kinds.length)]!;
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
// The Heavy Grade card says "Player sets orientation", but setup has no decision point yet —
// createGame is synchronous and returns a ready state. Rolled for now so the orientation is at
// least deterministic and varies between games; it should become a real player choice when
// setup gains an interactive phase. See implications.md §10 Q11.
if (mainlineProfile(card).speed.kind === 'grade') {
/**
* TEMPORARY, and flagged in TODO. The card prints "(Up)" and "Player sets orientation", so
* which way a Heavy Grade climbs is the player's decision — but it is dealt during setup, and
* setup has no decision point at all. Rolled from the seed until it gains one, because the
* orientation MATTERS: it decides which direction climbs, and therefore what a Brakeman or
* Helpers card is worth.
* SETTLED, not provisional (v0.5.0, Jesse's call) — this overrides the card's own printed
* "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two
* players (§4.2), or beyond an end Division Point next to one — never inside one player's own
* district — so whichever direction climbs advantages one neighbour over the other, and there
* is no single player who owns that call fairly. Orientation is rolled from the seed instead,
* identically for solitaire and multiplayer, and this is not expected to change when setup
* eventually gains an interactive phase for other decisions. See implications.md §10 Q11.
*/
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
}
@@ -333,7 +319,7 @@ export function createGame(opts: SetupOptions): GameState {
*/
const rules = houseRules(config);
const deal = OPENING_DEALS[rules.startingHand];
const deck = buildDeck(config.mode);
const deck = buildDeck(config.mode, config.pvpCardsAllowed);
const cards = new Map<CardId, Card>();
for (const c of deck) cards.set(c.id, c);
@@ -425,6 +411,7 @@ export function createGame(opts: SetupOptions): GameState {
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(),
collisionsToday: 0,
collisionsTotal: 0,
status: 'active',
outcome: null,
};
+44 -82
View File
@@ -12,7 +12,6 @@ import type {
Hand,
MainlineKind,
FreightKind,
GameLength,
HouseRuleOverrides,
ModifierKind,
OfficeTier,
@@ -56,47 +55,7 @@ export function coordKey(c: GridCoord): string {
// ---------------------------------------------------------------------------
/** §2.2 — a coloured car is loaded, a white car is empty. */
export type RollingStock = {
type: CarType;
loaded: boolean;
/**
* WHICH OFFICE AREA MADE THIS LOAD — the physical game's chip turned upside down in the tray.
*
* Reported from playtesting v0.4.9d as two bugs with one cause: a boxcar loaded at a Freight
* House could be unloaded at that same Freight House on the next Laborer action, and passengers
* who had just boarded could be detrained again before the train turned a wheel. Both paid full
* Revenue at each end for a load that never went anywhere.
*
* Jesse's rule (v0.4.9e): freight or passengers loaded anywhere in an Office Area may not be
* unloaded ANYWHERE in that same Office Area — not at another facility, not in a later Stage.
* They have to be carried by a train to a different Office Area. So the stamp is the SEAT, which
* is what an Office Area belongs to (Employee Rotation moves players between chairs; the district
* stays with the chair), and it never expires.
*
* A SEAT, NOT A PLAYER, and undefined rather than -1 for "no origin": the Division Yard opens with
* loaded cars and loaded coaches that were made up off-Division (`ROLLING_STOCK_SUPPLY`), and
* those are exactly the inbound traffic a solitaire district lives on. A sentinel inside
* `SeatIndex`'s own value range is not a sentinel — see `card.play`'s `node` in intents.ts.
*
* Stripped by `pooled` whenever a car goes back to a yard: the stamp belongs to the LOAD, and a
* car returning to the common supply is carrying nothing.
*/
origin?: SeatIndex;
};
/**
* A car returning to the common pool — the Division or Classification Yard — with its load's origin
* stamp taken off.
*
* Every yard push goes through this. A loaded car CAN reach a yard still loaded (a train retires at
* a Division Point with freight aboard, `advance.ts`), and without this it would carry a stamp from
* a district it left several Days ago into whatever train is made up from it next.
*/
export function pooled(car: RollingStock): RollingStock {
if (car.origin === undefined) return car;
const { origin: _origin, ...rest } = car;
return rest;
}
export type RollingStock = { type: CarType; loaded: boolean };
// ---------------------------------------------------------------------------
// Track and Office Area
@@ -355,23 +314,6 @@ export type CrewTray = {
/** null while a local crew is switching without a train card. */
trainNumber: number | null;
trainIsExtra: boolean;
/**
* STILL BEING ASSEMBLED, somewhere that is not a Division Point.
*
* `isBeingMadeUp` used to read the position alone — "standing at a Division Point" — which was
* true of every train being built when the only place to build one WAS a Division Point. An Extra
* may now be started at a Control Point or in an Interchange's yard (§7), and those trains could
* not be given a consist at all: they ran empty, and so did every Control Point Extra since that
* option was added. Jesse's report says it plainly — an Extra started at the Interchange "would be
* loaded with cars".
*
* Set when such an Extra is placed and cleared the moment it starts running (`enterMainline`), so
* it names a train that is being made up rather than one that merely happens to be standing
* somewhere. That distinction is load-bearing: a train that ARRIVED at an Office must never be
* fillable from the Division Yard, which is the "cars appearing on a train nobody was making up"
* bug `isBeingMadeUp` was tightened to kill, and an arriving train never carries this.
*/
beingMadeUp?: boolean;
/**
* WHERE THE ENGINE SITS IN THE TRAY, as an index into `consist`.
*
@@ -383,6 +325,12 @@ export type CrewTray = {
*
* The engine is NOT one of the `consist` entries: §8.2 counts the consist as Rolling Stock, and
* the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
*
* This only records POSITION, not a supply — and that is not a gap. `rules-v0.2.md`:339 (Gap 4b)
* ties Crew Trays and engine pieces together as one combined resource, `player count + 3`
* (`crewTrayCount` in `content.ts`), so engine scarcity IS tray scarcity: `freeTrays` running out
* already blocks a new train exactly when the engine supply would. There is no state for "a tray
* with no engine" because the rules never separate the two.
*/
engineAt: number;
/**
@@ -482,20 +430,6 @@ export type DivisionNode =
kind: 'mainline';
card: MainlineKind;
transits: Transit[];
/**
* TRAINS STANDING IN THE INTERCHANGE'S YARD — not out on the running line.
*
* Only an Interchange ever has these. An Extra started there (§7, Jesse's ruling) is made up
* in the yard beside the Mainline, which is why placing it can never be a collision however
* busy the card is: it is not on the road yet. It highballs onto this same card at a later
* Mainline Phase, through the ordinary §8.1 clearance check — held automatically against a
* facing train, put to the Superintendent against a following one — and becomes a `Transit`
* at that moment, exactly like a train leaving a Division Point.
*
* A tray listed here has `position.at === 'mainline'` with this node's index and NO entry in
* `transits`. That pair is what distinguishes standing from crossing.
*/
holding?: TrayId[];
absSignals?: boolean;
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
modifiers?: string[];
@@ -611,12 +545,42 @@ export type Player = {
};
export type GameMode = 'solitaire' | 'competitive' | 'coop';
export type VictoryCondition = 'firstToTarget' | 'highestAfterDays';
/**
* VICTORY CONDITIONS — designed 2026-08-20, unified across all three modes.
*
* Replaces the old `length`-preset lookup (`LENGTH_PROFILES.target`) and the dead
* `VictoryCondition: 'firstToTarget'` (grepped: never selected anywhere in the codebase). Winner is
* whoever has the most Revenue when `days` run out — solitaire's own player counts as "everyone" —
* unless `minCombinedRevenue` was missed, in which case everyone loses. Coop keeps summing every
* player's Revenue into one table score, now against a configurable floor instead of
* `profile.target * players.length`.
*
* `0` means "off" for every numeric field below. `minCombinedRevenue`'s natural default is
* `collectiveRevenueFloor(players, days)` (`content.ts`), computed by whoever authors the config —
* the engine only ever reads a concrete number here, never resolves one lazily, because player count
* is not always known at config-authoring time (a lobby, a CLI flag, a dialog).
*
* `maxCollisionsPerDay`/`maxCollisionsTotal` are deliberately FLAT, not scaled by player count: more
* players means more independent chances to collide, not a bigger shared budget, so a multiplayer
* table is genuinely riskier than solitaire at the same default (Jesse's call).
*/
export type GameConfig = {
mode: GameMode;
victory: VictoryCondition;
length: GameLength;
/** How many Days the game runs. */
days: number;
/** Everyone loses if the table's total Revenue is below this when `days` run out. 0 = off. */
minCombinedRevenue: number;
/** Everyone loses immediately, mid-game, once collisions in one Day reach this. 0 = off. */
maxCollisionsPerDay: number;
/** Same, summed across the whole game, never reset. 0 = off. */
maxCollisionsTotal: number;
/**
* Whether the 22 opponent-directed cards are in the deck (`setup.ts`'s `buildDeck`). Forced off in
* `solitaire` and `coop` — neither has a valid target for them — on by default in `competitive`.
* Has no effect until those cards are built (Phase 5); see `TODO.md`.
*/
pvpCardsAllowed: boolean;
optionalRules: {
reducedVisibility: boolean;
sisterTrains: boolean;
@@ -633,11 +597,7 @@ export type GameConfig = {
houseRules?: HouseRuleOverrides;
};
export type OutcomeReason =
| 'targetReached'
| 'daysElapsed'
| 'collisionFloor'
| 'revenueFloor';
export type OutcomeReason = 'daysElapsed' | 'collisionFloor' | 'revenueFloor';
export type Outcome = {
result: 'win' | 'loss';
@@ -813,8 +773,10 @@ export type GameState = {
turns: Map<PlayerIndex, TurnState>;
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
movedThisPhase: Set<TrayId>;
/** §3.4 — resets at the start of each Day. */
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
collisionsToday: number;
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
collisionsTotal: number;
status: 'setup' | 'active' | 'finished';
outcome: Outcome | null;
};
+10 -3
View File
@@ -667,12 +667,19 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
}
/**
* §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there.
* §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there — WITH ONE
* EXCEPTION (Jesse's call, v0.5.0): any train may set out one or more coaches at the Office. It is
* the one car type a passenger platform is meant to hold, and it is what makes the ENGINE-boxcar-
* coach Local arrangement workable — the coach drops here while the engine and freight car switch
* freely. Freight and cabooses stay banned at the Office for every train, no exception.
*
* An industry track also has a finite length (§9.3), so it can be full.
*/
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1): boolean {
if (sameCoord(coord, area.officeCoord)) return false;
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1, coachesOnly = false): boolean {
const card = cardAt(area, coord);
if (sameCoord(coord, area.officeCoord)) {
return coachesOnly && !!card && isOperationalRail(card) && spaceOn(card) >= count;
}
if (!card || !isOperationalRail(card)) return false;
return spaceOn(card) >= count;
}
+576
View File
@@ -0,0 +1,576 @@
/**
* The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12),
* extended for Phase 4 (§12 steps 17-20) to a lobby and more than one game.
*
* Plain `node:http`, no framework: the project has zero runtime dependencies
* (`package.json`), and `scripts/build-web.ts` already shells out to `tsc` directly rather than
* reaching for a bundler — this matches that everywhere-else choice rather than introducing the
* first framework dependency for one route table.
*
* All the game logic lives in `session.ts` and `lobby.ts`; this file is deliberately thin — routing,
* the join-secret gate on the DOOR (create/join), token resolution once a player is through it, SSE
* mechanics, and static file serving for the built client (`dist/`, D16: the server serves the
* client, which is what makes same-origin work with no CORS).
*
* TOKENS REPLACE `?seat=&secret=` ON THE RUNNING-GAME ROUTES. `lobby-and-sessions.md` §1: the token
* already proves "I am the player who was in this game," which is the only identity claim `/api/stream`
* and `/api/intent` need — the join secret's job ends at the lobby door.
*/
import { createServer } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import {
appendTiming,
deleteGame,
deleteLobby,
gameDir,
readIndex,
removeIndexEntry,
upsertIndexEntry,
writeGame,
writeLobby,
writeSessions,
} from './persistence.ts';
import { createSession } from './session.ts';
import type { GameSession, Push } from './session.ts';
import {
createLobby,
freshGameCode,
playerCountAllowed,
joinLobby,
reassignHost,
setBotSeat,
startLobby,
} from './lobby.ts';
import type { Lobby, PlayerSession } from './lobby.ts';
export type ServerOptions = {
port: number;
bindAddress: string;
/** D14 — a server-wide secret, passed out of band. Gates lobby creation and joining — the door;
* once a player is through it and holds a token, the token alone authenticates them. */
joinSecret: string;
/** The built client (`npm run build:web`'s `dist/`), served at `/` (D16). */
distDir: string;
/** Where every game's files live, one subdirectory per `gameId` (`persistence.ts`'s `gameDir`). */
dataDir: string;
/** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */
engineVersion: string;
/**
* Gates the administrative routes — listing, exporting and deleting games — and is DELIBERATELY
* not the join secret. Every player holds that one, so gating a delete with it would let anyone
* at the table destroy anyone else's game. This is held by whoever runs the server and nobody
* else. When it is unset the admin routes do not exist at all (404, the same answer as any other
* unknown path), so a server that was never given one cannot be administered by guessing.
*/
adminSecret?: string | undefined;
/** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */
initialGames: Map<string, GameSession>;
initialLobbies: Map<string, Lobby>;
initialSessions: Map<string, PlayerSession>;
};
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
};
const HEARTBEAT_MS = 20_000;
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const text = Buffer.concat(chunks).toString('utf8');
return text.trim() === '' ? {} : JSON.parse(text);
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const text = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) });
res.end(text);
}
async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise<void> {
const rel = urlPath === '/' ? '/index.html' : urlPath;
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
// for `/../../etc/passwd` must not escape the one directory this is allowed to read from.
const full = join(distDir, normalize(rel));
if (!full.startsWith(distDir)) {
sendJson(res, 400, { error: 'bad path' });
return;
}
try {
const info = await stat(full);
if (!info.isFile()) throw new Error('not a file');
res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size });
createReadStream(full).pipe(res);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
}
}
/**
* What a lobby SSE push carries — the whole `Lobby`, since the seat list is small and a delta
* mechanism buys nothing at this size (`session.ts`'s `Push` deltas the BOARD, which is not this).
*
* `started` rides on the FINAL push of a lobby's life, sent the instant before the connection is
* closed at `Lobby.Start` — without it, the client's only signal that the game began is the stream
* simply ending, indistinguishable from a network hiccup that `EventSource` would otherwise retry.
*/
type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean };
export function startServer(opts: ServerOptions): void {
const games = opts.initialGames;
const lobbies = opts.initialLobbies;
const sessions = opts.initialSessions;
const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
// One open SSE response per (gameId, seat) for a running game, and per (gameId, token) for a
// lobby still being seated — a second connection from the same seat/token replaces the first
// rather than fanning out to both (no concept yet of "the same seat from two tabs").
const gameConnections = new Map<string, Map<PlayerIndex, ServerResponse>>();
const gameEventIds = new Map<string, Map<PlayerIndex, number>>();
const lobbyConnections = new Map<string, Map<string, ServerResponse>>();
function writeSse(res: ServerResponse, id: number, data: unknown): void {
res.write(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`);
}
function nextEventId(gameId: string, seat: PlayerIndex): number {
const ids = gameEventIds.get(gameId) ?? new Map<PlayerIndex, number>();
const id = (ids.get(seat) ?? 0) + 1;
ids.set(seat, id);
gameEventIds.set(gameId, ids);
return id;
}
function broadcastGame(gameId: string, pushes: Map<PlayerIndex, Push>): void {
const conns = gameConnections.get(gameId);
if (!conns) return;
for (const [seat, push] of pushes) {
const res = conns.get(seat);
if (res) writeSse(res, nextEventId(gameId, seat), push);
}
}
/**
* Presence is transport-layer news about a CONNECTION, never a `GameEvent` — it does not go
* through `session.ts` at all (`lobby-and-sessions.md` §5). Sent to every OTHER currently
* connected seat of the same game as a presence-only push (an empty board delta, no menu, no new
* lines) rather than inventing a second SSE event type — one message shape for the client to parse.
*/
function broadcastPresence(gameId: string, seat: PlayerIndex, connected: boolean): void {
const conns = gameConnections.get(gameId);
if (!conns) return;
for (const [other, res] of conns) {
if (other === seat) continue;
const push: Push = { menu: null, lines: [], presence: { seat, connected } };
writeSse(res, nextEventId(gameId, other), push);
}
}
function broadcastLobby(gameId: string): void {
const lobby = lobbies.get(gameId);
const conns = lobbyConnections.get(gameId);
if (!lobby || !conns) return;
for (const [token, res] of conns) {
const ps = sessions.get(token);
if (!ps) continue;
writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush);
}
}
async function persistLobby(lobby: Lobby): Promise<void> {
lobbies.set(lobby.gameId, lobby);
gameCodes.set(lobby.gameCode, lobby.gameId);
await writeLobby(opts.dataDir, lobby);
await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'lobby' });
}
async function persistSession(ps: PlayerSession): Promise<void> {
sessions.set(ps.token, ps);
const all = [...sessions.values()].filter((s) => s.gameId === ps.gameId);
await writeSessions(opts.dataDir, ps.gameId, all);
}
const server = createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
// -- Is anyone home? --------------------------------------------------------------------
/**
* THE ONE ROUTE THAT EXISTS TO BE FAILED.
*
* The same `dist/` is served two ways: by this server, and as a plain static upload with no
* server behind it at all (`scripts/deploy-web.ts`). The bundle is byte-identical either way
* — one client, mode decided at runtime (D4) — so the page cannot know from its own build
* which it is, and every other route here answers a 404 for a path it does not have, exactly
* as a static host would. Nothing distinguished them until this did.
*
* Unauthenticated on purpose: it says only that a Station Master server is answering, which
* is what the client is about to offer the player anyway. It reveals no game and no seat.
*/
if (url.pathname === '/api/health' && req.method === 'GET') {
// `summary()` rather than `exportSave()`: this is polled on a timer, and the save copies
// every intent of every game to answer a question about none of them.
let active = 0;
for (const g of games.values()) if (g.summary().status === 'active') active++;
sendJson(res, 200, {
ok: true,
service: 'station-master',
engineVersion: opts.engineVersion,
games: { active, lobby: lobbies.size },
});
return;
}
// -- Administration: listing, exporting and deleting games ------------------------------
if (url.pathname === '/api/games' || url.pathname.startsWith('/api/games/')) {
// Unset means the routes are not here — indistinguishable from any other unknown path, so
// nothing advertises an administrative surface to someone probing for one.
if (!opts.adminSecret) {
await serveStatic(opts.distDir, url.pathname, res);
return;
}
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
sendJson(res, 403, { error: 'bad or missing admin secret' });
return;
}
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
if (url.pathname === '/api/games' && req.method === 'GET') {
const running = [...games.entries()].map(([gameId, g]) => ({
gameId,
gameCode: codes.get(gameId) ?? null,
state: 'running' as const,
...g.summary(),
}));
// A lobby has no game to summarize yet — it is reported as what it is, so an
// administrator sees a table that never started rather than nothing at all.
const waiting = [...lobbies.values()].map((l) => ({
gameId: l.gameId,
gameCode: l.gameCode,
state: 'lobby' as const,
playerCount: l.seats.length,
playerNames: l.seats.map((seat) =>
seat === null ? '(empty)' : seat.kind === 'bot' ? 'Bot' : seat.displayName,
),
createdAt: l.createdAt,
}));
sendJson(res, 200, { games: [...running, ...waiting] });
return;
}
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname);
const gameId = match?.[1];
if (!gameId) {
sendJson(res, 404, { error: 'no such route' });
return;
}
if (match?.[2] && req.method === 'GET') {
const session = games.get(gameId);
if (!session) {
sendJson(res, 404, { error: 'no such game' });
return;
}
sendJson(res, 200, { gameCode: codes.get(gameId) ?? null, save: session.exportSave() });
return;
}
if (req.method === 'DELETE') {
const session = games.get(gameId);
const lobby = lobbies.get(gameId);
if (!session && !lobby) {
sendJson(res, 404, { error: 'no such game' });
return;
}
// The save goes back with the deletion, so a game can never be destroyed without its
// record being handed to whoever destroyed it — the intents ARE the game (D5), so this
// is the whole thing, replayable later, not a summary of it.
const save = session?.exportSave() ?? null;
// Everyone watching is told the game is gone before its files are, rather than being
// left on a stream that will never push again.
for (const [, watcher] of gameConnections.get(gameId) ?? []) watcher.end();
gameConnections.delete(gameId);
for (const [, watcher] of lobbyConnections.get(gameId) ?? []) watcher.end();
lobbyConnections.delete(gameId);
games.delete(gameId);
lobbies.delete(gameId);
gameEventIds.delete(gameId);
const code = lobby?.gameCode ?? codes.get(gameId);
if (code) gameCodes.delete(code);
for (const [token, ps] of [...sessions]) if (ps.gameId === gameId) sessions.delete(token);
await removeIndexEntry(opts.dataDir, gameId);
await deleteGame(opts.dataDir, gameId);
sendJson(res, 200, { ok: true, gameCode: code ?? null, save });
return;
}
sendJson(res, 405, { error: 'method not allowed' });
return;
}
// -- Lobby: creating and joining (the door — join-secret gated) --------------------------
if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
const body = (await readJson(req)) as {
secret?: string;
config?: GameConfig;
displayName?: string;
players?: number;
};
if (body.secret !== opts.joinSecret) {
sendJson(res, 403, { error: 'bad or missing secret' });
return;
}
if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
sendJson(res, 400, { error: 'expected { secret, config, displayName, players }' });
return;
}
// The table size is the host's to choose and is fixed from here on, so it is validated at
// the door rather than at Start — `createLobby` builds the seats array from it.
const players = body.players ?? 0;
if (!Number.isInteger(players) || !playerCountAllowed(body.config.mode, players)) {
sendJson(res, 400, { error: 'BAD_PLAYER_COUNT' });
return;
}
const gameCode = freshGameCode((code) => gameCodes.has(code));
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players);
await persistLobby(lobby);
await persistSession(session);
sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
return;
}
if (url.pathname === '/api/lobby/join' && req.method === 'POST') {
const body = (await readJson(req)) as { secret?: string; gameCode?: string; displayName?: string };
if (body.secret !== opts.joinSecret) {
sendJson(res, 403, { error: 'bad or missing secret' });
return;
}
if (typeof body.gameCode !== 'string' || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
sendJson(res, 400, { error: 'expected { secret, gameCode, displayName }' });
return;
}
const gameId = gameCodes.get(body.gameCode.trim().toUpperCase());
const lobby = gameId ? lobbies.get(gameId) : undefined;
if (!lobby) {
// A game code that already started is no longer in `lobbies` at all — same NOT_FOUND a
// typo gets, which tells a latecomer "that game is gone" without leaking which case it was.
sendJson(res, 404, { error: 'no open lobby with that code' });
return;
}
const result = joinLobby(lobby, body.displayName.trim());
if (!result.ok) {
sendJson(res, 409, { error: result.code });
return;
}
await persistLobby(result.lobby);
await persistSession(result.session);
broadcastLobby(lobby.gameId);
sendJson(res, 200, { gameId: lobby.gameId, token: result.session.token, player: result.session.player });
return;
}
// -- Lobby: seating, once inside (token-authenticated) ------------------------------------
if (url.pathname === '/api/lobby/bot' && req.method === 'POST') {
const body = (await readJson(req)) as { token?: string; seat?: number; filled?: boolean };
const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined;
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
if (!ps || !lobby) {
sendJson(res, 404, { error: 'no such lobby' });
return;
}
if (lobby.hostToken !== ps.token) {
sendJson(res, 403, { error: 'NOT_HOST' });
return;
}
if (typeof body.seat !== 'number' || typeof body.filled !== 'boolean') {
sendJson(res, 400, { error: 'expected { token, seat, filled }' });
return;
}
const updated = setBotSeat(lobby, body.seat as PlayerIndex, body.filled);
await persistLobby(updated);
broadcastLobby(lobby.gameId);
sendJson(res, 200, { ok: true });
return;
}
if (url.pathname === '/api/lobby/start' && req.method === 'POST') {
const body = (await readJson(req)) as { token?: string };
const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined;
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
if (!ps || !lobby) {
sendJson(res, 404, { error: 'no such lobby' });
return;
}
const result = startLobby(lobby, ps.token);
if (!result.ok) {
sendJson(res, 409, { error: result.code });
return;
}
const session = createSession(Math.floor(Math.random() * 1e9), lobby.config, result.playerNames, result.botSeats);
games.set(lobby.gameId, session);
lobbies.delete(lobby.gameId);
// Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game
// forward from here. `started: true` on one last message, THEN close, is what lets a
// still-open lobby tab tell "the game began" apart from a network hiccup `EventSource`
// would otherwise silently retry through.
for (const [watcherToken, watcherRes] of lobbyConnections.get(lobby.gameId) ?? []) {
const watcherPs = sessions.get(watcherToken);
if (watcherPs) writeSse(watcherRes, 0, { lobby, you: watcherPs.player, started: true } satisfies LobbyPush);
watcherRes.end();
}
lobbyConnections.delete(lobby.gameId);
await writeGame(gameDir(opts.dataDir, lobby.gameId), session.exportSave(), opts.engineVersion);
await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'active' });
await deleteLobby(opts.dataDir, lobby.gameId);
sendJson(res, 200, { ok: true });
return;
}
if (url.pathname === '/api/lobby/stream' && req.method === 'GET') {
const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token);
const lobby = ps ? lobbies.get(ps.gameId) : undefined;
if (!ps || !lobby) {
sendJson(res, 404, { error: 'no such lobby' });
return;
}
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
const conns = lobbyConnections.get(lobby.gameId) ?? new Map<string, ServerResponse>();
conns.set(token, res);
lobbyConnections.set(lobby.gameId, conns);
writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush);
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
req.on('close', () => {
clearInterval(heartbeat);
const live = lobbyConnections.get(lobby.gameId);
if (live?.get(token) === res) live.delete(token);
// `lobby-and-sessions.md` §2 — host rights pass to the earliest-joined remaining player
// if the host's connection closes before start. `lobbies.get` again, not the captured
// `lobby`, because it may have changed (another join, another bot toggle) since connect.
const current = lobbies.get(lobby.gameId);
if (current && current.hostToken === token) {
void persistLobby(reassignHost(current, token)).then(() => broadcastLobby(lobby.gameId));
}
});
return;
}
// -- The running game (token-authenticated) ------------------------------------------------
/**
* IS THIS TOKEN STILL GOOD FOR ANYTHING?
*
* A browser remembers its session in `localStorage` and re-enters the game on the next load
* without asking, which is what makes reconnection seamless — and what leaves it stranded
* when the game is gone. `EventSource` cannot report a status code and retries a 404
* silently forever, so the client needs somewhere cheap to ask a yes/no question. Two ways a
* game legitimately disappears under a player: an engine-version bump refuses to resume it
* (D7), and an administrator ends it (`DELETE /api/games/<id>`).
*/
if (url.pathname === '/api/session' && req.method === 'GET') {
const ps = sessions.get(url.searchParams.get('token') ?? '');
const live = ps ? games.get(ps.gameId) : undefined;
if (!ps || !live) {
sendJson(res, 404, { error: 'no such game' });
return;
}
sendJson(res, 200, { gameId: ps.gameId, player: ps.player });
return;
}
if (url.pathname === '/api/stream' && req.method === 'GET') {
const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token);
const session = ps ? games.get(ps.gameId) : undefined;
if (!ps || !session) {
sendJson(res, 404, { error: 'no such game' });
return;
}
const { gameId, player: seat } = ps;
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
const conns = gameConnections.get(gameId) ?? new Map<PlayerIndex, ServerResponse>();
conns.set(seat, res);
gameConnections.set(gameId, conns);
writeSse(res, nextEventId(gameId, seat), session.connect(seat));
broadcastPresence(gameId, seat, true);
// Idle for minutes at a time is the expected shape of this game (multiplayer.md §9) — a
// silent SSE connection is exactly what a proxy in the path may reap. A comment line is not a
// real event (EventSource ignores lines starting with `:`), so it costs the client nothing.
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
req.on('close', () => {
clearInterval(heartbeat);
const live = gameConnections.get(gameId);
if (live?.get(seat) === res) live.delete(seat);
broadcastPresence(gameId, seat, false);
});
return;
}
if (url.pathname === '/api/intent' && req.method === 'POST') {
// Token comes from the QUERY STRING, matching `/api/stream` and matching what
// `web/session.ts`'s `RemoteSession.submit` actually sends (`fetch('/api/intent?token=…')`)
// — the body carries only what changes per call, `{ seq, intent }`.
const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token);
const session = ps ? games.get(ps.gameId) : undefined;
if (!ps || !session) {
sendJson(res, 404, { error: 'no such game' });
return;
}
const body = (await readJson(req)) as { seq?: number; intent?: Intent };
if (typeof body.seq !== 'number' || !body.intent) {
sendJson(res, 400, { error: 'expected { seq, intent }' });
return;
}
const result = session.intent(ps.player, body.seq, body.intent);
if (result.accepted) {
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
// this scale, not just "applied in memory" (§12 step 14).
const dir = gameDir(opts.dataDir, ps.gameId);
await writeGame(dir, session.exportSave(), opts.engineVersion);
if (result.timing) await appendTiming(dir, result.timing);
if (session.exportSave().status === 'finished') {
// `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be
// carried forward here rather than left blank — `gameCodes` is the only place still
// holding it once a lobby's own record is gone.
const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? '';
await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' });
}
}
sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code });
if (result.accepted) broadcastGame(ps.gameId, result.pushes);
return;
}
await serveStatic(opts.distDir, url.pathname, res);
})().catch((err: unknown) => {
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
});
});
server.listen(opts.port, opts.bindAddress);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Process bootstrap — Phase 2 §12 step 8, extended for Phase 3 (§12 steps 14-15) load-on-start and
* Phase 4 (§12 steps 17-20) to resume every saved game and lobby, not just one.
*
* Run with: node src/server/index.ts
*
* Env-configured, no config file — matches how the rest of this project's dev-side tooling reads
* `process.env` directly (`scripts/build-web.ts`'s `BUILD_DIST_DIR`).
*/
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startServer } from './http.ts';
import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.ts';
import { resumeSession } from './session.ts';
import type { GameSession } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts';
const port = Number(process.env['PORT'] ?? 8081);
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
const joinSecret = process.env['JOIN_SECRET'];
/**
* Optional, unlike `JOIN_SECRET`: a server with no administrator is a perfectly good server, and
* refusing to boot without one would break every existing deployment and every dev run. Unset
* simply means the admin routes are not there (`http.ts`), which is the safe default — the
* capability has to be granted, never merely left ungated.
*/
const adminSecret = process.env['ADMIN_SECRET'];
const distDir = resolve(process.env['DIST_DIR'] ?? 'dist');
const dataDir = resolve(process.env['DATA_DIR'] ?? 'data');
if (!joinSecret) {
console.error('JOIN_SECRET must be set — a server-wide secret, passed out of band (D14).');
process.exit(1);
}
// `package.json`'s version IS `engineVersion` (§12 step 15) — the same reading `scripts/build-web.ts`'s
// `buildStamp()` already does, just from `src/server/` rather than the repo root script directory.
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const engineVersion = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version;
const initialGames = new Map<string, GameSession>();
const initialLobbies = new Map<string, Lobby>();
const initialSessions = new Map<string, PlayerSession>();
const index = await readIndex(dataDir);
// Said before the loop, not after it: replaying is the reason a restart pauses before the port
// opens, and a log that only reports each game once it is done gives no warning of how much is
// still to come.
const resumable = index.filter((e) => e.status !== 'finished').length;
if (resumable > 0) console.log(`Resuming ${resumable} saved game(s)…`);
for (const entry of index) {
const sessions = await readSessions(dataDir, entry.gameId);
for (const s of sessions) initialSessions.set(s.token, s);
if (entry.status === 'lobby') {
const lobby = await readLobby(dataDir, entry.gameId);
if (lobby) initialLobbies.set(entry.gameId, lobby);
continue;
}
const loaded = await loadGame(gameDir(dataDir, entry.gameId), engineVersion);
if (loaded.found && loaded.ok) {
initialGames.set(entry.gameId, resumeSession(loaded.saved));
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
} else if (loaded.found && !loaded.ok) {
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded
// under. The file is left untouched: rolling the running version back would let it load again.
console.error(
`Refusing to resume ${entry.gameId} (${entry.gameCode}): saved under engine version ` +
`${loaded.storedVersion}, this server is running ${engineVersion}. Left untouched, and ` +
`will not appear as an active game until the version matches again.`,
);
}
// `entry.status === 'finished'` games are not resumed into memory at all — nothing plays them
// forward, and their files stay on disk for post-game replay (`lobby-and-sessions.md` §6).
}
startServer({
port,
bindAddress,
joinSecret,
adminSecret,
distDir,
dataDir,
engineVersion,
initialGames,
initialLobbies,
initialSessions,
});
console.log(
`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` +
`${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`,
);
if (!adminSecret) {
console.log('ADMIN_SECRET is unset — the /api/games administration routes are disabled.');
}
+199
View File
@@ -0,0 +1,199 @@
/**
* The lobby — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20), fully specified in
* `docs/architecture/lobby-and-sessions.md`.
*
* Pure logic, no sockets, no filesystem, no in-memory registry — same split `session.ts` already
* draws (`http.ts` wires connections and persistence to this; `persistence.ts` is the only thing
* that touches disk). A `Lobby` exists only BEFORE `Lobby.Start`: once the config locks and the
* `GameSession` is built, this module is done with that game — everything after is `session.ts`.
*
* WHY A SEPARATE FILE FROM `session.ts`. `session.ts` is already the "running game" module and is
* large; a lobby's concerns — seating, host rights, the join secret's door versus a game code's
* discovery, config locking — share almost no code with running a Stage clock, and mixing them would
* make both harder to read for no reuse gained.
*/
import { randomUUID } from 'node:crypto';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
/** `lobby-and-sessions.md` §1 — names the PLAYER, not the seat. Issued once, at join, and never
* reissued: it is what makes reconnection work, so it must outlive the connection that first used
* it. */
export type PlayerSession = {
token: string;
gameId: string;
player: PlayerIndex;
displayName: string;
};
export type LobbySeat = { kind: 'human'; token: string; displayName: string } | { kind: 'bot' } | null;
/** A game that has not started. `hostToken` ends its meaning at `Lobby.Start` — nothing here
* survives into the running game except the seat list and the locked `config`. */
export type Lobby = {
gameId: string;
gameCode: string;
hostToken: string;
config: GameConfig;
seats: LobbySeat[];
/** Tokens in the order they joined — human seats only, host first — so a departed host's
* replacement is unambiguous (`lobby-and-sessions.md` §2: "earliest-joined remaining player"). */
joinOrder: string[];
createdAt: number;
};
export type CreateResult = { lobby: Lobby; session: PlayerSession };
export type JoinResult = { ok: true; lobby: Lobby; session: PlayerSession } | { ok: false; code: 'LOBBY_FULL' | 'ALREADY_STARTED' };
export type StartResult = { ok: true; playerNames: string[]; botSeats: PlayerIndex[] } | { ok: false; code: 'NOT_HOST' | 'BAD_PLAYER_COUNT' };
/**
* `lobby-and-sessions.md` §2 — short and speakable, not an account name and not a UUID. A small
* fixed word list rather than a dictionary: this is read aloud across a table or a call, not typed
* from memory, so a handful of unambiguous railroad words serve better than a large random one.
*/
const CODE_WORDS = [
'RAIL', 'YARD', 'DEPOT', 'COAL', 'MAIL', 'CABOOSE', 'SIGNAL', 'FREIGHT',
'TRESTLE', 'HOPPER', 'TENDER', 'SIDING', 'GRADE', 'SPUR', 'WHISTLE', 'TROLLEY',
];
function randomCode(): string {
const word = CODE_WORDS[Math.floor(Math.random() * CODE_WORDS.length)]!;
const digits = Math.floor(Math.random() * 10_000)
.toString()
.padStart(4, '0');
return `${word}-${digits}`;
}
/** Collision-checked against whatever codes the caller already knows about — `http.ts` holds the
* live registry, so the check lives there rather than this module owning a set of its own. */
export function freshGameCode(taken: (code: string) => boolean): string {
let code = randomCode();
while (taken(code)) code = randomCode();
return code;
}
/** `lobby-and-sessions.md` §2 — solitaire is exactly 1, competitive/coop are 2-4. Not a rules
* limit: the engine will build a Division for any seat count. This is the lobby judgment, sized to
* what has actually been played and tested (`test/multiplayer.test.ts` exercises 2, 3 and 4). */
export function playerCountAllowed(mode: GameConfig['mode'], count: number): boolean {
return mode === 'solitaire' ? count === 1 : count >= 2 && count <= 4;
}
/** The creating player is the host and takes seat 0 (`lobby-and-sessions.md` §2). */
/**
* THE TABLE SIZE IS FIXED WHEN THE GAME IS CREATED, and `seats.length` is it.
*
* The host says how many are playing, so the seats array is built at full length with the host in
* chair 0 and the rest empty. Nothing ever grows or shrinks it, which is what makes a gap
* impossible to express rather than merely illegal — and that matters more than it looks: seats
* used to be appended as people joined, so a bot dropped into a later chair padded the array with
* a hole that silently blocked Start. It also removes any need to compact the seats at
* `Lobby.Start`, and compaction would have shifted the `player` index every `PlayerSession`
* already carries (`joinLobby` stamps it at join time, and `/api/stream` and `/api/intent` route
* by it) — quietly handing a player somebody else's railroad.
*
* Knowing the count this early has one more consequence, and it is a bug fix: the config's
* `minCombinedRevenue` is derived from the player count, and the lobby previously had to guess it
* as 4 before anyone had sat down.
*/
export function createLobby(
config: GameConfig,
hostDisplayName: string,
gameCode: string,
players: number,
): CreateResult {
const gameId = randomUUID();
const token = randomUUID();
const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName };
const seats: LobbySeat[] = Array.from({ length: players }, (_, i) =>
i === 0 ? { kind: 'human', token, displayName: hostDisplayName } : null,
);
const lobby: Lobby = {
gameId,
gameCode,
hostToken: token,
config,
seats,
joinOrder: [token],
createdAt: Date.now(),
};
return { lobby, session };
}
/** Joins the first empty seat, or extends the seat list if every seat so far is filled and the
* mode's cap allows one more (`lobby-and-sessions.md` §2's 2-4 is enforced at `Lobby.Start`, not
* here — a 5th join before anyone starts is refused outright since there is no seat it could ever
* play in, but the running count is checked against `playerCountAllowed` at every join too, so a
* lobby can never grow the seats array past what could legally start). */
export function joinLobby(lobby: Lobby, displayName: string): JoinResult {
// The table was sized at creation, so joining takes an empty chair or none at all — there is no
// longer an "append another seat" path for a late arrival to grow the game through.
const seatIndex = lobby.seats.findIndex((s) => s === null);
if (seatIndex < 0) return { ok: false, code: 'LOBBY_FULL' };
const token = randomUUID();
const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName };
const seats = [...lobby.seats];
seats[seatIndex] = { kind: 'human', token, displayName };
return {
ok: true,
lobby: { ...lobby, seats, joinOrder: [...lobby.joinOrder, token] },
session,
};
}
/** Host-only in effect (`http.ts` checks the caller's token against `hostToken` before calling
* this) — marks an empty seat as bot-filled, or clears one back to empty. Never touches an occupied
* human seat; the host removes a person by them leaving, not by overwriting their seat. */
export function setBotSeat(lobby: Lobby, seat: PlayerIndex, filled: boolean): Lobby {
const seats = [...lobby.seats];
// No padding: a seat outside the table the host chose is not a seat, and inventing one is how
// the old array grew holes in it.
if (seat < 0 || seat >= seats.length) return lobby;
if (filled) {
if (seats[seat] !== null) return lobby;
seats[seat] = { kind: 'bot' };
} else {
if (seats[seat]?.kind !== 'bot') return lobby;
seats[seat] = null;
}
return { ...lobby, seats };
}
/**
* The host's connection closed before `Lobby.Start`. `lobby-and-sessions.md` §2: rights pass to the
* earliest-joined remaining player — remaining means still occupying a seat, not necessarily still
* connected, since a momentary drop should not also cost the NEXT person the host chair. Returns
* the lobby unchanged if the departing token was not the host, or if no other human seat exists.
*/
export function reassignHost(lobby: Lobby, departingToken: string): Lobby {
if (lobby.hostToken !== departingToken) return lobby;
const stillSeated = new Set(
lobby.seats.flatMap((s) => (s?.kind === 'human' ? [s.token] : [])),
);
const next = lobby.joinOrder.find((t) => t !== departingToken && stillSeated.has(t));
return next ? { ...lobby, hostToken: next } : lobby;
}
/**
* Locks the config, checks the player count, and hands back exactly what `session.ts`'s
* `createSession` needs — this function does not call it, so `lobby.ts` never depends on
* `session.ts` (the dependency runs the other way: `http.ts` calls both).
*/
export function startLobby(lobby: Lobby, callerToken: string): StartResult {
if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' };
// Every chair at the table must be taken. The size itself was validated at creation and cannot
// have moved since, so this is only ever waiting on the last empty seat to fill.
if (lobby.seats.some((s) => s === null) || !playerCountAllowed(lobby.config.mode, lobby.seats.length)) {
return { ok: false, code: 'BAD_PLAYER_COUNT' };
}
// Seat index IS player index — no compaction, because there is nothing to compact past.
const taken = lobby.seats as Exclude<LobbySeat, null>[];
// Bots are numbered rather than all being called "Bot": two of them at one table are two
// different railroads, and a map labelling both the same cannot say which is which.
let botNumber = 0;
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : `Bot ${++botNumber}`));
const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : []));
return { ok: true, playerNames, botSeats };
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Persistence — Phase 3 of `docs/architecture/multiplayer.md` (§12 steps 14-15), extended for
* Phase 4 (§12 steps 17-20) to more than one game (`lobby-and-sessions.md` §6 specifies both
* shapes and the reasoning behind them).
*
* ONE DIRECTORY PER GAME (`games/<gameId>/`), plus one top-level `index.json` naming every game so
* `index.ts` can find and resume them all on boot without scanning the filesystem. Every write is
* still a full-file atomic rewrite (write to `.tmp`, `rename` over the real path) rather than true
* on-disk appending — §6 says the storage mechanism is genuinely open as long as the LOGICAL history
* is never rewritten or reordered, which a full rewrite of an always-growing array satisfies, and at
* the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet.
*/
import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { SavedGame, TurnTiming } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts';
const GAME_FILE = 'game.json';
const TIMINGS_FILE = 'turn-timings.json';
const LOBBY_FILE = 'lobby.json';
const SESSIONS_FILE = 'sessions.json';
const INDEX_FILE = 'index.json';
type PersistedGame = SavedGame & { engineVersion: string };
async function atomicWrite(path: string, text: string): Promise<void> {
const tmp = `${path}.tmp`;
await writeFile(tmp, text);
await rename(tmp, path);
}
export async function writeGame(dataDir: string, saved: SavedGame, engineVersion: string): Promise<void> {
await mkdir(dataDir, { recursive: true });
const payload: PersistedGame = { engineVersion, ...saved };
await atomicWrite(join(dataDir, GAME_FILE), JSON.stringify(payload, null, 1));
}
export type LoadResult =
| { found: false }
| { found: true; ok: true; saved: SavedGame }
/** §12 step 15 — refused explicitly, never silently replayed under the wrong rules. */
| { found: true; ok: false; storedVersion: string; currentVersion: string };
export async function loadGame(dataDir: string, currentVersion: string): Promise<LoadResult> {
let text: string;
try {
text = await readFile(join(dataDir, GAME_FILE), 'utf8');
} catch {
return { found: false };
}
const payload = JSON.parse(text) as PersistedGame;
if (payload.engineVersion !== currentVersion) {
return { found: true, ok: false, storedVersion: payload.engineVersion, currentVersion };
}
const { engineVersion: _engineVersion, ...saved } = payload;
return { found: true, ok: true, saved };
}
/** Appended once per closed turn span (`GameSession.intent`'s `timing` result) — read-modify-write at
* this scale rather than real appending, same reasoning as `writeGame`. */
export async function appendTiming(dataDir: string, timing: TurnTiming): Promise<void> {
await mkdir(dataDir, { recursive: true });
const path = join(dataDir, TIMINGS_FILE);
let existing: TurnTiming[];
try {
existing = JSON.parse(await readFile(path, 'utf8')) as TurnTiming[];
} catch {
existing = [];
}
existing.push(timing);
await atomicWrite(path, JSON.stringify(existing, null, 1));
}
// ---------------------------------------------------------------------------
// Phase 4 — more than one game
// ---------------------------------------------------------------------------
export type GameIndexEntry = { gameId: string; gameCode: string; status: 'lobby' | 'active' | 'finished' };
/** Where one game's own files live. Every function below takes a `gameId` and joins it under here
* itself; `writeGame`/`loadGame`/`appendTiming` above do not, and take a directory directly, so a
* caller wanting one specific game's `game.json` passes `gameDir(dataDir, gameId)` to those. */
export function gameDir(dataDir: string, gameId: string): string {
return join(dataDir, 'games', gameId);
}
export async function readIndex(dataDir: string): Promise<GameIndexEntry[]> {
try {
return JSON.parse(await readFile(join(dataDir, INDEX_FILE), 'utf8')) as GameIndexEntry[];
} catch {
return [];
}
}
async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise<void> {
await mkdir(dataDir, { recursive: true });
await atomicWrite(join(dataDir, INDEX_FILE), JSON.stringify(entries, null, 1));
}
/**
* Adds or updates exactly one game's row, by `gameId` — every caller that changes one game's status
* (created, started, finished) wants precisely this, not "replace the whole index", which would
* silently drop every other game's row the moment two writes happened close together.
*/
export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise<void> {
const entries = await readIndex(dataDir);
const i = entries.findIndex((e) => e.gameId === entry.gameId);
if (i >= 0) entries[i] = entry;
else entries.push(entry);
await writeIndex(dataDir, entries);
}
/**
* Removes a game from the index. Paired with `deleteGame` — the directory holds the game, the
* index says the game exists, and a delete that did one without the other would either resurrect
* it on the next boot or leave `index.json` pointing at nothing.
*/
export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> {
const entries = await readIndex(dataDir);
await writeIndex(
dataDir,
entries.filter((e) => e.gameId !== gameId),
);
}
/** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */
export async function deleteGame(dataDir: string, gameId: string): Promise<void> {
await rm(gameDir(dataDir, gameId), { recursive: true, force: true });
}
export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> {
const dir = gameDir(dataDir, lobby.gameId);
await mkdir(dir, { recursive: true });
await atomicWrite(join(dir, LOBBY_FILE), JSON.stringify(lobby, null, 1));
}
export async function readLobby(dataDir: string, gameId: string): Promise<Lobby | null> {
try {
return JSON.parse(await readFile(join(gameDir(dataDir, gameId), LOBBY_FILE), 'utf8')) as Lobby;
} catch {
return null;
}
}
/**
* Removed once a game starts — a `Lobby` and a running `SavedGame` are mutually exclusive for one
* `gameId`, and leaving the file behind would let a restart resurrect a lobby for a game already
* under way. Missing already is not an error; `Lobby.Start` calls this exactly once, in the same
* request that writes `game.json`.
*/
export async function deleteLobby(dataDir: string, gameId: string): Promise<void> {
try {
await unlink(join(gameDir(dataDir, gameId), LOBBY_FILE));
} catch {
// Already gone — nothing to do.
}
}
/** Session tokens for one game — `lobby-and-sessions.md` §6: persisted so a token still works after
* a server restart, the same reason `game.json` itself is persisted. */
export async function writeSessions(dataDir: string, gameId: string, sessions: PlayerSession[]): Promise<void> {
const dir = gameDir(dataDir, gameId);
await mkdir(dir, { recursive: true });
await atomicWrite(join(dir, SESSIONS_FILE), JSON.stringify(sessions, null, 1));
}
export async function readSessions(dataDir: string, gameId: string): Promise<PlayerSession[]> {
try {
return JSON.parse(await readFile(join(gameDir(dataDir, gameId), SESSIONS_FILE), 'utf8')) as PlayerSession[];
} catch {
return [];
}
}
+331
View File
@@ -0,0 +1,331 @@
/**
* The game session host — Phases 2 and 3 of `docs/architecture/multiplayer.md` (§8, §12 steps 9,
* 14-16).
*
* Pure logic, no sockets, no filesystem — `src/server/http.ts` is the thin wiring that calls into
* this, and `src/server/persistence.ts` is what actually reads/writes disk. One `GameSession` per
* in-memory game (Phase 2 is one game per process; Phase 4 generalizes to many).
*
* REUSES `src/web/game.ts`'s `Game`/`submit`/`drain`/`currentActor`/`actionMenu` wholesale — all pure
* logic already, with no DOM dependency, exactly as `LocalSession` uses them client-side. Building a
* second copy of "apply an intent, narrate it, compute the Menu" here would drift from what solitaire
* already does and is tested against.
*
* ONE THING `game.ts`'s `submit` DOES NOT DO: verify who is calling it. `submit(game, intent)` reads
* `currentActor(game)` itself and applies the intent AS that player, regardless of who asked — safe
* for `LocalSession` (there is only ever one possible caller: the one browser). A server has more than
* one seat submitting, so THIS file is where "is `seat` actually allowed to act right now?" has to be
* checked, before `submit` is ever called — see `intent()` below.
*/
import { check } from '../engine/apply.ts';
import { legalActions } from '../engine/legal.ts';
import type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, submit } from '../web/game.ts';
import type { Game, Menu } from '../web/game.ts';
import { deltaFrame } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
import { snapshot } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
import { developerBot } from '../sim/bot.ts';
export type Push = {
/**
* Absent on a presence-only push (below) — there is no board delta to send when nothing about the
* GAME changed, and computing one just to say "unchanged" would mean tracking a last-sent frame
* for a seat that may not even be in this game's connection table (a lobby-stage notice has none).
*/
frame?: FrameDelta;
/** Non-null only for the seat that may currently act — never guess otherwise (`game.ts`'s `actionMenu` guards this too, but the host must still only compute/send it for the actor). */
menu: Menu | null;
/** Narration since the LAST push to this specific seat, not the whole game's log. */
lines: { text: string; tone: string }[];
/**
* Connection news about ANOTHER seat — never this push's own recipient. `lobby-and-sessions.md`
* §5: a disconnect is server-layer news about a connection, not a `GameEvent`, so it must not go
* through the engine or the shared narration log (which must stay replayable from a seed). Built
* and broadcast entirely by `http.ts`, which already owns the connection table; `session.ts` never
* sets this field itself — every `Push` `session.ts` builds carries a real `frame` and no
* `presence`, and `http.ts`'s presence notices carry no `frame` and no `menu`.
*/
presence?: { seat: PlayerIndex; connected: boolean };
};
/**
* `lobby-and-sessions.md` §5 — "wall-clock at the start of a turn, wall-clock at the intent that
* ends it, per player per phase." Computed entirely here, never touching the engine (which has no
* clock and must stay deterministic) and never stored inside `history` (a replay must reproduce a
* game from decisions alone).
*/
export type TurnTiming = {
player: PlayerIndex;
phase: string;
day: number;
stage: number;
startedAt: number;
endedAt: number;
};
/** Everything `persistence.ts` needs to write `game.json` and rebuild a session from it later. */
export type SavedGame = {
seed: number;
config: GameConfig;
playerNames: string[];
history: Intent[];
status: 'active' | 'finished';
createdAt: number;
/**
* Seats `developerBot` plays for, filled at `Lobby.Start` (D8 — bots fill EMPTY seats at lobby
* time only, never mid-game). Persisted so a bot seat is still a bot after a server restart —
* `resumeSession` has no other way to know, and re-deriving it from `playerNames` would mean
* guessing from a display name rather than reading a fact.
*/
botSeats: PlayerIndex[];
/**
* Wall-clock of the last accepted intent, so "has this game stalled?" survives a restart.
*
* Optional because it postdates the format, and defaulted to `createdAt` when absent — a game
* whose last move is unrecorded reads as untouched since it began, which is the honest answer
* rather than a fabricated one. Kept OUT of `history`, like the turn timings and for the same
* reason: a replay must reproduce a game from decisions alone, and wall-clock is not a decision.
*/
lastMoveAt?: number;
};
/** What an administrator needs to see about a game without replaying it themselves. */
export type GameSummary = {
playerCount: number;
playerNames: string[];
botSeats: PlayerIndex[];
status: 'active' | 'finished';
createdAt: number;
lastMoveAt: number;
day: number;
stage: number;
phase: string;
/**
* Whose move it is, or `null` — which is not an error state: the Mainline Phase runs itself, and
* a finished game waits on nobody.
*/
waitingOn: { seat: PlayerIndex; name: string } | null;
};
export type IntentResult =
| { accepted: true; pushes: Map<PlayerIndex, Push>; timing: TurnTiming | null }
| { accepted: false; code: string };
export type GameSession = {
readonly playerCount: number;
isBot(seat: PlayerIndex): boolean;
/** A new SSE connection (or a reconnect) for `seat` — always a full Frame, never a delta. */
connect(seat: PlayerIndex): Push;
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult;
/** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */
exportSave(): SavedGame;
/**
* A cheap description of where this game has got to. Deliberately does not copy `history` the
* way `exportSave` must — the health check polls this on a timer, and an administrator listing
* games wants the state of each, not a copy of every intent in all of them.
*/
summary(): GameSummary;
};
type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
function buildSession(
game: Game,
playerNames: string[],
createdAt: number,
botSeats: Set<PlayerIndex>,
lastMoveAtInit: number,
): GameSession {
let lastMoveAt = lastMoveAtInit;
const lastSeq = new Map<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>();
const openSpanFor = (now: number): OpenSpan | null => {
const actor = currentActor(game);
if (actor === null) return null;
return { player: actor, phase: game.state.clock.phase, day: game.state.clock.day, stage: game.state.clock.stage, startedAt: now };
};
let open: OpenSpan | null = openSpanFor(Date.now());
function frameFor(seat: PlayerIndex): Frame {
return snapshot(game.state, game.log, null, null, null, false, seat);
}
function linesSince(seat: PlayerIndex): { text: string; tone: string }[] {
const already = sentLines.get(seat) ?? 0;
sentLines.set(seat, game.log.length);
return game.log.slice(already);
}
function menuFor(seat: PlayerIndex): Menu | null {
return seat === currentActor(game) ? actionMenu(game, seat) : null;
}
function pushFor(seat: PlayerIndex): Push {
const frame = frameFor(seat);
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
lastFrame.set(seat, frame);
return { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
}
function pushesForAll(): Map<PlayerIndex, Push> {
const out = new Map<PlayerIndex, Push>();
for (let seat = 0; seat < playerNames.length; seat++) out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex));
return out;
}
/**
* Closes the open span if the acting player, phase, Day or Stage moved since it opened, and opens
* the next one (or none, in the automatic Mainline Phase). Returns the just-closed span, if any,
* for the caller to persist — a "turn" here is exactly the contiguous stretch where none of those
* four things changed, which can span several intents (draw, then play, then end) as one span.
*/
function settleTiming(): TurnTiming | null {
const now = Date.now();
const next = openSpanFor(now);
const same =
open !== null &&
next !== null &&
open.player === next.player &&
open.phase === next.phase &&
open.day === next.day &&
open.stage === next.stage;
if (same) return null;
const closed: TurnTiming | null = open === null ? null : { ...open, endedAt: now };
open = next;
return closed;
}
/**
* PLAY EVERY BOT SEAT FORWARD until a human is due, or the game ends.
*
* `developerBot` (D8, `lobby-and-sessions.md` §2) fills EMPTY seats at lobby time only — it never
* takes over for a disconnected human, so this loop only ever touches seats `botSeats` named at
* `Lobby.Start`. Uses `submit` directly rather than the public `intent()` path: a bot is not a
* client with a `seq` to dedupe against, and its choice is by construction always legal (`check`
* would only ever confirm what `legalActions` already promised).
*
* Intermediate bot-turn spans are swept through `settleTiming()` but their result is discarded —
* `lobby-and-sessions.md` §5's turn clock exists to learn how long HUMANS take, and a bot decides
* in zero wall-clock time by definition. Called once at construction (a resume could land exactly
* on a bot's turn) and once after every accepted human intent.
*/
function driveBots(): void {
let guard = 0;
for (;;) {
const actor = currentActor(game);
if (actor === null || !botSeats.has(actor)) return;
if (++guard > 10_000) throw new Error(`driveBots: probable infinite loop at seat ${actor}`);
const options = legalActions(game.state, actor);
if (options.length === 0) return;
const choice = developerBot.choose(game.state, actor, options);
const ok = submit(game, choice);
/* c8 ignore next -- `options` came from `legalActions`, so `choice` is always legal. */
if (!ok) throw new Error(`driveBots: developerBot chose an illegal action for seat ${actor}`);
settleTiming();
}
}
driveBots();
return {
playerCount: playerNames.length,
isBot: (seat) => botSeats.has(seat),
connect(seat) {
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection
// (or a server restart, Phase 3) — so the honest thing is a full Frame, not a delta.
lastFrame.delete(seat);
return pushFor(seat);
},
intent(seat, seq, i) {
// Idempotent resend (protocol.md §5): a repeat of an ALREADY-APPLIED seq is a no-op success,
// with no pushes to re-broadcast. A repeat after a REJECTION is not remembered here — it was
// never applied, so it is worth trying again (see the `lastSeq.set` below: only on success).
if (lastSeq.get(seat) === seq) return { accepted: true, pushes: new Map(), timing: null };
if (seat !== currentActor(game)) return { accepted: false, code: 'NOT_YOUR_TURN' };
// Checked directly, rather than via `submit`'s boolean, for two reasons: `submit` writes a
// "that is not allowed" line into the SHARED `game.log` on rejection, which would otherwise
// broadcast one seat's illegal attempt to the whole table on their next push (rejection
// feedback is private, to the submitter only); and calling `check` first means an intent
// already known to be illegal never touches the engine at all.
const code = check(game.state, seat, i);
if (code) return { accepted: false, code };
const applied = submit(game, i);
/* c8 ignore next -- `check` above already proved this intent is legal; `submit` cannot then refuse it. */
if (!applied) return { accepted: false, code: 'REJECTED' };
lastSeq.set(seat, seq);
lastMoveAt = Date.now();
const timing = settleTiming();
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
// diffs against whatever was last sent, so it captures the bots' moves along with the human's
// in one push regardless of how many turns that took.
driveBots();
return { accepted: true, pushes: pushesForAll(), timing };
},
exportSave() {
return {
seed: game.seed,
config: game.state.config,
playerNames: [...playerNames],
history: [...game.history],
status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt,
botSeats: [...botSeats],
lastMoveAt,
};
},
summary() {
const actor = currentActor(game);
return {
playerCount: playerNames.length,
playerNames: [...playerNames],
botSeats: [...botSeats],
status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt,
lastMoveAt,
day: game.state.clock.day,
stage: game.state.clock.stage,
phase: game.state.clock.phase,
waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${actor}` },
};
},
};
}
export function createSession(
seed: number,
config: GameConfig,
playerNames: string[],
botSeats: PlayerIndex[] = [],
): GameSession {
const now = Date.now();
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, now, new Set(botSeats), now);
}
/**
* Phase 3 — rebuild a session from a persisted `SavedGame` (`persistence.ts`). The engine-version
* check happens before this is ever called; by the time `saved.history` reaches here it is already
* known to have been recorded under the currently-running rules.
*/
export function resumeSession(saved: SavedGame): GameSession {
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
return buildSession(
game,
saved.playerNames,
saved.createdAt,
new Set(saved.botSeats),
saved.lastMoveAt ?? saved.createdAt,
);
}
+134 -29
View File
@@ -28,7 +28,23 @@ export type BoardTrain = { label: string; consist: string[] };
* The Division as a dispatcher would see it: one continuous line per running track, sections
* separated by thin seams, capacity legible because the lines can be counted.
*/
export function divisionSvg(nodes: DivisionView[]): string {
/**
* Who is at the table, so an Office can be labelled with its owner rather than only its tier.
*
* Passed in rather than read off the nodes because a `DivisionView` knows its seat and nothing
* about people — the roster lives on the `Frame`, keyed by player, and `seat` is what joins them.
* Optional so the standalone replay (`replay.ts`, which serialises this function by `toString()`)
* keeps working unchanged.
*/
export type DivisionRoster = {
players: { index: number; seat: number; name: string }[];
/** The player whose move it is, or null in an automatic phase. A PLAYER index, not a seat. */
actor: number | null;
/** The player this map is being drawn for. */
viewer: number;
};
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
/**
* THE WHOLE DIVISION, west to east, as one continuous route.
*
@@ -97,6 +113,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
tip: string;
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
seat: number | null;
/** Set on an Office cell when a roster was supplied: whose district this is. */
owner?: { name: string; isTurn: boolean; isYou: boolean } | null;
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
regions: number;
w: number;
@@ -116,12 +134,34 @@ export function divisionSvg(nodes: DivisionView[]): string {
if (n.kind === 'office') {
const cap = n.capacity;
const ad = n.trains.flat();
/**
* THE NAME IS THE HEADLINE, the tier is the detail.
*
* "Where does Bob sit?" is the question this map could not answer: an Office was labelled
* with its tier, which every player's Office also has, so four districts read the same. The
* owner's name takes the headline and the tier moves down beside the A/D count, because the
* name is what is being looked for and the tier is what is being referred to once found.
*/
const seatOwner =
roster && n.seat !== null ? (roster.players.find((p) => p.seat === n.seat) ?? null) : null;
const owner = seatOwner
? {
name: seatOwner.name,
isTurn: roster!.actor === seatOwner.index,
isYou: roster!.viewer === seatOwner.index,
}
: null;
for (const rc of n.running ?? []) {
const isOffice = rc.kind === 'office';
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
push({
kind: 'run',
label: rc.label,
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
label: isOffice && owner ? owner.name : rc.label,
owner: isOffice ? owner : null,
// With an owner on the headline the tier would otherwise vanish, so it joins the A/D
// count on the line below.
sub: isOffice ? (owner ? [rc.label, adLabel].filter(Boolean).join(' · ') : adLabel) : '',
/**
* A train standing at the Office occupies an A/D track, which is where it is — but it is
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
@@ -131,7 +171,12 @@ export function divisionSvg(nodes: DivisionView[]): string {
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
: rc.trains,
cap: isOffice ? cap : null,
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
tip: owner && isOffice
? `${owner.name}'s ${rc.label}` +
(owner.isYou ? ' — this is your railroad' : '') +
// "their move" is wrong when the reader is the one being waited on.
(owner.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '')
: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
seat: n.seat ?? null,
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
// occupies a card outright rather than a part of one.
@@ -151,30 +196,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
continue;
}
const dp = n.kind === 'dp';
/**
* A train in the Interchange's yard is drawn on the card but counted against nothing.
*
* It is not on the running line — that is the whole distinction §7 rests on — so it cannot take
* the card's capacity. It still has to be SEEN: an Extra made up here would otherwise be a train
* the player just placed that appears nowhere on the map.
*/
const inYard = n.yard ?? [];
const onRoad = n.trains.flat();
const free = n.capacity === null ? '' : `${Math.max(0, n.capacity - onRoad.length)} of ${n.capacity} free`;
push({
kind: dp ? 'dp' : 'ml',
label: n.label,
sub: n.capacity === null
? 'no limit — trains queue'
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
trains: [...onRoad, ...inYard],
sub: n.capacity === null ? 'no limit — trains queue' : `${Math.max(0, n.capacity - n.trains.flat().length)} of ${n.capacity} free`,
trains: n.trains.flat(),
cap: n.capacity,
tip: dp
? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit'
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}` +
(inYard.length > 0
? `\n\n${inYard.length} train${inYard.length === 1 ? '' : 's'} standing in the yard, not on the running line — waiting to highball onto this card`
: '') +
// What the card actually DOES. The name alone left Hilly and Uncontrolled Siding as
// words with no gameplay attached — reported exactly that way.
(n.what ? `\n\n${n.what}` : ''),
@@ -294,7 +324,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
const full = c.cap !== null && c.trains.length >= c.cap;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
out += `<text class="bs-name" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label)}</text>`;
/**
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
* than by a legend. Amber is the same "it is happening here" the action panel uses; "(you)"
* is spelled out because a colour alone cannot say which of four railroads is the reader's,
* and that is the first thing anybody wants to know at a table they just sat down at.
*/
const mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : '';
const suffix = c.owner?.isYou ? ' (you)' : '';
out += `<text class="bs-name${mark}" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label + suffix)}</text>`;
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
@@ -489,6 +527,65 @@ export function officeSvg(
return out;
};
/**
* A 45° LEG, drawn as a smooth curve rather than two straight segments meeting at a hard corner.
*
* It is an EASEMENT, not an arbitrary curve: tangent to horizontal at `p0` (the east or west edge),
* so an abutting straight card's through-rail still reads as one unbroken line, and tangent to
* exactly 45° at `p3` (the north or south edge), so two stacked curves still read as one continuous
* diagonal and the edge-crossing angle the matching rule depends on is unchanged. `p1`/`p2` are
* cubic-Bezier control points chosen to hit those two tangents — see the call site.
*
* Sampled as a short polyline rather than left as one SVG path command, because the two parallel
* rails and the tie marks all need points evenly spaced ALONG THE CURVE with the local tangent at
* each one — `rail()`'s straight-line version does the same sampling, just on a line.
*/
const curvedRail = (
p0: { x: number; y: number },
p1: { x: number; y: number },
p2: { x: number; y: number },
p3: { x: number; y: number },
): string => {
const at = (t: number): { x: number; y: number } => {
const u = 1 - t;
return {
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
};
};
const tangentAt = (t: number): { x: number; y: number } => {
const u = 1 - t;
return {
x: 3 * u * u * (p1.x - p0.x) + 6 * u * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x),
y: 3 * u * u * (p1.y - p0.y) + 6 * u * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y),
};
};
const N = 24;
const left: string[] = [];
const right: string[] = [];
let ties = '';
for (let i = 0; i <= N; i++) {
const t = i / N;
const pt = at(t);
const tan = tangentAt(t);
const tlen = Math.hypot(tan.x, tan.y) || 1;
const nx = (-tan.y / tlen) * 2.5;
const ny = (tan.x / tlen) * 2.5;
left.push(`${pt.x + nx} ${pt.y + ny}`);
right.push(`${pt.x - nx} ${pt.y - ny}`);
// Every third sample — the same rough 9px spacing `rail()` uses for a straight run of similar
// length, not tied to `N` itself.
if (i % 3 === 0) {
ties += `<line class="bs-tie" x1="${pt.x + nx * 1.8}" y1="${pt.y + ny * 1.8}" x2="${pt.x - nx * 1.8}" y2="${pt.y - ny * 1.8}"/>`;
}
}
return (
`<path class="bs-rail" fill="none" d="M${left.join(' L')}"/>` +
`<path class="bs-rail" fill="none" d="M${right.join(' L')}"/>` +
ties
);
};
// Where each port meets the card edge. East and west sit at the rail height — the card's middle —
// so a straight run stays straight across the whole row; north and south are centred on the edge.
const port = (p: string): { x: number; y: number } => {
@@ -549,19 +646,23 @@ export function officeSvg(
out += rail(port(from).x, port(from).y, port(to).x, port(to).y);
} else {
/**
* A 45° LEG, drawn as the card prints it: along the centre line from the east or west edge
* to the FROG, then out at exactly 45° through the middle of the north or south edge.
* A 45° LEG — drawn as a smooth curve (v0.5.0) that still passes through the same FROG the
* old two-segment version bent at, so the underlying geometry (and the `frog` position used
* elsewhere for the actual joining/matching rules) is unchanged; only the picture is.
*
* The frog is `H/2` from the centre because a 45° run climbing half the card's height
* travels half its height sideways. This used to be a fixed elbow at (W/2, RAIL+(H-RAIL)/2)
* — below the rail on the assumption everything diverged downward — which drew a leg
* reaching north as a hook that dropped past the rail and came back up, and drew nothing at
* any angle the matching rule cares about.
* travels half its height sideways. The control points sit halfway from each endpoint to the
* frog, which is what gives the curve a horizontal tangent at the e/w edge (matching an
* abutting straight card's through-rail) and an exact 45° tangent at the n/s edge (matching
* the card above or below) — see `curvedRail`.
*/
const side = vertical === from ? to : from;
const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL };
const start = port(side);
const edge = port(vertical);
out += rail(port(side).x, port(side).y, frog.x, frog.y) + rail(frog.x, frog.y, edge.x, edge.y);
const c1 = { x: start.x + 0.5 * (frog.x - start.x), y: start.y };
const c2 = { x: edge.x + 0.5 * (frog.x - edge.x), y: edge.y + 0.5 * (frog.y - edge.y) };
out += curvedRail(start, c1, c2, edge);
}
}
@@ -1042,6 +1143,10 @@ export const BOARD_CSS = `
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
seconds and which railroad is yours never does. */
.bs-name.bs-turn{fill:#f0b64a;font-weight:700}
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
+24 -11
View File
@@ -31,6 +31,12 @@
import { pump } from '../engine/advance.ts';
import { createGame } from '../engine/setup.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
collectiveRevenueFloor,
lengthProfile,
} from '../engine/content.ts';
import type { GameLength } from '../engine/content.ts';
import type { GameConfig, GameMode } from '../engine/state.ts';
import type { BotPolicy, BotTweaks } from './bot.ts';
@@ -56,17 +62,24 @@ export type PairedResult = {
variantStats: GameStats[];
};
const SOLO = (length: GameLength, mode: GameMode): GameConfig => ({
mode,
victory: 'highestAfterDays',
length,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
});
// Always one bot (`runOne` below), so the revenue floor is `collectiveRevenueFloor(1, days)`.
const SOLO = (length: GameLength, mode: GameMode): GameConfig => {
const days = lengthProfile(length).days;
return {
mode,
days,
minCombinedRevenue: collectiveRevenueFloor(1, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
};
/**
* One game, one seed, one policy.
+53
View File
@@ -0,0 +1,53 @@
/**
* Live per-seat Frame delta — Phase 2 of `docs/architecture/multiplayer.md`.
*
* `src/sim/replay.ts`'s `compress()` looked like the thing to reuse here (D2/D3's "2.9 KB per push
* with the board omitted when unchanged" cites it) but it solves a different problem: it interns
* card/description strings across a WHOLE recorded array of frames, which only pays off when
* bundling many frames into one replay file. A live server pushes one frame at a time and has
* nothing to intern against. The part that genuinely carries over is much smaller — a one-step-back
* "null if unchanged since the last thing sent to THIS seat" check on the three fields that make up
* almost all of a Frame's size: `cells`, `facilities`, `division` (`replay.ts`'s own `keys` array).
*
* Node-free by design, unlike `replay.ts` (which imports `node:fs`) — both the server and a browser
* `RemoteSession` import this file directly.
*/
import type { CellView, DivisionView, FacilityView, Frame } from './view.ts';
/** A `Frame` with the three board-shaped fields replaced by `null` where unchanged since `previous`. */
export type FrameDelta = Omit<Frame, 'cells' | 'facilities' | 'division'> & {
cells: CellView[] | null;
facilities: FacilityView[] | null;
division: DivisionView[] | null;
};
const BOARD_KEYS = ['cells', 'facilities', 'division'] as const;
/**
* `previous` is the last Frame actually sent to THIS seat, or `null` for a first connect / a
* reconnect after a gap — Phase 2 has no persistence to replay a gap against (Phase 3), so a
* reconnect always gets a full Frame here rather than a delta.
*/
export function deltaFrame(previous: Frame | null, next: Frame): FrameDelta {
const out = { ...next } as unknown as FrameDelta;
for (const key of BOARD_KEYS) {
const unchanged = previous !== null && JSON.stringify(previous[key]) === JSON.stringify(next[key]);
(out as Record<string, unknown>)[key] = unchanged ? null : next[key];
}
return out;
}
/** The receiving side: merges a delta back onto the last full Frame this seat actually has. */
export function applyDelta(previous: Frame | null, delta: FrameDelta): Frame {
const out = { ...delta } as unknown as Frame;
for (const key of BOARD_KEYS) {
if (delta[key] === null) {
if (previous === null) {
throw new Error(`deltaFrame said "${key}" is unchanged, but there is no previous Frame to merge onto`);
}
(out as Record<string, unknown>)[key] = previous[key];
}
}
return out;
}
+15 -6
View File
@@ -12,7 +12,12 @@
*/
import { pump } from '../engine/advance.ts';
import { collectiveRevenueFloor, lengthProfile } from '../engine/content.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
collectiveRevenueFloor,
lengthProfile,
} from '../engine/content.ts';
import { createGame } from '../engine/setup.ts';
import type { GameLength } from '../engine/content.ts';
import type { GameConfig, GameMode } from '../engine/state.ts';
@@ -60,11 +65,15 @@ function statsOf(xs: number[]): Stats {
};
}
function configFor(mode: GameMode, length: GameLength): GameConfig {
function configFor(mode: GameMode, length: GameLength, players: number): GameConfig {
const days = lengthProfile(length).days;
return {
mode,
victory: 'highestAfterDays',
length,
days,
minCombinedRevenue: collectiveRevenueFloor(players, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -83,7 +92,7 @@ export function simulate(opts: SimOptions): SimReport {
const s = createGame({
id: `sim-${i}`,
seed,
config: configFor(opts.mode, opts.length),
config: configFor(opts.mode, opts.length, opts.players.length),
playerNames: opts.players,
});
// The probe watches the game as it is played: the funnel gates are conditions at a moment, not
@@ -154,7 +163,7 @@ export function formatReport(r: SimReport, length: GameLength, players: number):
// The provisional numbers this exists to test.
out.push('\n against the provisional targets:');
out.push(` target ${profile.target} over ${profile.days} Days`);
out.push(` ${profile.days} Days`);
out.push(
` predicted ~5-6 revenue/player/Day steady state; observed mean ` +
`${r.revenuePerPlayerPerDay.mean.toFixed(1)}`,
+8 -34
View File
@@ -17,7 +17,7 @@
import { MAX_CONSIST } from '../engine/content.ts';
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
@@ -34,20 +34,11 @@ export function clockTime(stage: number): string {
return CLOCK[stage - 1] ?? `Stage ${stage}`;
}
/**
* `homeSeat` is the district the page is being drawn for. Give it, and a load THIS district made
* says so — the printed game's answer is to turn the chip upside down in the tray, and this is the
* screen's. A load may not be broken in the Office Area that made it (state.ts `RollingStock.origin`),
* so "loaded here" is the difference between a boxcar worth switching and one that has to leave the
* district first. Omit it and the label is what it always was, which is what the replay viewers and
* the history lines want: they describe a board, not a seat's view of one.
*/
export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
export function carLabel(c: RollingStock): string {
// A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even
// though the supply marks every caboose loaded. Name it plainly.
if (c.type === 'caboose') return 'caboose';
const label = `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
}
export function carsLabel(cars: RollingStock[]): string {
@@ -255,31 +246,14 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'bad',
text: `SECOND SECTION ordered on Train ${e.trainNumber} — an identical train will run right behind it, which forces the Superintendent to rule on a following train (§8.1)`,
};
/**
* WHERE THE PLAYER PUT IT, not where its number would have sent it.
*
* An Extra's direction comes from its start now (§7, Jesse's ruling), so the line that used to
* explain the number's parity would be explaining a rule that no longer applies to this train.
*/
case 'extraStarted': {
const where =
e.at.kind === 'divisionPoint'
? `the ${e.at.side === 'west' ? 'Western' : 'Eastern'} Division Point`
: e.at.kind === 'mainline'
? "the Interchange's yard"
: `the Control Point in seat ${e.at.seat}`;
const why =
e.at.kind === 'divisionPoint'
? 'the end it runs away from — an Extra may start at either, and the end chooses the run'
: e.at.kind === 'mainline'
? 'made up off the running line, so it highballs onto the Mainline once the Subdivision ' +
'is clear and may be held in the yard until it is'
: 'an Extra may begin at any Office above a Whistle Post, and the player chooses the run';
case 'extraStarted':
return {
tone: 'good',
text: `EXTRA X${e.trainNumber} started at ${where}, running ${e.direction} — ${why}`,
text:
e.atSeat === null
? `EXTRA X${e.trainNumber} started at the ${e.trainNumber % 2 === 0 ? 'Western' : 'Eastern'} Division Point, running ${e.trainNumber % 2 === 0 ? 'east' : 'west'} — odd numbers run west and even run east (§2.3), so its number chose the end`
: `EXTRA X${e.trainNumber} started at the Control Point in seat ${e.atSeat}, running ${e.trainNumber % 2 === 0 ? 'east' : 'west'} — an Extra may begin at any Office above a Whistle Post instead of at a Division Point`,
};
}
case 'trainMadeUp':
return {
tone: 'good',
+20 -6
View File
@@ -21,7 +21,14 @@ import { writeFileSync } from 'node:fs';
import { advance } from '../engine/advance.ts';
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import type { GameLength } from '../engine/content.ts';
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
MAINLINE_PROFILES,
collectiveRevenueFloor,
lengthProfile,
officeProfile,
} from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
@@ -51,10 +58,14 @@ import { playCue } from '../web/sound.ts';
export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };
export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
const days = lengthProfile(length).days;
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length,
days,
minCombinedRevenue: collectiveRevenueFloor(1, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -263,7 +274,7 @@ export function rehydrateCells(
}
export function renderHtml(rec: Recording): string {
const target = lengthProfile(rec.length);
const profile = lengthProfile(rec.length);
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
@@ -360,7 +371,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
</style></head><body>
<header>
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (${profile.days} Days) · ${esc(rec.outcome)}</h1>
<div class="bar">
<span class="dim">fedora <span id="super">—</span></span>
<span>revenue <b class="big" id="rev">0</b></span>
@@ -502,7 +513,10 @@ function render() {
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
$('division').innerHTML = divisionSvg(DIV);
// players, actor and viewer are not among the delta'd keys (see compress), so they ride whole on
// every frame and the replay names the districts exactly as the live page does. No backticks in
// this comment: it is inside the generated-page template literal, which they would terminate.
$('division').innerHTML = divisionSvg(DIV, { players: f.players, actor: f.actor, viewer: f.viewer });
// The same office renderer the playable app uses, so replay and game draw one board.
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
+85 -74
View File
@@ -18,14 +18,13 @@ import {
laborersLeft,
movesFor,
ownCutFor,
isTrainCard,
portersLeft,
resolveExtraStart,
selectDestination,
} from '../engine/apply.ts';
import {
ACTION_CARDS,
ENHANCEMENT_CARDS,
HAND_LIMIT,
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
@@ -39,7 +38,6 @@ import {
industryProfile,
mainlineProfile,
modifierProfile,
lengthProfile,
officeProfile,
trainProfile,
houseRules,
@@ -309,14 +307,6 @@ export type DivisionView = {
/** Office nodes only: whose district this is. */
/** Which SEAT's district this is — a position on the Division, not a player. */
seat?: number;
/**
* Interchange only: trains standing in its yard, not out on the running line (state.ts).
*
* Separate from `trains` for the same reason `switching` is separate from an Office's A/D list —
* they are not occupying the thing whose capacity is being counted. An Extra made up here has to
* be VISIBLE, though, or the player who placed it has a train that exists nowhere on the map.
*/
yard?: TrainChip[];
/**
* Office nodes only: crews working BELOW the Running Track.
*
@@ -358,6 +348,18 @@ export type Frame = {
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
*/
houseRules: HouseRules;
/**
* The victory-condition dials this game was configured with (`GameConfig`, `state.ts`), plus the
* running collision counts — same reasoning as `houseRules`: a remote client holds no `GameState`
* and needs to show live progress ("2 of 3 collisions today") without guessing a default. `0` on
* any `max*`/`minCombinedRevenue` field means that check is off.
*/
days: number;
minCombinedRevenue: number;
maxCollisionsPerDay: number;
maxCollisionsTotal: number;
collisionsToday: number;
collisionsTotal: number;
status: GameState['status'];
outcome: GameState['outcome'];
/**
@@ -368,8 +370,32 @@ export type Frame = {
* player order once §4.4's D12 decided who sits where.
*/
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
/**
* WHO THIS FRAME WAS BUILT FOR.
*
* Every private thing on a Frame is already scoped to one player — the hand, the Office Area,
* `revenue`, `option`, `movesLeft` — but nothing said which player that was, so a page rendering
* it could show a railroad without being able to say whose it is. Harmless in solitaire, where
* there is only one; the first thing you want to know at a four-player table.
*/
viewer: number;
/** The viewer's position in the west-to-east chain, which is not their player index (§4.4). */
viewerSeat: number;
/**
* §4.4's opening D12 per player, and the roll that chose the Superintendent — kept so a client
* can show the chain being formed rather than only its result (`lobby-and-sessions.md` §4).
* Indexed by player, like `s.players`, not by seat.
*/
openingRolls: { division: number[]; superintendent: number[] };
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
handCount: number;
/**
* True when the VIEWER's hand is over §6.2's limit and their turn cannot end until it is played
* down. Duplicates `game.ts`'s `overHandLimit(game, seat)` at the engine-data level rather than
* importing the web layer here — a `RemoteSession` (Phase 2) has no `GameState` to compute this
* from, only a `Frame`, so it has to already be resolved on the wire.
*/
overHandLimit: boolean;
lines: { text: string; tone: string }[];
where: { row: number; col: number } | null;
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
@@ -427,15 +453,6 @@ export type Frame = {
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
handWhat: string[];
/**
* Whether each hand card may be DISCARDED, in the same order.
*
* §6.2 as ruled by Jesse (Gitea#6): a train card never can be. The player has to be told which
* cards those are, not merely find that a button is missing — that silence is the whole of the
* Gitea#2 complaint, where a blocked platform left the board with nothing to click and no reason.
* Named for the rule rather than for trains, since it answers the question the panel is asking.
*/
handDiscardable: boolean[];
deck: number;
/** The face-up card on top of each Department pile — the only one that may be drawn. */
departments: string[];
@@ -527,7 +544,6 @@ const FACILITY_NAMES: Record<string, string> = {
function facilityView(
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
officeName: string,
viewerSeat: SeatIndex,
): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
// Passenger facilities were excluded entirely, so the Office's green and red slots never
@@ -549,9 +565,7 @@ function facilityView(
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
red: f.inboundBox.map(carLabel),
redCap: f.capacity.inbound,
// Marked when this district made the load: the spotted car is exactly where a player is looking
// when they ask why the Laborer will not unload it.
track: f.industryTrack.cars.map((c) => carLabel(c, viewerSeat)),
track: f.industryTrack.cars.map(carLabel),
laborers: `${laborersLeft(f)}/${f.laborers}`,
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
@@ -642,9 +656,7 @@ function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellVie
out.push({
trayId: id,
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
// A coach filled at THIS Office reads "loaded coach (loaded here)" — those passengers may not
// alight in the district that boarded them, and the tray is where a player looks for that.
cars: t.consist.map((c) => carLabel(c, viewerSeat)),
cars: t.consist.map(carLabel),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: railFacingOf(t),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
@@ -700,11 +712,6 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
}
/** " onto Train 8", or nothing at all when the intent names no train (an old save, or one train). */
function onto(s: GameState, trayId: string | undefined, joiner: string): string {
return trayId === undefined ? '' : `${joiner}${trainName(s, trayId)}`;
}
/** One readable line for a single intent. */
export function describeIntent(s: GameState, i: Intent): string {
// X,Y — east/west then north/south, not the internal row/col storage order.
@@ -884,39 +891,18 @@ export function describeIntent(s: GameState, i: Intent): string {
return `advance load in box ${i.box} at ${at(i.at)}`;
case 'laborer.beginUnload':
return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
/**
* NAME THE TRAIN. The action list drops duplicate labels within a crew, and with two trains
* standing at one station "board passengers at (0,0)" describes both — which is half of why the
* v0.4.9d playtest found that picking a train changed nothing. The intent now carries the tray;
* the label has to say so or the second button is thrown away before the menu sees it.
*/
case 'porter.board':
return `board passengers at ${at(i.at)}${onto(s, i.trayId, ' onto ')}`;
return `board passengers at ${at(i.at)}`;
case 'porter.detrain':
return `detrain passengers at ${at(i.at)}${onto(s, i.trayId, ' from ')}`;
/**
* NAME THE PLACE AND THE DIRECTION, because the player is choosing both.
*
* This used to explain why the Extra had no choice — "it runs west, so that is the end it
* starts from". It has one now (§7, Jesse's ruling), and every candidate is on screen at once,
* so each label has to be distinguishable from its three or four siblings at a glance.
*/
return `detrain passengers at ${at(i.at)}`;
case 'newTrain.startExtra': {
const where = resolveExtraStart(s, s.clock.currentActor ?? 0, i);
if (typeof where === 'string') return `start Extra X${i.trainNumber}`;
const { direction } = where;
if (where.at.kind === 'divisionPoint') {
const end = where.at.side === 'west' ? 'Western' : 'Eastern';
return `start Extra X${i.trainNumber} at the ${end} Division Point — it runs ${direction} from there`;
const runs = i.trainNumber % 2 === 0 ? 'east' : 'west';
if (i.atSeat === null) {
const end = i.trainNumber % 2 === 0 ? 'Western' : 'Eastern';
return `start Extra X${i.trainNumber} at the ${end} Division Point — it runs ${runs}, so that is the end it starts from`;
}
if (where.at.kind === 'mainline') {
return (
`start Extra X${i.trainNumber} ${direction}bound in the Interchange — it is made up in the ` +
'yard and highballs onto the Mainline once the Subdivision is clear'
);
}
const tier = officeProfile(areaAtSeat(s, where.at.seat).tier).name;
return `start Extra X${i.trainNumber} ${direction}bound at the ${tier} in seat ${where.at.seat} — a Control Point, so it may begin its run there`;
const tier = officeProfile(areaAtSeat(s, i.atSeat).tier).name;
return `start Extra X${i.trainNumber} at the ${tier} in seat ${i.atSeat} — a Control Point, so it may begin its ${runs}bound run there instead`;
}
case 'newTrain.placeCar':
@@ -1057,7 +1043,7 @@ export function snapshot(
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = geometryLabel(g.geometry);
const fv = facilityView(card as never, officeProfile(area.tier).name, viewerSeat);
const fv = facilityView(card as never, officeProfile(area.tier).name);
if (fv) facilities.push(fv);
cells.push({
@@ -1072,7 +1058,7 @@ export function snapshot(
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
trains: trainsOnCard(s, viewerSeat, key),
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
cars: carsOn(card).map((c) => carLabel(c, viewerSeat)),
cars: carsOn(card).map(carLabel),
standingWest: card.standingWest,
facility: fv,
});
@@ -1141,9 +1127,6 @@ export function snapshot(
};
})],
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
// Drawn at the start of the card: the yard is beside the rail, and this is the end the
// train will pull out of. It counts against nothing — see `yard` on DivisionView.
yard: (n.holding ?? []).map((id) => ({ ...trainChip(s, id), region: 0 })),
modifiers: [
...(n.modifiers ?? []).map(prettyKey),
...(n.absSignals ? ['ABS Signals'] : []),
@@ -1232,7 +1215,6 @@ export function snapshot(
*/
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => !isTrainCard(s, id)),
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((pile) => {
const top = pile[pile.length - 1];
@@ -1261,6 +1243,12 @@ export function snapshot(
wasted,
option: turnOf(s, viewer).option,
houseRules: houseRules(s.config),
days: s.config.days,
minCombinedRevenue: s.config.minCombinedRevenue,
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
maxCollisionsTotal: s.config.maxCollisionsTotal,
collisionsToday: s.collisionsToday,
collisionsTotal: s.collisionsTotal,
status: s.status,
outcome: s.outcome,
players: s.players.map((p) => ({
@@ -1270,7 +1258,15 @@ export function snapshot(
revenue: p.revenue,
hand: (s.decks.hands.get(p.index) ?? []).length,
})),
viewer,
viewerSeat,
openingRolls: {
division: [...s.openingRolls.division],
superintendent: [...s.openingRolls.superintendent],
},
handCount: (s.decks.hands.get(viewer) ?? []).length,
overHandLimit:
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
objective: objectiveOf(s, viewer),
runningRow: area.runningRow,
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
@@ -1687,21 +1683,36 @@ const SIMPLE_CARDS = [
...ACTION_CARDS,
];
/** The goal, and whether the VIEWER's score is keeping up with the clock. */
/**
* The goal, and whether the VIEWER's score is keeping up with the clock.
*
* `target` is `config.minCombinedRevenue` now (2026-08-20) — the floor below which everyone loses,
* not a per-player win threshold; `days` and `daysLeft` come off `config.days`. Paced against the
* VIEWER's own Revenue, same as before: exact for solitaire (the viewer IS the whole table), an
* approximation for competitive/coop until Phase 2 gives the objective panel a combined-progress
* view of its own. `0` means no floor is configured — nothing to pace against.
*/
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
const profile = lengthProfile(s.config.length);
const { days, minCombinedRevenue: target } = s.config;
const revenue = s.players[viewer]?.revenue ?? 0;
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
const elapsed = profile.days - daysLeft + 1;
const daysLeft = Math.max(0, days - s.clock.day + 1);
const elapsed = days - daysLeft + 1;
if (target <= 0) {
const note =
daysLeft === 0
? 'the last Day is over'
: `${revenue} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · no minimum this game`;
return { target: 0, days, daysLeft, onPace: true, note };
}
// Straight-line pace: by the end of Day N you want N/days of the target.
const expected = (profile.target * elapsed) / profile.days;
const expected = (target * elapsed) / days;
const onPace = revenue >= expected;
const note =
daysLeft === 0
? 'the last Day is over'
: `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
: `${revenue} of ${target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
(onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
return { target: profile.target, days: profile.days, daysLeft, onPace, note };
return { target, days, daysLeft, onPace, note };
}
/**
+129 -5
View File
@@ -44,9 +44,13 @@ import {
variantLabel,
} from '../sim/view.ts';
import {
DEFAULT_DAYS,
DEFAULT_HOUSE_RULES,
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
HAND_LIMIT,
LEGACY_HOUSE_RULES,
collectiveRevenueFloor,
houseRules,
mainlineProfile,
trainProfile,
@@ -57,10 +61,19 @@ import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.t
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
import type { Frame } from '../sim/view.ts';
/**
* The canonical "what does a fresh solitaire game look like" config — also what the New Game
* dialog's solitaire defaults are drawn from (`main.ts`). One player, so `minCombinedRevenue` uses
* `collectiveRevenueFloor(1, DEFAULT_DAYS)` — the same formula multiplayer configs use, just at
* player count 1.
*/
export const SOLO_CONFIG: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
days: DEFAULT_DAYS,
minCombinedRevenue: collectiveRevenueFloor(1, DEFAULT_DAYS),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -72,9 +85,56 @@ export const SOLO_CONFIG: GameConfig = {
houseRules: DEFAULT_HOUSE_RULES,
};
/**
* The lobby's (`lobby.ts`) starting point for a Competitive or Co-op `Lobby.Create` — same formula
* `SOLO_CONFIG` uses, at a nominal player count. `minCombinedRevenue` is necessarily a guess at
* create time: nobody is seated yet, so there is no real headcount to size it against. It stays a
* guess rather than being fixed up at `Lobby.Start`, matching what the New Game dialog already told
* players before there was a lobby at all ("assumes a 4-player table until there's a lobby to ask
* who's actually seated") — Phase 5 or a follow-up can revisit sizing it to the seats actually
* filled once that is worth the complexity.
*/
export function defaultMultiplayerConfig(mode: 'competitive' | 'coop', players = 4): GameConfig {
return {
mode,
days: DEFAULT_DAYS,
minCombinedRevenue: collectiveRevenueFloor(players, DEFAULT_DAYS),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: mode === 'competitive',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
houseRules: DEFAULT_HOUSE_RULES,
};
}
/**
* Everything the New Game dialog can set for a solitaire game — the opening deal and the three
* revenue rates (`houseRules`, unchanged), plus the four victory-condition dials added 2026-08-20.
* Solitaire's `pvpCardsAllowed` is not here: it is forced off (`SOLO_CONFIG`), never a player choice.
*/
export type NewGameOptions = {
houseRules?: HouseRuleOverrides;
days?: number;
minCombinedRevenue?: number;
maxCollisionsPerDay?: number;
maxCollisionsTotal?: number;
};
/** The same config with the New Game dialog's answers in it. */
export function configWith(rules: HouseRuleOverrides): GameConfig {
return { ...SOLO_CONFIG, houseRules: houseRules({ houseRules: rules }) };
export function configWith(opts: NewGameOptions): GameConfig {
return {
...SOLO_CONFIG,
days: opts.days ?? SOLO_CONFIG.days,
minCombinedRevenue: opts.minCombinedRevenue ?? SOLO_CONFIG.minCombinedRevenue,
maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay,
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
houseRules: houseRules(opts.houseRules ? { houseRules: opts.houseRules } : {}),
};
}
/** A group of legal actions of one kind, ready to put on screen. */
@@ -237,6 +297,23 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
return game;
}
/**
* `newGame`'s multi-player sibling — the server session host (Phase 2) needs a `Game` wrapper for
* more than one seat, and `newGame` hardcodes `[SOLO_PLAYER]`. Kept as a separate function rather
* than a shared parametrized helper: `newGame` is exercised by every solitaire test and save, and
* reordering its log-then-drain sequence to share code with this risks nothing for a marginal DRY
* gain. `submit`/`drain`/`actionMenu`/`currentActor` all work on either unchanged, since none of them
* know or care how many players a `Game` has.
*/
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
game.log.push({ text: 'Game Begins', tone: 'start' });
game.log.push({ text: `${config.mode} · ${playerNames.length} players · seed ${seed}`, tone: 'quiet' });
drain(game);
return game;
}
/**
* Run the engine forward until it needs a decision.
*
@@ -530,7 +607,16 @@ export type Menu = {
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
const { options, groups } = actionGroups(game);
/**
* SEAT-SAFETY. `actionGroups`/`legalActions` answer for `currentActor(game)`, not for `seat` — there
* is exactly one acting player at a time, so a Menu built for anyone else must show no actions at
* all, only their own hand (found the hard way: calling this for a non-acting seat used to hand
* back the ACTOR's legal moves paired with the WRONG seat's cards). Emptying `options`/`groups` here
* degrades every downstream computation (`direct`, `placeable`, `makeUp`) to empty for free — the
* hand section below still reads `seat`'s own cards correctly either way.
*/
const isActor = seat === currentActor(game);
const { options, groups } = isActor ? actionGroups(game) : { options: [] as Intent[], groups: [] as ActionGroup[] };
const direct: ActionGroup[] = [];
const placeableByTitle = new Map<string, Map<string, Placeable>>();
@@ -1079,3 +1165,41 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
}
return game;
}
/**
* `fromSave`'s multi-player sibling — Phase 3 (`docs/architecture/multiplayer.md`): "a mid-game
* server restart is a replay rather than a recovery" (`lobby-and-sessions.md` §6). Built on
* `newMultiplayerGame` instead of `newGame` since a saved multiplayer game did not deal to
* `[SOLO_PLAYER]`. The engine-version check belongs to the caller (`src/server/persistence.ts`) —
* this function only ever reconstructs from history that is already known to have been recorded
* under the currently-running rules.
*
* UNLIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
* resumed game hits every time. `fromSave` has the same gap (it predates multiplayer and nothing ever
* compares its output against a LIVE-played log, so it has gone unnoticed — `undo`'s rebuilt game is
* itself `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compares one
* unattributed replay against another). Flagged in `TODO.md` rather than fixed there in this pass —
* out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a
* touch-in-passing.
*/
export function fromMultiplayerSave(
seed: number,
config: GameConfig,
playerNames: string[],
history: Intent[],
): Game {
const game = newMultiplayerGame(seed, config, playerNames);
for (const intent of history) {
const actor = currentActor(game);
if (actor === null) break;
const result = applyIntent(game.state, actor, intent);
if (!result.ok) break;
game.history.push(intent);
record(game, result.events, actor);
drain(game);
}
return game;
}
+8 -6
View File
@@ -29,6 +29,8 @@ a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
.door.disabled .go{color:var(--dim)}
a.door.disabled{pointer-events:none}
a.door.disabled:hover{border-color:var(--line);background:var(--panel);transform:none}
.lightbox{position:fixed;inset:0;background:rgba(8,10,13,.92);display:flex;
align-items:center;justify-content:center;padding:32px;z-index:10;cursor:zoom-out}
.lightbox[hidden]{display:none}
@@ -69,13 +71,13 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</div>
<div class="doors">
<div class="door disabled">
<a class="door" id="door-multiplayer" href="./play.html?lobby">
<h2>Play multiplayer</h2>
<p>Play with your friends across the internet. You each run your own office area within the
entire division. You can play a normal game, or choose co-op or cutthroat. This requires
a Station Master server to be running.</p>
<span class="go">Coming soon</span>
</div>
<p id="door-multiplayer-blurb">Play with your friends across the internet. You each run your
own office area within the entire division. You can play a normal game, or choose co-op or
cutthroat.</p>
<span class="go" id="door-multiplayer-go">Set up a game &rarr;</span>
</a>
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
+192
View File
@@ -0,0 +1,192 @@
/**
* The lobby screen — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20).
*
* Everything in `#lobby` (`play.html`) is owned here: the join-secret gate, creating or joining a
* game by code, and the seating screen up to `Lobby.Start`. `main.ts` calls `runLobby` once, at
* `start()`, only when there is no stored session to reconnect with — see `main.ts`'s own comment
* on why a stored `{token, gameId, seat}` skips this module entirely.
*
* MIRRORS SERVER TYPES RATHER THAN IMPORTING THEM, same choice `web/session.ts` already made for
* `Push`: this file must never depend on anything under `src/server/`, even at the type level, since
* it ships to the browser and the server does not.
*/
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { defaultMultiplayerConfig } from './game.ts';
export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex };
type LobbySeat = { kind: 'human'; token: string; displayName: string } | { kind: 'bot' } | null;
type Lobby = {
gameId: string;
gameCode: string;
hostToken: string;
config: GameConfig;
seats: LobbySeat[];
joinOrder: string[];
createdAt: number;
};
type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean };
/** Per-origin, same reasoning `lobby-and-sessions.md` §1 gives for the session token itself — a
* secret typed at one address means nothing at another. */
const SECRET_KEY = 'stationmaster-joinsecret';
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => document.getElementById(id) as T;
async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}
/**
* Shows `#lobby`, drives it through creating or joining a game and then seating, and calls
* `onReady` exactly once — the instant `Lobby.Start` fires, from WHICHEVER browser tab started it.
* Never calls back more than once; the caller is expected to tear this screen down (`main.ts` hides
* `#lobby` and shows `#gameui`) as its very first action inside `onReady`.
*/
export function runLobby(onReady: (r: LobbyReady) => void): void {
$('lobby').hidden = false;
$<HTMLInputElement>('lb-secret').value = localStorage.getItem(SECRET_KEY) ?? '';
let source: EventSource | null = null;
function setError(id: string, message: string): void {
$(id).textContent = message;
}
function secret(): string {
const value = $<HTMLInputElement>('lb-secret').value;
localStorage.setItem(SECRET_KEY, value);
return value;
}
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
$('lb-gamecode').textContent = lobby.gameCode;
const isHost = lobby.hostToken === token;
let html = '';
for (let seat = 0; seat < lobby.seats.length; seat++) {
const occupant = lobby.seats[seat] ?? null;
const isYou = occupant?.kind === 'human' && occupant.token === token;
const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken;
const who =
occupant === null
? '<span class="dim">— waiting —</span>'
: occupant.kind === 'bot'
? 'Bot'
: `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`;
let action = '';
if (isHost) {
if (occupant === null) action = `<button class="lb-bot-add" data-seat="${seat}">+ bot</button>`;
else if (occupant.kind === 'bot') action = `<button class="lb-bot-remove" data-seat="${seat}">remove bot</button>`;
}
html += `<div class="lb-seat"><span class="dim">Seat ${seat}</span><span class="who">${who}</span>${action}</div>`;
}
$('lb-seats').innerHTML = html;
/**
* The code is the whole invitation, so it has to leave this screen by some route other than
* being read off it and retyped. `navigator.clipboard` is unavailable on an insecure origin
* and can be refused outright, so a failure says the code is there to be selected rather than
* silently doing nothing.
*/
const copyBtn = $<HTMLButtonElement>('lb-copy');
copyBtn.onclick = () => {
const say = (m: string): void => {
$('lb-copied').textContent = m;
setTimeout(() => ($('lb-copied').textContent = ''), 4000);
};
void navigator.clipboard
?.writeText(lobby.gameCode)
.then(() => say('Copied.'))
.catch(() => say('Could not copy — select the code above instead.'));
};
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-add'))) {
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true });
}
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-remove'))) {
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: false });
}
const waiting = lobby.seats.filter((s) => s === null).length;
const startBtn = $<HTMLButtonElement>('lb-start');
startBtn.hidden = !isHost;
startBtn.disabled = waiting > 0;
$('lb-start-note').textContent = isHost
? waiting === 0
? ''
: `Waiting on ${waiting} more ${waiting === 1 ? 'player' : 'players'} — add a bot to any empty chair to start now.`
: 'Waiting for the host to start the game.';
startBtn.onclick = () => {
void postJson('/api/lobby/start', { token }).then(({ status, body }) => {
if (status !== 200) setError('lb-start-note', String(body['error'] ?? 'could not start'));
});
};
}
function enterSeating(gameId: string, token: string): void {
$('lb-choice-section').hidden = true;
$('lb-seating-section').hidden = false;
source = new EventSource(`/api/lobby/stream?token=${encodeURIComponent(token)}`);
source.onmessage = (ev: MessageEvent<string>) => {
const push = JSON.parse(ev.data) as LobbyPush;
if (push.started) {
source?.close();
onReady({ token, gameId, seat: push.you });
return;
}
renderSeating(push.lobby, push.you, token);
};
}
$<HTMLButtonElement>('lb-create').onclick = () => {
const displayName = $<HTMLInputElement>('lb-name').value.trim();
const mode = ($('lb-choice-section').querySelector<HTMLInputElement>('input[name="lb-mode"]:checked')?.value ??
'competitive') as 'competitive' | 'coop';
if (displayName === '') {
setError('lb-create-err', 'enter a display name first');
return;
}
const players = Number($<HTMLSelectElement>('lb-players').value) || 4;
// The real seat count reaches `defaultMultiplayerConfig`, so the combined-Revenue floor is
// sized for the table actually being played rather than for an assumed four.
void postJson('/api/lobby/create', {
secret: secret(),
config: defaultMultiplayerConfig(mode, players),
displayName,
players,
}).then(
({ status, body }) => {
if (status !== 200) {
setError('lb-create-err', String(body['error'] ?? 'could not create the game'));
return;
}
setError('lb-create-err', '');
enterSeating(body['gameId'] as string, body['token'] as string);
},
);
};
$<HTMLButtonElement>('lb-join').onclick = () => {
const displayName = $<HTMLInputElement>('lb-name').value.trim();
const gameCode = $<HTMLInputElement>('lb-code').value.trim();
if (displayName === '' || gameCode === '') {
setError('lb-join-err', 'enter a display name and a game code');
return;
}
void postJson('/api/lobby/join', { secret: secret(), gameCode, displayName }).then(({ status, body }) => {
if (status !== 200) {
setError('lb-join-err', String(body['error'] ?? 'could not join that game'));
return;
}
setError('lb-join-err', '');
enterSeating(body['gameId'] as string, body['token'] as string);
});
};
}
+412 -71
View File
@@ -12,21 +12,98 @@ import type { Menu, Save } from './game.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import { EXTRA_START_LABELS, MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.ts';
import type { ExtraStartRule, HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts';
import type { LocalSession } from './session.ts';
import { createLocalSession } from './session.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
MOVES_PER_LOCAL_OPS,
STARTING_HAND_LABELS,
collectiveRevenueFloor,
houseRules,
} from '../engine/content.ts';
import type { HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts';
import type { NewGameOptions } from './game.ts';
import type { LocalSession, Session } from './session.ts';
import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts';
import { runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts';
const SAVE_KEY = 'station-master.save.v1';
const SETTINGS_KEY = 'station-master.settings.v1';
/**
* The multiplayer session — `lobby-and-sessions.md` §1's token, plus the `gameId`/`seat` a fresh
* `createRemoteSession` needs without waiting on a push to learn its own seat. Separate from
* `SAVE_KEY`: a solitaire save is the seed plus intents and is meant to be portable between
* browsers; this is a credential for THIS origin's server and must never be treated as one.
*/
const REMOTE_KEY = 'station-master.remote.v1';
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
/**
* The game, behind the Session boundary.
*
* Typed as `LocalSession` because this page is the solitaire client and uses undo, local saves and
* new-game — all of which are local-only. The parts that draw and submit go through the plain
* `Session` surface, which is what a remote client will provide unchanged.
* Small persisted preferences, kept in a `localStorage` key of their own — separate from
* `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
* in it, and in a multiplayer game two players may reasonably want these set differently. Grows as
* more of the page's display state earns a preference; `districtMode`/`soundOn`/`zoom` are the
* first three.
*/
let session: LocalSession;
type Settings = {
districtMode: 'auto' | 'open' | 'closed';
soundOn: boolean;
zoom: number;
};
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1 };
function loadSettings(): Settings {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
const parsed = raw ? (JSON.parse(raw) as Partial<Settings>) : {};
// A missing key, a corrupt value, or a level dropped from `ZOOM_LEVELS` since it was saved all
// fall back to the default for that one field, rather than rejecting the whole object.
return {
districtMode:
parsed.districtMode === 'open' || parsed.districtMode === 'closed' ? parsed.districtMode : 'auto',
soundOn: typeof parsed.soundOn === 'boolean' ? parsed.soundOn : DEFAULT_SETTINGS.soundOn,
zoom:
typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom)
? parsed.zoom
: DEFAULT_SETTINGS.zoom,
};
} catch {
// A full or disabled localStorage must not take the game down with it — same guard as the save.
return { ...DEFAULT_SETTINGS };
}
}
let settings = loadSettings();
function saveSettings(patch: Partial<Settings>): void {
settings = { ...settings, ...patch };
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {
/* nothing to do */
}
}
/**
* The game, behind the Session boundary — `LocalSession` (solitaire, `?seat=` absent from the URL)
* or `RemoteSession` (`?seat=` present, Phase 2). Typed as the common `Session` surface; every
* LocalSession-only touch (undo, local saves, dealing a new game) goes through `isLocal` below rather
* than assuming, since `session` may now be either.
*/
let session: Session;
/**
* The three `Capabilities` (`undo`/`saveLocal`/`newGame`) travel together — all `true` for a
* `LocalSession`, all `false` for a `RemoteSession` (`session.ts`) — so any one of them is a safe
* discriminant. `newGame` is used here since it reads clearly at every call site: "only if this
* session can deal locally."
*/
function isLocal(s: Session): s is LocalSession {
return s.capabilities.newGame;
}
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/**
@@ -49,18 +126,20 @@ let pendingAt: string | null = null;
*
* 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until
* they change it again. Display only — in a multiplayer game two players may reasonably want it
* set differently, so this must never become part of game state.
* set differently, so this must never become part of game state. Persisted in `settings`, not the
* save, for exactly that reason.
*/
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
/**
* Sound, OFF until asked for.
* Sound, OFF by default until a player asks for it once — then remembered via `settings`.
*
* Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio
* rather than the finished thing — and a playtester who did not ask for noise should not get any.
* One click in the title bar turns it on, and that click is also the gesture browsers require
* before any audio may start.
* rather than the finished thing. One click in the title bar turns it on, and that click is also
* the gesture browsers require before any audio may start.
*/
let soundOn = false;
let soundOn = settings.soundOn;
/** Preset board zoom (see `ZOOM_LEVELS`), persisted in `settings`. */
let zoom = settings.zoom;
/**
* The phase the page last drew, so a change of phase can be announced.
*
@@ -99,6 +178,23 @@ const $ = (id: string): HTMLElement => {
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/**
* Size a freshly-rendered board SVG off its own `viewBox`, at the current `zoom`.
*
* `#grid` and `#division` already scroll horizontally when their content is wider than the column
* (`overflow-x:auto` in `play.html`) — that mechanism is untouched. This only changes how big the
* SVG itself renders, in real pixels rather than a CSS `transform` (which would leave the container's
* scrollable area the wrong size), so zooming in genuinely grows the scrollable area and zooming out
* genuinely shrinks it.
*/
function applyZoom(container: HTMLElement): void {
const svg = container.querySelector('svg');
const box = svg?.viewBox.baseVal;
if (!svg || !box || box.width === 0) return;
svg.style.width = `${box.width * zoom}px`;
svg.style.height = `${box.height * zoom}px`;
}
/**
* A DRAWING OF THE PIECE A PLACEMENT WOULD LAY.
*
@@ -153,24 +249,30 @@ function renderHouseRules(rules: HouseRules): void {
}
/**
* THE HOUSE RULES TRAVEL IN THE URL, BESIDE THE SEED.
* THE HOUSE RULES AND VICTORY DIALS TRAVEL IN THE URL, BESIDE THE SEED.
*
* A seed on its own no longer names a game: `?seed=430` dealt three random cards is a different
* railroad from `?seed=430` dealt three track and three other, and at 0 Revenue per transit it is a
* different economy again. The link has to carry all of it or "same link, same deal" stops being
* true — and the New Game dialog navigates by URL, so this is also how its answers reach `start()`.
* different economy again — and now a game with `minrev=15` is a different game from one with
* `minrev=0`. The link has to carry all of it or "same link, same deal" stops being true — and the
* New Game dialog navigates by URL, so this is also how its answers reach `start()`.
*
* Absent parameters mean the DEFAULTS, not the legacy rules: a bare `?seed=430` is a new game at
* today's settings. It is a save with no rules in it that is old (`game.ts`, `configFor`).
*/
const RULE_PARAMS = { passenger: 'passengerPerCoach', freight: 'freightPerLoad', transit: 'trainPerTransit' } as const;
/** The four victory-condition dials added 2026-08-20 (`GameConfig`), same URL-round-trip convention. */
const VICTORY_PARAMS = {
days: 'days',
minrev: 'minCombinedRevenue',
colday: 'maxCollisionsPerDay',
coltotal: 'maxCollisionsTotal',
} as const;
function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions {
const rules: HouseRuleOverrides = {};
const hand = params.get('hand');
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
const extra = params.get('extra');
if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule;
const revenue: Partial<RevenueRules> = {};
for (const [param, key] of Object.entries(RULE_PARAMS)) {
@@ -180,32 +282,134 @@ function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw);
}
if (Object.keys(revenue).length > 0) rules.revenue = revenue;
return rules;
const options: NewGameOptions = { houseRules: rules };
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
const raw = params.get(param);
// Negative or fractional values from a hand-edited URL are clamped the same way `configWith`'s
// defaults are — a stray `minrev=-5` should mean "off-ish", not a config the engine never sees.
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) {
options[key] = Math.max(0, Math.round(Number(raw)));
}
}
return options;
}
function rulesToUrl(rules: HouseRules, seed: string): string {
function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): string {
const params = new URLSearchParams();
if (seed !== '') params.set('seed', seed);
params.set('hand', rules.startingHand);
// The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from
// here is a setting the dialog silently discards.
params.set('extra', rules.extraStart);
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
const value = options[key];
if (value !== undefined) params.set(param, String(value));
}
return `?${params}`;
}
function loadRemote(): LobbyReady | null {
try {
const raw = localStorage.getItem(REMOTE_KEY);
return raw ? (JSON.parse(raw) as LobbyReady) : null;
} catch {
return null;
}
}
/** Toggles the two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4)
* and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
* ever flashes before `start()` decides which one this load actually needs. */
function showScreen(which: 'lobby' | 'gameui'): void {
document.getElementById('lobby')!.hidden = which !== 'lobby';
document.getElementById('gameui')!.hidden = which !== 'gameui';
}
/**
* The one place a `RemoteSession` gets built — from a fresh `Lobby.Start` push (`lobby.ts`'s
* `runLobby` callback) or from a `{token, gameId, seat}` already sitting in `localStorage` from an
* earlier visit. Either way the token is what makes reconnection work (`lobby-and-sessions.md` §1),
* so it is always written back here before anything else happens.
*/
function beginRemote(ready: LobbyReady): void {
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
showScreen('gameui');
// Nothing can be drawn until the first push arrives, and a page showing nothing at all is
// indistinguishable from a page that is broken — which is exactly what a dead session used to
// look like, forever.
$('presence').textContent = '… connecting to the game';
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
// `createRemoteSession` explains why `view()` would otherwise throw).
session.subscribe(render);
}
/**
* The game this browser remembered is gone, so stop waiting for it and go somewhere useful.
*
* Two things legitimately destroy a game under a seated player, and both are by design: an
* engine-version bump refuses to resume it (D7 — a move legal under the old rules may not be under
* the new ones), and an administrator ends it. Neither used to be survivable here. The remembered
* token sent `start()` straight past the lobby into a game that no longer existed, `EventSource`
* retried the 404 in silence, and the player sat on a blank page with no controls and no way back
* short of clearing site data.
*
* Forgetting the token is what makes the next load land in the lobby instead of repeating it.
*/
function abandonRemote(): void {
localStorage.removeItem(REMOTE_KEY);
showScreen('lobby');
$('presence').textContent = '';
runLobby(beginRemote);
const note = document.getElementById('lb-create-err');
if (note) {
note.textContent =
'That game is no longer on this server — it was either ended by whoever runs it, or the ' +
'service was updated, which does not carry games in progress across. Create or join a new one.';
}
}
/**
* NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through
* `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL
* cannot produce one. `start()`'s job is only to decide which of three screens this load is: back
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the
* common case and the only one a bare page load has ever needed a decision for), or the lobby.
*/
function start(): void {
const params = new URLSearchParams(location.search);
const requested = params.get('seed');
// Entered without checking it still exists — deliberately. Verifying up front would mean an
// await before anything renders on the common path, where the game IS still there; instead the
// session reports a dead game through `abandonRemote`, which lands in the lobby.
const remembered = loadRemote();
if (remembered) {
beginRemote(remembered);
return;
}
// The splash's "Play multiplayer" door (index.html) lands here — straight into the lobby,
// rather than dealing a solitaire game first and leaving the player to find the in-game
// Multiplayer button themselves.
if (params.get('lobby') !== null) {
showScreen('lobby');
runLobby(beginRemote);
return;
}
showScreen('gameui');
const requested = params.get('seed');
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
session = createLocalSession(seed, rulesFromUrl(params));
const local = createLocalSession(seed, gameOptionsFromUrl(params));
session = local;
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
// `configFor`. That is why the restore happens after the session is built rather than feeding it.
const saved = load();
if (saved && requested === null) session.restore(saved);
if (saved && requested === null) local.restore(saved);
applyCapabilities();
// Every render goes through the session, so the page redraws whenever the game says it changed —
@@ -231,6 +435,50 @@ function applyCapabilities(): void {
hide('undo', c.undo);
hide('savefile', c.saveLocal);
hide('newgame', c.newGame);
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
// page offers — same reasoning as `newgame`, and the same capability answers both.
hide('multiplayer', c.newGame);
}
/**
* The west-to-east chain in words, with the D12 that decided it (§4.4).
*
* The map shows where everyone ended up; this says WHY, which is the half `state.openingRolls` was
* kept for. It is also the answer to "am I always at the eastern end" — no, the roll decides, and
* here is the roll.
*/
function renderSeatingChain(f: Frame): void {
const el = document.getElementById('seating-chain');
if (!el) return;
if (f.players.length < 2) {
el.textContent = '';
return;
}
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat);
const chain = bySeat
.map((p) => {
const roll = f.openingRolls.division[p.index];
const marks = [p.index === f.viewer ? 'you' : '', p.index === f.actor ? 'now' : '']
.filter(Boolean)
.join(', ');
return `${p.name}${roll === undefined ? '' : ` (${roll})`}${marks ? ` [${marks}]` : ''}`;
})
.join(' → ');
el.textContent = `West to East: ${chain}. Order set by the opening D12 — highest roll takes the eastern end.`;
}
/**
* `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table
* has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
* has anything to report), and empty again the moment everyone reports back in — `#presence:empty`
* collapses the banner rather than leaving a reassuring "all connected" line nobody needs to read.
*/
function renderPresence(f: Frame): void {
const away = session
.presence()
.filter((p) => !p.connected)
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${p.seat}`);
$('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`;
}
function render(): void {
@@ -256,6 +504,7 @@ function render(): void {
}
renderTurnChart(f);
renderPresence(f);
$('revenue').textContent = String(f.revenue);
/**
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
@@ -268,11 +517,19 @@ function render(): void {
const obj = $('objective');
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
obj.className = 'pace';
$('seed').textContent = String(session.seed());
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${session.seat()}`;
renderHouseRules(f.houseRules);
// -- division
$('division').innerHTML = divisionSvg(f.division);
$('division').innerHTML = divisionSvg(f.division, {
players: f.players,
actor: f.actor,
viewer: f.viewer,
});
renderSeatingChain(f);
applyZoom($('division'));
// -- board. Both renderers are shared with the replay so the two can never draw different
// pictures of the same position.
@@ -307,6 +564,7 @@ function render(): void {
return [{ row: cell.row, col: cell.col, label }];
});
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
applyZoom(grid);
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
// you switching?" picker writes, so the board and the action panel drive one value either way.
@@ -598,14 +856,18 @@ function render(): void {
*/
function renderUndo(): void {
const btn = document.getElementById('undo') as HTMLButtonElement | null;
if (!btn || !session.capabilities.undo) return;
const n = session.steps();
if (!btn || !isLocal(session)) return;
// Captured as a `const` rather than read as `session` again inside the closure below: `session` is
// a mutable module-level `let`, so TypeScript cannot carry the `isLocal` narrowing across a closure
// boundary — a local const it can never see reassigned keeps the narrowed `LocalSession` type.
const local = session;
const n = local.steps();
btn.disabled = n === 0;
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
btn.onclick = () => {
// The session drops the rebuilt game's cues and draws — replaying the history re-records them,
// and none of it is news to a player who just stepped back.
if (!session.undo()) return;
if (!local.undo()) return;
selected = null;
mode = null;
pendingAt = null;
@@ -662,6 +924,7 @@ function renderDistrict(f: Frame): void {
btn.onclick = () => {
// auto -> pin it to the opposite of what auto is doing -> back to auto.
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
saveSettings({ districtMode });
render();
};
}
@@ -949,29 +1212,9 @@ function renderActions(
) {
// The hand being counted is the ACTOR's — they are the one who cannot end the turn.
const hand = f.handCount;
/**
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
*
* §6.2 as ruled by Jesse (Gitea#6): a train card is never discarded, so a player holding four
* trains has exactly one way forward — play one onto the timetable. The rule creates that corner
* deliberately and needs no machinery, but it must not be a corner the player has to infer from
* a discard button that has quietly stopped appearing.
*/
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
const tip = stuck
? '§6.2 — you may not end a turn holding more than three cards, and a TRAIN CARD IS NEVER ' +
'DISCARDED. Every card you hold is a train, so the only way on is to play one onto the ' +
'timetable. A train may be held for as many Stages and Days as you like; it just cannot be ' +
'thrown away.'
: '§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play ' +
'one onto the board, or discard one face-up to a Department slot. A train card is never ' +
'discardable and can only be played.';
html +=
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '&quot;')}">` +
(stuck
? `End Local Operations — play a train card first, they cannot be discarded (holding ${hand})`
: `End Local Operations — play or discard down to three first (holding ${hand})`) +
`</button></div>`;
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` +
`End Local Operations — play or discard down to three first (holding ${hand})</button></div>`;
}
el.innerHTML = html;
@@ -1005,6 +1248,10 @@ function renderActions(
* where a rendered page would have been megabytes.
*/
function downloadSave(): void {
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
// here rather than only trusted to the DOM.
if (!isLocal(session)) return;
const data = JSON.stringify(session.save(), null, 1);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -1016,7 +1263,7 @@ function downloadSave(): void {
}
function save(): void {
if (!session.capabilities.saveLocal) return;
if (!isLocal(session)) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
} catch {
@@ -1064,39 +1311,97 @@ if (saveBtn) saveBtn.onclick = downloadSave;
* Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would
* deal the same game again and look like the button had done nothing.
*/
const multiplayerBtn = document.getElementById('multiplayer');
if (multiplayerBtn) {
multiplayerBtn.onclick = () => {
// Hidden whenever `session` cannot deal (`applyCapabilities`), but repeated here for the same
// reason `newBtn`'s handler repeats its own guard: the click handler outlives any one session.
if (!isLocal(session)) return;
const f = session.view();
const started = f.status === 'active' && (f.day > 1 || f.stage > 1);
if (started && !confirm(`Leave this game (seed ${session.seed()}, Day ${f.day}) for multiplayer?`)) return;
showScreen('lobby');
runLobby(beginRemote);
};
}
const newBtn = document.getElementById('newgame');
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
if (newBtn && dlg) {
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
type Mode = 'solitaire' | 'competitive' | 'coop';
/**
* ASK FOR ALL THREE, rather than documenting URL parameters in the title bar.
* PICKING A TYPE JUST SETS THE FIELDS BELOW TO THAT TYPE'S DEFAULTS (Jesse's design, 2026-08-20) —
* every number stays editable afterward, so "Competitive" isn't a fixed ruleset, it's a starting
* point. `players = 4` for Competitive/Co-op is a nominal stand-in: there's no lobby yet to ask who
* is actually seated (Phase 4), so this is a suggestion a real seat count will replace.
*
* Only Solitaire can be dealt today — Deal disables itself for the other two, with a note, rather
* than pretending a click would do something (`RemoteSession` is Phase 2).
*/
function applyModePreset(mode: Mode): void {
const days = 5;
const players = mode === 'solitaire' ? 1 : 4;
field<HTMLInputElement>('ng-days').value = String(days);
field<HTMLInputElement>('ng-minrev').value = String(collectiveRevenueFloor(players, days));
field<HTMLInputElement>('ng-colday').value = String(DEFAULT_MAX_COLLISIONS_PER_DAY);
field<HTMLInputElement>('ng-coltotal').value = String(DEFAULT_MAX_COLLISIONS_TOTAL);
// No valid target for these cards in Solitaire or Co-op — forced off, not merely defaulted off.
const pvp = field<HTMLInputElement>('ng-pvp');
pvp.checked = mode === 'competitive';
pvp.disabled = mode !== 'competitive';
field<HTMLButtonElement>('ng-deal').disabled = mode !== 'solitaire';
field<HTMLElement>('ng-multiplayer-note').style.visibility = mode === 'solitaire' ? 'hidden' : 'visible';
}
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-mode"]')) {
input.onchange = () => applyModePreset(input.value as Mode);
}
/**
* ASK FOR ALL OF IT, rather than documenting URL parameters in the title bar.
*
* It asked for the seed alone, through `prompt()`. The opening hand and the three revenue rates
* were constants in the source, so trying a variation meant an edit and a rebuild — and balance is
* the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest.
*
* The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to
* compare against the first is the common case, and re-entering four settings each time is how a
* comparison silently stops comparing.
* compare against the first is the common case, and re-entering settings each time is how a
* comparison silently stops comparing. Mode always reopens on Solitaire — it's the only one a
* previous session could actually have been, since Deal is disabled for the other two.
*/
newBtn.onclick = () => {
const f = session.view();
// The button itself is hidden for a session that cannot deal (`applyCapabilities`), but the
// dialog's whole answer-reading/URL-navigating flow below assumes a LocalSession throughout, so
// the guard is repeated — and `local` is captured as a `const` so the narrowing survives the
// closures below it (see `renderUndo`'s identical note on why `session` itself cannot be).
if (!isLocal(session)) return;
const local = session;
const f = local.view();
const day = f.day;
const started = f.status === 'active' && (day > 1 || f.stage > 1);
if (started && !confirm(`Forget this game (seed ${session.seed()}, Day ${day}) and deal a new one?`)) return;
if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return;
const current = session.view().houseRules;
const current = f.houseRules;
field<HTMLInputElement>('ng-seed').value = '';
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-mode"]')) {
input.checked = input.value === 'solitaire';
}
applyModePreset('solitaire');
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-hand"]')) {
input.checked = input.value === current.startingHand;
}
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-extra"]')) {
input.checked = input.value === current.extraStart;
}
field<HTMLInputElement>('ng-passenger').value = String(current.revenue.passengerPerCoach);
field<HTMLInputElement>('ng-freight').value = String(current.revenue.freightPerLoad);
field<HTMLInputElement>('ng-transit').value = String(current.revenue.trainPerTransit);
// Overwrite the preset with the actual rules in play — solitaire is the only real session today.
field<HTMLInputElement>('ng-days').value = String(f.days);
field<HTMLInputElement>('ng-minrev').value = String(f.minCombinedRevenue);
field<HTMLInputElement>('ng-colday').value = String(f.maxCollisionsPerDay);
field<HTMLInputElement>('ng-coltotal').value = String(f.maxCollisionsTotal);
dlg.showModal();
};
@@ -1106,6 +1411,8 @@ if (newBtn && dlg) {
*
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
* took: `start()` reads them back, so there is exactly one place that turns a URL into a game.
* Deal is disabled whenever the mode radio isn't Solitaire, so this never actually runs for the
* other two — nothing here needs to branch on mode.
*/
dlg.addEventListener('close', () => {
if (dlg.returnValue !== 'deal') return;
@@ -1115,11 +1422,9 @@ if (newBtn && dlg) {
// both mean "surprise me", which is what leaving the box alone plainly asks for.
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
const picked = dlg.querySelector<HTMLInputElement>('input[name="ng-hand"]:checked')?.value;
const extra = dlg.querySelector<HTMLInputElement>('input[name="ng-extra"]:checked')?.value;
const rules = houseRules({
houseRules: {
...(STARTING_HAND_LABELS.some((o) => o.value === picked) ? { startingHand: picked as StartingHand } : {}),
...(EXTRA_START_LABELS.some((o) => o.value === extra) ? { extraStart: extra as ExtraStartRule } : {}),
revenue: {
passengerPerCoach: Number(field<HTMLInputElement>('ng-passenger').value),
freightPerLoad: Number(field<HTMLInputElement>('ng-freight').value),
@@ -1127,9 +1432,15 @@ if (newBtn && dlg) {
},
},
});
const victory: NewGameOptions = {
days: Math.max(1, Math.round(Number(field<HTMLInputElement>('ng-days').value)) || 5),
minCombinedRevenue: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-minrev').value)) || 0),
maxCollisionsPerDay: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-colday').value)) || 0),
maxCollisionsTotal: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-coltotal').value)) || 0),
};
clearSave();
const next = rulesToUrl(rules, seed);
const next = rulesToUrl(rules, victory, seed);
// Assigning the search string the page ALREADY has does nothing at all, which reads as a button
// that did not work — and it is the common case: deal a random seed, decide it was a bad deal,
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
@@ -1138,6 +1449,35 @@ if (newBtn && dlg) {
});
}
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
const zoomLabel = document.getElementById('zoomlabel');
if (zoomOutBtn && zoomInBtn && zoomLabel) {
const paintZoom = (): void => {
zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
zoomOutBtn.disabled = zoom <= ZOOM_LEVELS[0]!;
zoomInBtn.disabled = zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]!;
};
const setZoom = (level: number): void => {
zoom = level;
saveSettings({ zoom });
paintZoom();
// Re-apply to whichever boards are already on the page — no full re-render needed, this is
// display-only sizing, same as `render()`'s own calls after each innerHTML assignment.
applyZoom($('division'));
applyZoom($('grid'));
};
zoomOutBtn.onclick = () => {
const i = ZOOM_LEVELS.indexOf(zoom as (typeof ZOOM_LEVELS)[number]);
if (i > 0) setZoom(ZOOM_LEVELS[i - 1]!);
};
zoomInBtn.onclick = () => {
const i = ZOOM_LEVELS.indexOf(zoom as (typeof ZOOM_LEVELS)[number]);
if (i >= 0 && i < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[i + 1]!);
};
paintZoom();
}
const soundBtn = document.getElementById('sound');
if (soundBtn) {
const paint = (): void => {
@@ -1145,6 +1485,7 @@ if (soundBtn) {
};
soundBtn.onclick = () => {
soundOn = !soundOn;
saveSettings({ soundOn });
paint();
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it
// doubles as the user gesture the browser needs before any audio may start.
+1 -11
View File
@@ -31,18 +31,8 @@ export function cardRow(name: string, why: string, playable: boolean | null): st
}
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
// §6.2 (Gitea#6) — say so on the card itself. A player who cannot discard a train needs to read
// that on the train, not deduce it from a button that is not there.
const held = 'You may hold this for as many Stages and Days as you like — but a train card is ' +
'never discarded (§6.2). The only way it leaves your hand is onto the timetable.';
return f.hand.length
? f.hand
.map((h, i) => {
const what = f.handWhat[i] ?? '';
const keep = f.handDiscardable[i] === false;
return cardRow(h, keep ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
})
.join('')
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: '<span class="dim">empty</span>';
}
+124 -10
View File
@@ -33,6 +33,13 @@ header button:hover{border-color:#4d6fa8}
stops inviting the press. */
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
header button:disabled:hover{border-color:#2c333d}
.zoom{display:inline-flex;align-items:center;gap:4px}
.zoom button{padding:3px 9px;line-height:1}
.lb-invite{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 10px;
background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px}
.lb-invite-label{font-size:11px;color:var(--dim)}
.lb-invite-code{font-size:22px;font-weight:700;letter-spacing:.08em;color:#f2e6cf}
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
.home:hover{color:#5aa9e6}
@@ -70,6 +77,14 @@ main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14p
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
padding:10px 12px;margin-bottom:12px}
#lobby{max-width:640px;margin:0 auto;padding:14px}
#lobby h2{margin-top:0}
#lobby h3{margin-bottom:2px}
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
.lb-seat:last-child{border-bottom:none}
.lb-seat .who{flex:1}
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
#presence:empty{display:none}
/* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
@@ -154,6 +169,12 @@ button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);colo
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
button:hover{background:#39424e;border-color:#4d6fa8}
/* GENERIC, and it was not. `header button:disabled` and `#actions button:disabled` were the only
disabled styles on the page, so a disabled button anywhere else — #lb-start being the one that
mattered — kept its normal face AND still lit up under the cursor from the rule above. It was
advertising a click it would refuse. */
button:disabled{opacity:.45;cursor:not-allowed}
button:disabled:hover{background:#2a3038;border-color:var(--line)}
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
color:#f2e6cf;font-weight:600}
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
@@ -207,6 +228,70 @@ ul.blocked li{padding:2px 0}
</head>
<body>
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
`main.ts` only decides whether THIS div or `#gameui` below is the one currently visible.
`#newgamedlg` at the very end of the body is solitaire-only and untouched by any of this. -->
<div id="lobby" hidden>
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
<section id="lb-secret-section">
<h2>Join secret</h2>
<p class="ng-note">Whoever is running this server gave you a secret out of band (a chat message, not a public page). It is kept in this browser only, never shown back, and sent with every lobby request.</p>
<label class="ng-num"><span>Join secret</span><input id="lb-secret" type="password" autocomplete="off"></label>
</section>
<section id="lb-choice-section">
<h2>Create or join a game</h2>
<label class="ng-num"><span>Your display name</span><input id="lb-name" type="text" autocomplete="off" maxlength="40"></label>
<h3>Create a new game</h3>
<p class="ng-note">You become the host — you choose the mode and, once everyone's seated, start the game. 2 to 4 players.</p>
<label class="ng-radio"><input type="radio" name="lb-mode" value="competitive" checked>
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-mode" value="coop">
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
<label class="ng-num"><span>Players at the table</span>
<select id="lb-players">
<option value="2">2</option>
<option value="3">3</option>
<option value="4" selected>4</option>
</select></label>
<p class="ng-note">Every chair has to be taken before the game can start — by a person or by a
bot. Pick the size of the table now; it cannot change once the game is created.</p>
<button id="lb-create">Create game</button>
<p class="dim" id="lb-create-err" role="alert"></p>
<h3>Join a game</h3>
<p class="ng-note">Ask whoever created the game for its code.</p>
<label class="ng-num"><span>Game code</span><input id="lb-code" type="text" autocomplete="off" placeholder="RAIL-1234"></label>
<button id="lb-join">Join game</button>
<p class="dim" id="lb-join-err" role="alert"></p>
</section>
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
<section id="lb-seating-section" hidden>
<h2>Seating</h2>
<div class="lb-invite">
<div>
<div class="lb-invite-label">Send this code to your players</div>
<div class="lb-invite-code" id="lb-gamecode"></div>
</div>
<button id="lb-copy" class="ghost">Copy</button>
<span class="ng-note" id="lb-copied"></span>
</div>
<p class="ng-note">They enter it under <b>Join a game</b>, along with the same join secret you used.</p>
<p class="ng-note">The host may fill an empty seat with a bot, and starts the game once every
seat is either a player or a bot. <b>These chairs are not the running order</b> — who sits
where along the Division is decided by a D12 roll when the game starts (§4.4), and the map
shows the result.</p>
<div id="lb-seats"></div>
<button id="lb-start" disabled>Start game</button>
<p class="dim" id="lb-start-note"></p>
</section>
</div>
<div id="gameui" hidden>
<div class="topbar">
<header>
<b><a href="./index.html" class="home">Station Master</a></b>
@@ -221,9 +306,16 @@ ul.blocked li{padding:2px 0}
to keep the header on one line; the tooltip spells it out. -->
<span class="dim" id="houserules" title="">—</span>
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
rendered SVG's own pixel dimensions; the existing scrollbar keeps doing the panning. -->
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
</span>
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
@@ -238,10 +330,15 @@ ul.blocked li{padding:2px 0}
what you miss when the automatic phases run between two clicks. -->
<div id="phasenote"></div>
<div id="announce"></div>
<!-- `lobby-and-sessions.md` §5 — a disconnect keeps the seat and the game simply waits; this is
what says WHY, instead of the table watching nothing happen with no explanation. Empty and
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
<div id="presence"></div>
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div></section>
<section><h2>The Division — west to east</h2><div id="division"></div>
<p class="ng-note" id="seating-chain"></p></section>
<section id="district">
<h2>Your Office Area
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
@@ -305,10 +402,21 @@ ul.blocked li{padding:2px 0}
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
==================================================================== -->
</div><!-- /gameui -->
<dialog id="newgamedlg" aria-labelledby="ng-title">
<form method="dialog" id="newgameform">
<h2 class="big" id="ng-title">New game</h2>
<h3>Game type</h3>
<p class="ng-note">Picking a type just sets the fields below to that type's defaults — every number stays yours to change afterward. Solitaire is the only type that can be dealt today; Competitive and Co-op need a server (coming soon).</p>
<label class="ng-radio"><input type="radio" name="ng-mode" value="solitaire" checked>
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. Everything below is real today.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-mode" value="competitive">
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-mode" value="coop">
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
<h3>Seed</h3>
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game can be shared, compared or replayed. Leave it blank for a random one.</p>
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
@@ -322,15 +430,6 @@ ul.blocked li{padding:2px 0}
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeTrackThreeOther">
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
<h3>Where an Extra may start</h3>
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes, and the place decides which way it runs — a Division Point sends it away from itself; in the middle of the railroad the player picks east or west. The Division Points and the Interchange belong to nobody and are always available. Starting one inside a district is the part that favours a seat, so it is set here. An Office must be a Control Point whatever this says: a Whistle Post never qualifies.</p>
<label class="ng-radio"><input type="radio" name="ng-extra" value="divisionPointsOnly">
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading of §7. Every Extra begins on shared ground.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-extra" value="ownOffice">
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-extra" value="anyOffice" checked>
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive, and what the game did before this setting existed.</span></span></label>
<h3>Revenue</h3>
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and again when it is detrained; a load pays when it is made up and again when it is broken. Zero switches an economy off so the others can be read.</p>
<label class="ng-num"><span>Passenger revenue per coach</span>
@@ -341,7 +440,22 @@ ul.blocked li{padding:2px 0}
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the Division — the one thing nobody has to work for. It defaults to 0 for that reason.</p>
<h3>Victory conditions</h3>
<p class="ng-note">How long the game runs, and the ways it can end. 0 turns any of these off. The combined-Revenue suggestion updates for Competitive/Co-op — it assumes a 4-player table until there's a lobby to ask who's actually seated.</p>
<label class="ng-num"><span>Days</span>
<input id="ng-days" type="number" min="1" max="20" step="1" value="5"></label>
<label class="ng-num"><span>Minimum combined Revenue to avoid a loss</span>
<input id="ng-minrev" type="number" min="0" step="1" value="15"></label>
<label class="ng-num"><span>Collisions in one Day that end the game</span>
<input id="ng-colday" type="number" min="0" step="1" value="3"></label>
<label class="ng-num"><span>Collisions across the whole game that end it</span>
<input id="ng-coltotal" type="number" min="0" step="1" value="5"></label>
<label class="ng-num"><span>Allow the opponent-directed cards</span>
<input id="ng-pvp" type="checkbox"></label>
<p class="ng-note" id="ng-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect either way until then.</p>
<menu class="ng-buttons">
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Use the <b>Multiplayer</b> button instead — it creates or joins a game on this server.</span>
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
<button value="deal" id="ng-deal" type="submit">Deal</button>
</menu>
+146 -9
View File
@@ -18,8 +18,9 @@
import type { Intent } from '../engine/intents.ts';
import type { Frame } from '../sim/view.ts';
import type { PlayerIndex } from '../engine/state.ts';
import type { HouseRuleOverrides } from '../engine/content.ts';
import type { Game, Menu, Save } from './game.ts';
import { applyDelta } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
import type { Game, Menu, NewGameOptions, Save } from './game.ts';
import {
actionMenu,
configWith,
@@ -92,6 +93,15 @@ export type Session = {
takeAnnouncement(): string | null;
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
justDrawn(): string | null;
/**
* Which OTHER seats are currently connected, as last reported by the server — always empty for a
* `LocalSession` (there is nobody else to track). `lobby-and-sessions.md` §5: a disconnect keeps
* the seat and waits; this is what lets the page say why, instead of going quiet with no
* explanation. Reflects the last presence push for each seat the server has ever mentioned, not
* only the ones currently disconnected — a seat that reconnects updates its own entry rather than
* disappearing, so the page can tell "never heard from" apart from "was here, then left."
*/
presence(): { seat: PlayerIndex; connected: boolean }[];
};
/**
@@ -115,14 +125,14 @@ export type LocalSession = Session & {
* `Game` is mutated in place by `submit`, so the wrapper keeps a mutable reference rather than
* copying — `undo` and `restore` replace the whole game, which is why `game` is a getter.
*
* It takes `rules` rather than a whole `GameConfig` because DEALING is the only thing on the far
* side of this that the page is allowed to decide. A config carries the mode, the victory condition
* and the optional rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build
* meant importing the engine's own defaults into the page, which is the boundary `session.test.ts`
* guards. The seed and the house rules are the two things a player picks when they press New game.
* It takes `NewGameOptions` rather than a whole `GameConfig` because DEALING is the only thing on the
* far side of this that the page is allowed to decide. A config carries the mode and the optional
* rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build meant importing
* the engine's own defaults into the page, which is the boundary `session.test.ts` guards. The seed,
* the house rules and the victory-condition dials are what a player picks when they press New game.
*/
export function createLocalSession(seed: number, rules?: HouseRuleOverrides): LocalSession {
let game: Game = rules ? newGame(seed, configWith(rules)) : newGame(seed);
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
@@ -162,6 +172,7 @@ export function createLocalSession(seed: number, rules?: HouseRuleOverrides): Lo
return text;
},
justDrawn: () => game.justDrawn,
presence: () => [],
seed: () => game.seed,
save: () => toSave(game),
@@ -187,3 +198,129 @@ export function createLocalSession(seed: number, rules?: HouseRuleOverrides): Lo
},
};
}
/** The push envelope `src/server/session.ts` sends over SSE — mirrored here rather than imported,
* so this file never depends on anything under `src/server/` even at the type level. */
type Push = {
frame?: FrameDelta;
menu: Menu | null;
lines: { text: string; tone: string }[];
presence?: { seat: PlayerIndex; connected: boolean };
};
/**
* A session backed by a server (Phase 2, extended Phase 4). Holds no authoritative state — no deck
* order, no other seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities`
* are all `false`: undo would have to un-see what other players already saw, a local save is
* meaningless when the server is the store, and dealing a new game is the lobby's job.
*
* `token` is `lobby-and-sessions.md` §1's session token, issued at `Lobby.Create`/`Lobby.Join` and
* stored by the caller (`main.ts`, in `localStorage`) — it alone proves identity to `/api/stream`
* and `/api/intent`, so this function no longer takes a join secret at all; that gate belongs to
* the lobby endpoints only. `seat` still has to be passed in rather than learned from a push,
* because the very FIRST thing this session needs — `seat()` — has to answer before any push has
* necessarily arrived; the caller already knows it from the join/create/start response.
*
* Construction is synchronous (the `Session` interface has no async surface), but the first real
* `Frame` only exists once the SSE connection's first push arrives — `main.ts` accounts for this by
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
*/
export function createRemoteSession(
token: string,
seat: PlayerIndex,
/**
* Called once when this session's game is established to be gone for good, so the page can stop
* waiting for it. Without this the only symptom is a blank screen: `EventSource` retries a 404
* forever and reports nothing, and `frame` never becomes non-null.
*/
onGone?: () => void,
): Session {
let frame: Frame | null = null;
let menu: Menu | null = null;
let lines: { text: string; tone: string }[] = [];
const presence = new Map<PlayerIndex, boolean>();
let nextSeq = 1;
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
};
const qs = `token=${encodeURIComponent(token)}`;
const source = new EventSource(`/api/stream?${qs}`);
/**
* A DROPPED CONNECTION AND A DEAD GAME LOOK IDENTICAL HERE, so ask before giving up.
*
* `EventSource` fires `error` for both a transient blip — which it recovers from by itself, and
* which is the expected shape of a game that sits idle for minutes (multiplayer.md §9) — and a
* 404 it will nonetheless retry forever. It exposes no status code either way. `/api/session` is
* the cheap question that separates them: only a definite 404 closes the stream and reports the
* game gone, so a flaky network still self-heals.
*/
let reportedGone = false;
source.onerror = () => {
if (reportedGone) return;
void fetch(`/api/session?${qs}`)
.then((r) => {
if (r.status !== 404 || reportedGone) return;
reportedGone = true;
source.close();
onGone?.();
})
.catch(() => {
// The probe itself failed, so this says nothing about the game — leave the retry running.
});
};
source.onmessage = (ev: MessageEvent<string>) => {
const push = JSON.parse(ev.data) as Push;
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
// seat's turn — only a push that actually came from the game (always carries a real `frame`,
// per `session.ts`'s `Push`) updates the board or the menu.
if (push.frame) {
frame = applyDelta(frame, push.frame);
menu = push.menu;
}
lines = [...lines, ...push.lines];
if (push.presence) presence.set(push.presence.seat, push.presence.connected);
changed();
};
const need = (): Frame => {
if (frame === null) throw new Error('RemoteSession.view() called before the first Frame arrived');
return frame;
};
return {
view: need,
menu: () => menu ?? { options: [], direct: [], placeable: [], hand: [], makeUp: null },
seat: () => seat,
actor: () => need().actor,
overHandLimit: () => need().overHandLimit,
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
async submit(intent: Intent): Promise<boolean> {
const seq = nextSeq++;
const res = await fetch(`/api/intent?${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seq, intent }),
});
const result = (await res.json()) as { ok: boolean; code?: string };
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
// not from this response — this only reports whether the rules accepted it.
return result.ok;
},
subscribe(fn: () => void) {
listeners.add(fn);
return () => listeners.delete(fn);
},
capabilities: { undo: false, saveLocal: false, newGame: false },
lines: () => lines,
takeCues: () => [],
takeScheduled: () => null,
takeAnnouncement: () => null,
justDrawn: () => null,
presence: () => [...presence].map(([s, connected]) => ({ seat: s, connected })),
};
}
+43
View File
@@ -26,3 +26,46 @@ if (heroImage && lightbox) {
if (e instanceof KeyboardEvent && e.key === 'Escape') close();
});
}
/**
* Is a Station Master server actually behind this page?
*
* The same `dist/` ships two ways — served by `src/server/`, or uploaded as flat files with no
* server at all (`scripts/deploy-web.ts`) — and the bundle is identical in both, so the only
* honest way to answer is to ask. `/api/health` is the one route that exists to be failed.
*
* THE DOOR STARTS OPEN AND ONLY EVER CLOSES. Getting this wrong in the "no server" direction is
* the bug this whole change exists to fix: a disabled door is invisible and leaves a player who
* DOES have a server with no way in, and nothing on screen to explain it. Getting it wrong the
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
*/
const mpDoor = document.getElementById('door-multiplayer');
if (mpDoor) {
const close = (): void => {
mpDoor.classList.add('disabled');
mpDoor.removeAttribute('href');
const go = document.getElementById('door-multiplayer-go');
if (go) go.textContent = 'Requires a Station Master server';
const blurb = document.getElementById('door-multiplayer-blurb');
if (blurb) {
blurb.textContent =
'Play with your friends across the internet, each running your own office area within the ' +
'entire division. This copy of the game is a plain website with no server behind it, so ' +
'there is nowhere to host a table — multiplayer needs a Station Master server.';
}
};
// Relative, never absolute: the page must work at whatever address it is reached by, and the
// server is always the origin that served it (D16).
void fetch('./api/health')
.then((r) => (r.ok ? (r.json() as Promise<unknown>) : null))
.then((body) => {
// A static host that answers unknown paths with 200 and its own index page would sail past
// an `r.ok` check, so the body has to name itself before the door is believed.
const named =
typeof body === 'object' && body !== null && (body as { service?: unknown }).service === 'station-master';
if (!named) close();
})
.catch(() => close());
}
+202 -301
View File
@@ -8,18 +8,21 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.ts';
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import { developerBot } from '../src/sim/bot.ts';
import type { CrewTray, DivisionNode, GameConfig, GameState } from '../src/engine/state.ts';
import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts';
import { coordKey, railFacingOf } from '../src/engine/state.ts';
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'short',
days: 3,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -327,6 +330,75 @@ describe('collisions are automatic (Gap 2)', () => {
assert.ok(!s.trays.has(id), 'the train is removed');
});
it('does not collide when a coach is legally parked at the Office (v0.5.0 §A.4 exception)', () => {
// A coach may now be set out at the Office (§A.4's carve-out). It must not become a hazard to
// the next train in — that would punish exactly the thing the rule was written to allow.
const s = game();
const area = s.officeAreas.get(0)!;
area.adOccupancy = []; // an A/D track is free — only the fouling check is under test
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
officeCard.standing = [{ type: 'coach', loaded: false }];
const id = 'inbound';
s.trays.set(id, {
id,
trainNumber: 2,
trainIsExtra: false,
engineAt: 0,
consist: [{ type: 'coach', loaded: true }],
direction: 'east',
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
ml.card = 'plains';
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.collisionsToday, 0, 'a legally parked coach is not a hazard to the next arrival');
assert.ok(s.trays.has(id), 'the train is not written off');
});
it('still collides when a non-coach car is left fouling the Office track', () => {
// Reachable only by poking state directly — `canDropCarsAt` already refuses every non-coach
// drop at the Office, so this backstops the collision check itself, not a state a legal game
// can reach.
const s = game();
const area = s.officeAreas.get(0)!;
area.adOccupancy = [];
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
officeCard.standing = [{ type: 'boxcar', loaded: false }];
const id = 'inbound';
s.trays.set(id, {
id,
trainNumber: 2,
trainIsExtra: false,
engineAt: 0,
consist: [{ type: 'coach', loaded: true }],
direction: 'east',
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
ml.card = 'plains';
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.collisionsToday, 1);
assert.ok(!s.trays.has(id), 'the train is removed');
});
it('returns cabooses to the Division Yard and other stock to Classification (Gap 2c)', () => {
const s = game();
s.officeAreas.get(0)!.adOccupancy = ['blocker'];
@@ -464,11 +536,11 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
// ---------------------------------------------------------------------------
describe('victory conditions (§3, Gap 10e)', () => {
it('loses a timed Solitaire game that misses the target', () => {
// The target doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
const s = game(1);
s.clock.day = lengthProfile('short').days + 1;
describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
it('loses a timed Solitaire game that misses minCombinedRevenue', () => {
// minCombinedRevenue doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
const s = game(1, { minCombinedRevenue: 10 });
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 0;
@@ -478,25 +550,101 @@ describe('victory conditions (§3, Gap 10e)', () => {
assert.equal(s.outcome!.reason, 'revenueFloor');
});
it('wins a timed Solitaire game that clears the target', () => {
const s = game(1);
s.clock.day = lengthProfile('short').days + 1;
it('wins a timed Solitaire game that clears minCombinedRevenue', () => {
const s = game(1, { minCombinedRevenue: 10 });
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = lengthProfile('short').target;
s.players[0]!.revenue = 10;
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.result, 'win');
});
it('ends a first-to-target game the moment the target is reached', () => {
const s = game(1, { victory: 'firstToTarget' });
s.players[0]!.revenue = lengthProfile('short').target;
it('minCombinedRevenue = 0 disables the floor — any score wins once days run out', () => {
const s = game(1, { minCombinedRevenue: 0 });
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 0;
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.result, 'win');
});
it('everyone loses in Competitive when the TABLE misses minCombinedRevenue, whoever scored best', () => {
const s = createGame({
id: 'g', seed: 2, config: baseConfig({ mode: 'competitive', minCombinedRevenue: 20 }),
playerNames: ['A', 'B'],
});
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 5; // best individual score...
s.players[1]!.revenue = 3; // ...but combined (8) still misses the floor (20).
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.result, 'loss');
assert.equal(s.outcome!.reason, 'revenueFloor');
});
it('Co-op wins as one table score (summed Revenue), winner stays null', () => {
const s = createGame({
id: 'g', seed: 3, config: baseConfig({ mode: 'coop', minCombinedRevenue: 10 }),
playerNames: ['A', 'B'],
});
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 4;
s.players[1]!.revenue = 6; // combined 10 clears the floor, neither alone would.
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.result, 'win');
assert.equal(s.outcome!.winner, null, 'Co-op names an individual winner instead of a shared one');
});
it('ends immediately, mid-Stage, when collisions reach maxCollisionsPerDay', () => {
// Competitive/coop only, and immediate — not gated on the day boundary the way the revenue
// floor is. Stage 1, well short of STAGES_PER_DAY, proves it fires mid-day.
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 2 });
s.collisionsToday = 2;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.reason, 'targetReached');
assert.equal(s.outcome!.result, 'loss');
assert.equal(s.outcome!.reason, 'collisionFloor');
});
it('ends immediately when the running total reaches maxCollisionsTotal, even under the per-day cap', () => {
const s = game(1, { mode: 'coop', maxCollisionsPerDay: 0, maxCollisionsTotal: 3 });
s.collisionsTotal = 3;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.reason, 'collisionFloor');
});
it('maxCollisionsPerDay = 0 and maxCollisionsTotal = 0 disable the collision floor entirely', () => {
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 });
s.collisionsToday = 99;
s.collisionsTotal = 99;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.notEqual(s.status, 'finished');
});
it('solitaire never checks the collision floor, whatever the counts', () => {
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
s.collisionsToday = 99;
s.collisionsTotal = 99;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.notEqual(s.status, 'finished');
});
});
@@ -708,7 +856,7 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
* which deadlocks the moment Local Operations wants an option chosen before it can be ended.
*/
const phasesWith = (trainNumber: number): { localOps: number; loadUnload: number; leftIn: string } => {
const s = game(7, { length: 'standard' });
const s = game(7, { days: 5 });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
/**
* A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a
@@ -799,7 +947,7 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
// What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left
// standing in the meantime. Parked on Secondary Track — cleared there to make way for other
// switching, say — it must not still be there when the next Mainline Phase begins.
const s = game(7, { length: 'standard' });
const s = game(7, { days: 5 });
const area = areaOf(s, 0);
const id = 'expedited';
const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 };
@@ -827,7 +975,7 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
it('does not fault an Expedited train legitimately held at the station itself', () => {
// A train sitting on the Office square is not "left" anywhere — including one §8.1 is holding
// for a same-direction meet, which is an ordinary hold, not a Station Master failure.
const s = game(7, { length: 'standard' });
const s = game(7, { days: 5 });
const area = areaOf(s, 0);
const id = 'expedited';
s.trays.set(id, {
@@ -872,20 +1020,15 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
// ---------------------------------------------------------------------------
describe('an Extra starts where the player puts it (Gitea#4)', () => {
describe('an Extra starts where its number sends it, or at a Control Point', () => {
/**
* REPORTED, v0.4.9e: "When extras are played the player doing so may choose where the extra
* starts. They may choose either division point. And if the interchange mainline card has been
* played, they may start the extra on that card and choose the direction from there. If there is
* potential for conflict with other trains in that area the superintendent may hold the extra."
* REPORTED: "Extras should start at Eastern or Western Division point based on their numbers. Even
* trains run to the east (start at western DP), odd run to the west (start at eastern DP). They
* can also start at a control point (any office except whistlepost) at player's choice."
*
* This SUPERSEDES the earlier ruling these tests used to assert — "the number decides, like
* everything else on the timetable" — for Extras only. The number still decides for a timetabled
* train. The reason the two cannot both hold: an odd (westbound) Extra placed at the WEST end
* would leave the Division on its first move having crossed nothing, and be paid for the run.
*
* So: the start decides the direction. A Division Point runs the train away from itself; in the
* middle of the railroad — an Interchange, a Control Point — the player says which way.
* Every Extra used to launch eastbound from the West Division Point, hardcoded, with the
* simplification flagged in a comment — so half the Extras ran the wrong way and the Control Point
* option did not exist at all.
*/
const pending = (trainNumber: number, tier?: 'depot' | 'station'): GameState => {
const s = game(11);
@@ -903,14 +1046,6 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
return tray;
};
/** Turn one Mainline card into an Interchange, and say which node it is. */
const withInterchange = (s: GameState): number => {
const i = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[i]!;
if (node.kind === 'mainline') node.card = 'interchange';
return i;
};
it('stops for the decision instead of launching the train itself', () => {
const s = pending(17);
assert.equal(s.clock.phase, 'newTrain');
@@ -921,46 +1056,36 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
);
});
it('offers BOTH Division Points, not the one the number would dictate', () => {
it('sends an odd Extra west from the EASTERN Division Point', () => {
// §2.3 — odd runs west. It therefore starts at the end it runs away from.
const s = pending(17);
const sides = legalActions(s, 0)
.filter((i) => i.type === 'newTrain.startExtra' && i.start?.kind === 'divisionPoint')
.map((i) => (i.type === 'newTrain.startExtra' && i.start?.kind === 'divisionPoint' ? i.start.side : ''));
assert.deepEqual([...sides].sort(), ['east', 'west']);
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
const tray = started(s);
assert.equal(tray.direction, 'west');
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'east');
});
it('runs an Extra AWAY from the Division Point it was placed at, whatever its number', () => {
// X17 is odd. Under the superseded rule it could only ever start at the East end and run west.
for (const [side, direction] of [['west', 'east'], ['east', 'west']] as const) {
const s = pending(17);
const r = applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 17, start: { kind: 'divisionPoint', side },
});
assert.ok(r.ok, `the ${side} Division Point was refused: ${r.ok ? '' : r.code}`);
const tray = started(s);
assert.equal(tray.direction, direction, `an Extra at the ${side} end must run ${direction}`);
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, side);
}
it('sends an even Extra east from the WESTERN Division Point', () => {
const s = pending(18);
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: null }).ok);
const tray = started(s);
assert.equal(tray.direction, 'east');
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'west');
});
it('refuses a Whistle Post, which is not a Control Point, at every setting of the house rule', () => {
for (const extraStart of ['divisionPointsOnly', 'ownOffice', 'anyOffice'] as const) {
const s = pending(18);
s.config = { ...s.config, houseRules: { ...s.config.houseRules, extraStart } };
assert.equal(s.officeAreas.get(0)!.tier, 'whistlePost');
const code = check(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
});
assert.ok(code !== null, `a Whistle Post was allowed under ${extraStart}`);
}
it('refuses a Whistle Post, which is not a Control Point', () => {
const s = pending(18);
assert.equal(s.officeAreas.get(0)!.tier, 'whistlePost');
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 }),
'NOT_A_CONTROL_POINT',
);
});
it('starts at a Control Point when the player picks one, taking an A/D track', () => {
// Upgrading the Office is what buys this: a Depot is a Control Point, a Whistle Post is not.
const s = pending(18, 'depot');
const r = applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'west',
});
const r = applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 });
assert.ok(r.ok, `starting at the Depot was refused: ${r.ok ? '' : r.code}`);
const tray = started(s);
const area = s.officeAreas.get(0)!;
@@ -971,240 +1096,16 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
'the Extra did not start on the Office card',
);
assert.ok(area.adOccupancy.includes(tray.id), 'it did not take an A/D track');
// The point of the change: an EVEN Extra running WEST, because the player said so.
assert.equal(tray.direction, 'west', 'the direction the player chose was not honoured');
});
it('needs a direction anywhere that is not an end of the Division', () => {
const s = pending(18, 'depot');
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 } }),
'NO_DIRECTION_CHOSEN',
);
});
it('honours the extraStart house rule for Office starts, and never for the shared ground', () => {
for (const [extraStart, code] of [
['divisionPointsOnly', 'OFFICE_STARTS_NOT_ALLOWED'],
['anyOffice', null],
] as const) {
const s = pending(18, 'depot');
s.config = { ...s.config, houseRules: { ...s.config.houseRules, extraStart } };
assert.equal(
check(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
}),
code,
`office start under ${extraStart}`,
);
// The Division Points belong to nobody, so no setting ever closes them.
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'divisionPoint', side: 'west' } }),
null,
);
}
});
it('starts at an Interchange in the players yard, not out on the running line', () => {
const s = pending(18);
const node = withInterchange(s);
const r = applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
});
assert.ok(r.ok, `the Interchange was refused: ${r.ok ? '' : r.code}`);
const tray = started(s);
const card = s.division.nodes[node]!;
assert.equal(tray.direction, 'east');
assert.deepEqual(tray.position, { at: 'mainline', index: node });
assert.ok(card.kind === 'mainline' && card.holding?.includes(tray.id), 'it is not in the yard');
assert.equal(card.kind === 'mainline' && card.transits.length, 0, 'it was put on the running line');
});
it('refuses any Mainline card that is not an Interchange', () => {
const s = pending(18);
const plains = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[plains]!;
if (node.kind === 'mainline') node.card = 'plains';
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node: plains }, direction: 'east' }),
'NOT_AN_INTERCHANGE',
);
});
it('may be started at an Interchange however busy the card is — the yard forces no collision', () => {
const s = pending(18);
const node = withInterchange(s);
const card = s.division.nodes[node]!;
// Nose to tail with opposing traffic. §7: placing here still must not force a collision.
if (card.kind === 'mainline') {
card.transits.push({ tray: 'tray9', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east' }),
null,
);
});
/**
* §7's last clause, and the reason the Interchange start is modelled as a yard at all: "If there
* is potential for conflict with other trains in that area the superintendent may hold the extra."
*
* Jesse's split: a GUARANTEED collision holds the train at the Interchange for another Stage and
* it tries again; a POTENTIAL one is the Superintendent's to rule on. Those are exactly §8.1's
* two answers, so the Extra highballs out of the yard through `evaluateClearance` — the same
* check a train leaving a Division Point goes through — rather than through anything new.
*/
describe('highballing out of the Interchange yard', () => {
/** A pending X18 sitting in the yard of an Interchange, with the terrain pinned. */
const inYard = (): { s: GameState; node: number; tray: CrewTray } => {
const s = pending(18);
const node = withInterchange(s);
const r = applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
});
assert.ok(r.ok, `the Interchange was refused: ${r.ok ? '' : r.code}`);
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
return { s, node, tray: started(s) };
};
const card = (s: GameState, node: number): Extract<DivisionNode, { kind: 'mainline' }> => {
const n = s.division.nodes[node]!;
assert.equal(n.kind, 'mainline');
return n as Extract<DivisionNode, { kind: 'mainline' }>;
};
it('pulls out onto the card at the next Mainline Phase when the Subdivision is clear', () => {
const { s, node, tray } = inYard();
advance(s);
const n = card(s, node);
assert.deepEqual(n.holding, [], 'it never left the yard');
assert.ok(n.transits.some((t) => t.tray === tray.id), 'it is not on the running line');
});
it('is held in the yard by a facing train, and tries again the next Stage', () => {
const { s, node, tray } = inYard();
// Westbound, against an eastbound Extra: §8.1 calls that an absolute bar, not a judgment call.
card(s, node).transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
s.trays.set('facing', {
id: 'facing', trainNumber: 3, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', position: { at: 'mainline', index: node }, movesUsed: 0,
});
const r = advance(s);
assert.equal(r.needsInput ?? false, false, 'a guaranteed collision is not a question to ask');
assert.equal(s.clock.pendingDecision, null);
assert.ok(card(s, node).holding?.includes(tray.id), 'it was not held in the yard');
assert.ok(
!card(s, node).transits.some((t) => t.tray === tray.id),
'it pulled out in front of a train coming the other way',
);
assert.ok(s.trays.has(tray.id), 'the Extra was destroyed rather than held');
// AND IT TRIES AGAIN. The Extra waits in the yard, not on the pending list, so once the road
// clears the next Mainline Phase takes it out with no further intervention.
s.trays.delete('facing');
card(s, node).transits = [];
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.deepEqual(card(s, node).holding, [], 'it did not try again once the road was clear');
assert.ok(card(s, node).transits.some((t) => t.tray === tray.id), 'it never pulled out');
});
it('puts a following train to the Superintendent rather than holding it automatically', () => {
const { s, node, tray } = inYard();
// Same direction: §8.1's judgment call, which is what "may hold the extra" means.
card(s, node).transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', position: { at: 'mainline', index: node }, movesUsed: 0,
});
const r = advance(s);
assert.equal(r.needsInput, true, 'the phase must stop and ask');
assert.equal(s.clock.pendingDecision?.train, tray.id);
assert.equal(s.clock.pendingDecision?.occupiedBy, 'ahead');
// HOLD keeps it in the yard.
assert.ok(applyIntent(s, s.clock.superintendent, { type: 'mainline.clearance', allow: false }).ok);
advance(s);
assert.ok(card(s, node).holding?.includes(tray.id), 'the Superintendent held it and it left anyway');
});
});
/**
* FOUND BY PLAYING IT, not by the tests above: an Extra started anywhere but a Division Point was
* never offered a car and ran empty.
*
* `isBeingMadeUp` asked only "is this tray standing at a Division Point", which was the whole
* truth while that was the only place to build a train. The Control Point start has therefore
* shipped since it was added with a train that could not be loaded, and the Interchange start
* would have shipped the same way — against Jesse's report, which says an Extra started at the
* Interchange "would be Loaded with cars".
*/
describe('an Extra started away from a Division Point can still be made up', () => {
const fill = (s: GameState): string[] => {
for (let i = 0; i < MAX_CONSIST + 1; i++) {
const options = legalActions(s, s.clock.currentActor ?? 0).filter((a) => a.type === 'newTrain.placeCar');
if (options.length === 0) break;
assert.ok(applyIntent(s, s.clock.currentActor ?? 0, options[0]!).ok);
}
return started(s).consist.map((c) => c.type);
};
it('takes a consist in the Interchange yard', () => {
const s = pending(18);
const node = withInterchange(s);
assert.ok(applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
}).ok);
assert.ok(fill(s).length > 0, 'the Extra was never offered a car and would have run empty');
});
it('takes a consist at a Control Point', () => {
const s = pending(18, 'depot');
assert.ok(applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
}).ok);
assert.ok(fill(s).length > 0, 'the Extra was never offered a car and would have run empty');
});
it('stops being made up the moment it starts running', () => {
// Otherwise a train out on the Mainline could be handed cars from the Division Yard — the
// "cars appearing on a train nobody was making up" bug `isBeingMadeUp` exists to prevent.
const s = pending(18);
const node = withInterchange(s);
assert.ok(applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
}).ok);
const tray = started(s);
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.trays.get(tray.id)?.beingMadeUp, undefined, 'a running train is still being made up');
assert.equal(isBeingMadeUp(s.trays.get(tray.id)!), false);
});
});
it('replays a save written before the choice existed exactly as it meant it', () => {
/**
* A save is a seed and a list of intents, so an intent whose meaning moves is a save that
* quietly replays as a different game. The legacy shape carried only `atSeat`: null meant the
* Division Point the NUMBER sent it to, running in the number's direction.
*/
const s = pending(17);
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
const tray = started(s);
assert.equal(tray.direction, 'west', 'the legacy intent stopped meaning what it meant');
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'east');
assert.equal(tray.direction, 'east', 'an even Extra still runs east from wherever it starts');
});
it('takes the Extra off the pending list once, whichever end it started from', () => {
const s = pending(17);
const at = { type: 'newTrain.startExtra', trainNumber: 17, start: { kind: 'divisionPoint', side: 'east' } } as const;
assert.ok(applyIntent(s, 0, at).ok);
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
assert.deepEqual(s.pendingExtras, []);
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }),
'NO_EXTRA_PENDING',
);
});
});
+8 -389
View File
@@ -7,7 +7,6 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, check, areaOf, facilityCarTypes, movesFor, reduce } from '../src/engine/apply.ts';
import { pump } from '../src/engine/advance.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MAX_CONSIST, MOVES_PER_LOCAL_OPS, officeProfile } from '../src/engine/content.ts';
import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
@@ -18,8 +17,11 @@ import { cardDescription, snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -260,122 +262,6 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'the turn cannot be ended even at the limit');
});
describe('a train card is never discarded (Gitea#6)', () => {
/**
* Jesse's ruling, v0.4.9e playtest: "Players are not allowed to discard Train cards. They may
* keep the card in their hand for multiple stages and even multiple days, but they may not
* discard it. If a player has three train cards in their hand, and they draw a fourth, then they
* must play one of those cards."
*
* Extras count too — an Extra is a train, even though it runs once and ends in the Salvage Yard
* where a Timetabled card joins the timetable for the rest of the game.
*
* Note there is no new FORCING mechanism, and deliberately so: the corner is what the two
* existing rules produce together. Nothing discardable plus "you may not end the turn over the
* limit" leaves exactly one legal way on, and playing a train is unconditionally legal.
*/
const handOf = (s: GameState, kinds: string[]): string[] => {
// Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not
// depend on what the shuffle happened to deal.
const picked: string[] = [];
for (const want of kinds) {
for (const [id, card] of s.cards) {
if (card.kind.kind !== want || picked.includes(id)) continue;
picked.push(id);
break;
}
}
assert.equal(picked.length, kinds.length, 'the catalogue is missing a card this test needs');
s.decks.hands.set(0, picked);
return picked;
};
it('refuses the discard, for a Timetabled train and for an Extra alike', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
assert.equal(
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
'TRAINS_ARE_NEVER_DISCARDED',
);
assert.equal(
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
'TRAINS_ARE_NEVER_DISCARDED',
);
// And everything else is still discardable — the rule is about trains, not about discarding.
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
});
it('never offers the discard, so the bot needs no rule of its own', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
const offered = legalActions(s, 0).filter(
(i) => i.type === 'card.discard' && i.cardId === timetabled,
);
assert.deepEqual(offered, [], 'a train discard was offered as a legal action');
});
it('leaves PLAYING a train as the only way out of a hand of four trains', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const four = handOf(s, ['timetabledTrain', 'timetabledTrain', 'timetabledTrain', 'extraTrain']);
assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit');
// Over the limit, so the turn cannot be ended...
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
// ...and not one of them may be discarded...
for (const id of four) {
assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED');
}
// ...but playing one is always legal, so the player is never actually stuck.
assert.equal(check(s, 0, { type: 'card.play', cardId: four[0]! }), null);
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok);
assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT);
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'playing a train did not free the turn');
});
it('lets a train be held across Stages and into the next Day', () => {
// "They may keep the card in their hand for multiple stages and even multiple days." Nothing
// sweeps a hand at a Stage or Day boundary, and this is what says so out loud.
const s = game();
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
const startDay = s.clock.day;
// Play out Stages by taking whatever ends the current turn, until the Day turns over.
for (let guard = 0; guard < 400 && s.clock.day === startDay; guard++) {
pump(s);
const actor = s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
const end = options.find((i) => i.type.endsWith('.end')) ?? options[0];
if (!end) break;
applyIntent(s, actor, end);
}
assert.ok(s.clock.day > startDay, `the Day never turned (stopped at ${s.clock.day}/${s.clock.stage})`);
assert.ok(
(s.decks.hands.get(0) ?? []).includes(timetabled!),
'the train did not survive being held into the next Day',
);
assert.equal(
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
'TRAINS_ARE_NEVER_DISCARDED',
'a Day boundary made a train discardable',
);
});
it('tells the player on the card itself, and on the button when every card is a train', () => {
// The Gitea#2 lesson: a rule the player cannot see is a board with nothing to click and no
// reason given.
const s = game();
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
const f = snapshot(s, [], null);
// `hand` is reversed for display, so compare as a set rather than by position.
assert.deepEqual([...f.handDiscardable].sort(), [false, false, true]);
});
});
it('discards face up ON TOP of a chosen Department, burying what was there', () => {
// The choice of WHICH Department is the strategy: a card put on an empty-ish pile is an offer, a
// card put on top of one a rival wants takes that card out of reach. Overwriting the slot — what
@@ -1401,11 +1287,9 @@ describe("a Modifier grants only what its host's flow can use", () => {
* and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied
* only the difference between two tiers and knew nothing about what had been discarded.
*
* This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again
* now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the
* suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across
* that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently
* and tests nothing about the upgrade path.
* This used to be written against an Ice House on a Grocer's Warehouse. That case no longer
* suppresses anything, because the Grocer's is a both-direction facility — which was the other
* half of the same report.
*/
const s = game();
const area = areaOf(s, 0);
@@ -2014,268 +1898,3 @@ describe('the switching job a player actually does: put a car in a siding, take
);
});
});
// ---------------------------------------------------------------------------
/**
* The v0.4.9d playtest, three reports with two causes.
*
* "Freight House: boxcars loaded cannot be immediately unloaded", "passenger stations: passengers
* just boarded cannot be immediately unloaded" — one rule, `RollingStock.origin`. And "operating two
* trains in a station: the select button does not work, regardless of which you pick it is always
* one train, not the other" — the porter intents carrying no tray.
*/
describe('a load may not be broken in the district that made it (v0.4.9e)', () => {
const office = (s: GameState) => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
/** An Office that can work passengers, with someone waiting and Porters to hand. */
function platform(s: GameState): void {
const f = office(s);
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }];
s.yards.divisionYard.push({ type: 'coach', loaded: false });
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
}
/** A tray standing on an A/D track at the Office. */
function atOffice(s: GameState, consist: CrewTray['consist']): string {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist,
direction: 'east', position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord }, movesUsed: 0,
});
areaOf(s, 0).adOccupancy.push(id);
return id;
}
/** A Freight House with a load staged, an empty spotted, and Laborers enough to finish. */
function freightHouse(s: GameState, coord: GridCoord): void {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight', subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [{ type: 'boxcar', loaded: true }],
inboundBox: [],
capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null],
industryTrack: { cars: [{ type: 'boxcar', loaded: false }] },
laborers: 9, porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [], enhancements: [],
} as TrackCard);
s.yards.divisionYard.push({ type: 'boxcar', loaded: false }, { type: 'boxcar', loaded: false });
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
}
/** Walk a staged load all the way onto the spotted car. */
function finishLoad(s: GameState, coord: GridCoord): void {
applyIntent(s, 0, { type: 'laborer.startLoad', at: coord });
for (const box of [0, 1, 2]) applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
}
it('refuses to unload the boxcar the Freight House just loaded', () => {
const s = game();
const coord = at(-1, 0);
freightHouse(s, coord);
finishLoad(s, coord);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
assert.deepEqual(f.industryTrack.cars.map((c) => c.loaded), [true], 'the load never reached the car');
assert.equal(f.industryTrack.cars[0]!.origin, 0, 'the load is not stamped with the district that made it');
assert.equal(
check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }),
'LOADED_IN_THIS_DISTRICT',
);
// And it is not merely absent from the menu by accident — the menu agrees with `check`.
assert.ok(
!legalActions(s, 0).some((i) => i.type === 'laborer.beginUnload'),
'the unload was still offered',
);
});
it('unloads a load that came from somewhere else', () => {
// The mirror, and the reason the rule is a stamp rather than a per-facility flag: a car made up
// at a Division Point out of the common supply carries no origin, and is exactly the inbound
// traffic a district lives on.
const s = game();
const coord = at(-1, 0);
freightHouse(s, coord);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.industryTrack.cars = [{ type: 'boxcar', loaded: true }];
assert.equal(check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }), null);
});
it('refuses to detrain the passengers this Office just put aboard', () => {
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: false }]);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
const coach = s.trays.get(tray)!.consist[0]!;
assert.equal(coach.loaded, true, 'nobody boarded');
assert.equal(coach.origin, 0, 'the coach is not stamped with the Office that filled it');
assert.equal(
check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }),
'LOADED_IN_THIS_DISTRICT',
);
});
it('detrains passengers who boarded somewhere else', () => {
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: true }]);
assert.equal(check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }), null);
});
it('takes the origin stamp off a coach that reaches the red box', () => {
// The stamp belongs to the LOAD. A coach going into the inbound box has finished its journey and
// heads back to a yard from there; carrying the stamp on would poison the common supply.
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]);
assert.ok(applyIntent(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
assert.equal(office(s).inboundBox[0]!.origin, undefined, 'the stamp survived the red box');
});
});
describe('two trains in one station are told apart (v0.4.9e)', () => {
function twoAtOffice(s: GameState): [string, string] {
const area = areaOf(s, 0);
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
const ids: string[] = [];
for (let n = 0; n < 2; n++) {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: false }],
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push(id);
ids.push(id);
}
return [ids[0]!, ids[1]!];
}
it('offers boarding on each train, not once for the platform', () => {
// REPORTED: "operating two trains in a station, the select button does not work — regardless of
// which you pick, it is always one train, not the other." There was one button, because the
// intent carried no train at all.
const s = game();
const [a, b] = twoAtOffice(s);
const boards = legalActions(s, 0).filter((i) => i.type === 'porter.board');
assert.deepEqual(
boards.map((i) => (i as { trayId?: string }).trayId).sort(),
[a, b].sort(),
'both trains at the platform must be offered',
);
});
it('boards the train the player named, not the first on the A/D tracks', () => {
const s = game();
const [a, b] = twoAtOffice(s);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: b }).ok);
assert.equal(s.trays.get(b)!.consist[0]!.loaded, true, 'the named train did not get the passengers');
assert.equal(s.trays.get(a)!.consist[0]!.loaded, false, 'the other train was filled instead');
});
it('still works for an intent that names no train, so old saves replay', () => {
// `trayId` is optional for the same reason `switch.move`'s `via` is: intents are the canonical
// record every save and every undo replays against.
const s = game();
const [a] = twoAtOffice(s);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord }).ok);
assert.equal(s.trays.get(a)!.consist[0]!.loaded, true, 'the first eligible train should have taken them');
});
});
/**
* Reported from the v0.4.9d playtest and NOT reproduced: "when I back up to collect standing cars
* and, further down the tracks, the caboose, I get the caboose but the cars remain. I can later
* drive right through them."
*
* Coupling is mandatory (§A.4) and the walk accumulates what it meets card by card, so cars the
* engine can see always couple — which means cars a train can drive through are cars the engine does
* not think are there. Nothing found: `carsOn` is the single answer to "what is standing here" and
* the movement walk, the sweep and every renderer all ask it. These pin the shapes that were tried,
* so if the case is found later it is somewhere none of them cover.
*/
describe('backing up over a cut to something beyond it takes both (v0.4.9d report)', () => {
const boxcar = () => ({ type: 'boxcar' as const, loaded: false });
const caboose = () => ({ type: 'caboose' as const, loaded: false });
/** A Freight House card, with cars spotted on its industry track. */
function industry(cars: TrackCard['standing']): TrackCard {
return {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true, standing: [], standingWest: 0,
facility: {
kind: 'freight', subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null], industryTrack: { cars: [...cars] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [], enhancements: [],
} as TrackCard;
}
const empty = (s: GameState, ...coords: GridCoord[]): void => {
for (const c of coords) {
assert.deepEqual(carsOn(areaOf(s, 0).grid.get(coordKey(c))!), [], `cars left standing at (${c.col},${c.row})`);
}
};
it('takes a cut standing on plain track on the way to the caboose', () => {
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(0, 1), straight([boxcar(), boxcar()]));
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 2));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
it('takes cars SPOTTED AT AN INDUSTRY on the way, not just the destination', () => {
// Jesse's best guess at the reported shape. An industry card is plain east-west track carrying a
// facility, and `carsOn` reads its industry track rather than the card — so this is the case
// where the two could have come apart.
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(0, 1), industry([boxcar(), boxcar()]));
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 2));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
it("takes the train's own cut off the square it is standing on as well", () => {
const s = game();
addCard(s, at(0, 1), straight());
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 1), [boxcar(), boxcar()]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
// Set the pair out behind the engine, pull forward, then back up past them to the caboose.
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
assert.equal(carsOn(areaOf(s, 0).grid.get(coordKey(at(0, 1)))!).length, 2);
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
});
+5 -2
View File
@@ -18,8 +18,11 @@ import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
+5 -2
View File
@@ -18,8 +18,11 @@ import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
+87
View File
@@ -0,0 +1,87 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { areaOf } from '../src/engine/apply.ts';
import { createGame } from '../src/engine/setup.ts';
import { coordKey } from '../src/engine/state.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { applyDelta, deltaFrame } from '../src/sim/frame-delta.ts';
import { snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'solitaire',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const game = (seed = 1): GameState => createGame({ id: 'g', seed, config, playerNames: ['a'] });
describe('deltaFrame — the live one-step-back board diff', () => {
it('nulls cells/facilities/division when the board has not changed, keeps everything else', () => {
const s = game();
const first = snapshot(s, [], null);
s.players[0]!.revenue = 42; // a non-board change
const second = snapshot(s, [], null);
const delta = deltaFrame(first, second);
assert.equal(delta.cells, null, 'unchanged cells were not nulled');
assert.equal(delta.facilities, null, 'unchanged facilities were not nulled');
assert.equal(delta.division, null, 'unchanged division was not nulled');
assert.equal(delta.revenue, 42, 'a real change was dropped along with the unchanged board');
});
it('sends the real board when it changed', () => {
const s = game();
const first = snapshot(s, [], null);
const area = areaOf(s, 0);
area.grid.set(coordKey({ row: area.runningRow - 1, col: 0 }), {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const second = snapshot(s, [], null);
const delta = deltaFrame(first, second);
assert.notEqual(delta.cells, null, 'a real board change was nulled');
assert.deepEqual(delta.cells, second.cells);
});
it('never nulls anything on a first connect (no previous Frame)', () => {
const s = game();
const frame = snapshot(s, [], null);
const delta = deltaFrame(null, frame);
assert.deepEqual(delta.cells, frame.cells);
assert.deepEqual(delta.facilities, frame.facilities);
assert.deepEqual(delta.division, frame.division);
});
it('applyDelta round-trips: merge(delta(A, B)) against A reconstructs B exactly', () => {
const s = game();
const a = snapshot(s, [], null);
s.players[0]!.revenue = 7;
const area = areaOf(s, 0);
area.grid.set(coordKey({ row: area.runningRow - 1, col: 1 }), {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const b = snapshot(s, [], null);
const merged = applyDelta(a, deltaFrame(a, b));
assert.deepEqual(merged, b);
});
it('throws rather than silently reconstructing garbage when previous is missing but the delta says "unchanged"', () => {
const s = game();
const frame = snapshot(s, [], null);
const delta = deltaFrame(frame, frame); // everything nulled, since nothing changed
assert.throws(() => applyDelta(null, delta));
});
});
+5 -2
View File
@@ -29,8 +29,11 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
+7 -4
View File
@@ -32,8 +32,11 @@ import { snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
@@ -733,7 +736,7 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
id: 'reg',
seed: 4,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
@@ -796,7 +799,7 @@ describe('Q13 — a train that catches the one ahead runs into it', () => {
const s = createGame({
id: 'rear', seed: 3,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['bot'],
+198 -4
View File
@@ -11,21 +11,32 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { areaAtSeat, areaOf } from '../src/engine/apply.ts';
import { applyIntent, areaAtSeat, areaOf } from '../src/engine/apply.ts';
import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
import { coordKey, playerAtSeat, seatOf, subdivisions } from '../src/engine/state.ts';
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { snapshot } from '../src/sim/view.ts';
import { divisionSvg } from '../src/sim/board-svg.ts';
import { impediments } from '../src/sim/narrate.ts';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { actionMenu } from '../src/web/game.ts';
import type { Game } from '../src/web/game.ts';
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
const wrap = (s: GameState): Game =>
({ state: s, seed: s.seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null });
const competitive: GameConfig = {
mode: 'competitive',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
};
@@ -474,6 +485,9 @@ describe('the view shows one seat at a time', () => {
* which is a list of squares that do not exist on the board they are looking at.
*/
const s = game(3);
// A real floor to pace against — the shared `competitive` fixture leaves it off (0) since most
// tests in this file don't care, but pacing against "off" is trivially "always on pace" (view.ts).
s.config.minCombinedRevenue = 10;
s.players[0]!.revenue = 1;
s.players[1]!.revenue = 9;
s.players[2]!.revenue = 17;
@@ -600,3 +614,183 @@ describe('scoring lands on the right seat', () => {
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
});
});
describe('the New Train phase car-placement round rotates (§7, Gap 9)', () => {
/**
* REGRESSION. `newTrainPhase` used to hand the whole car-filling loop to a fixed actor —
* `actorOffset` is reset to 0 entering the phase and was never incremented, so `actorAt(s, 0)`
* always resolved to the Superintendent, who placed every car of every train alone. The written
* rule (`rules-v0.2.md` §7, Gap 9) 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 example showing seats alternating. The fix reads the round position off
* `tray.consist.length` instead, which is already exactly that counter and resets per train.
*/
it('cycles Superintendent-then-left, one car per player, wrapping as the round repeats', () => {
// Train 1, "Crack Limited" — 3 coaches, no freight or caboose (content.ts) — small enough to
// exercise both a player count that wraps (2p: seats 0,1,0) and one that doesn't (3p: 0,1,2).
for (const players of [2, 3]) {
const s = game(players);
s.clock.phase = 'newTrain';
s.trays.set('t1', {
id: 't1',
trainNumber: 1,
trainIsExtra: false,
engineAt: 0,
consist: [],
direction: 'west',
position: { at: 'divisionPoint', side: 'east' },
movesUsed: 0,
});
s.yards.divisionYard.push(
{ type: 'coach', loaded: false },
{ type: 'coach', loaded: false },
{ type: 'coach', loaded: false },
);
const expected = [0, 1, 2].map((offset) => playerLeftOf(s, s.clock.superintendent, offset));
const seenActors: PlayerIndex[] = [];
for (let guard = 0; guard < 10; guard++) {
const r = advance(s);
if (!r.needsInput) break;
const actor = s.clock.currentActor!;
seenActors.push(actor);
const result = applyIntent(s, actor, {
type: 'newTrain.placeCar',
trayId: 't1',
carType: 'coach',
loaded: false,
});
assert.ok(result.ok, `${players}p: placeCar rejected — ${result.ok ? '' : result.code}`);
}
const wanted = [0, 1, 2].map((i) => expected[i % players]!);
assert.deepEqual(
seenActors,
wanted,
`${players}p: actor sequence was [${seenActors}], wanted [${wanted}] (Superintendent-then-left)`,
);
}
});
});
describe('actionMenu is seat-safe (Phase 2 prep)', () => {
/**
* REGRESSION. `actionMenu(game, seat)` used `seat` only for the `hand` field — `options`/`direct`/
* `placeable`/`makeUp` all came from `currentActor(game)` regardless of which seat was asked. 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 hand. Found tracing Phase 2's per-seat Menu step.
*/
it('gives the acting seat its real options, and every other seat none at all', () => {
const s = game(3);
const g = wrap(s);
const actorSeat = s.clock.currentActor!;
const waiting = [0, 1, 2].filter((seat) => seat !== actorSeat);
const actorMenu = actionMenu(g, actorSeat as PlayerIndex);
assert.ok(actorMenu.options.length > 0, "the acting seat's Menu had no options at all");
for (const seat of waiting) {
const menu = actionMenu(g, seat as PlayerIndex);
assert.deepEqual(menu.options, [], `seat ${seat} (not acting) was given real options`);
assert.deepEqual(menu.direct, [], `seat ${seat} (not acting) was given direct actions`);
assert.deepEqual(menu.placeable, [], `seat ${seat} (not acting) was given placeable actions`);
assert.equal(menu.makeUp, null, `seat ${seat} (not acting) was given a make-up panel`);
}
});
it('still shows a waiting seat its own hand, just nothing to do with it', () => {
const s = game(3);
const g = wrap(s);
const actorSeat = s.clock.currentActor!;
const waitingSeat = [0, 1, 2].find((seat) => seat !== actorSeat)! as PlayerIndex;
const menu = actionMenu(g, waitingSeat);
const expectedHand = s.decks.hands.get(waitingSeat) ?? [];
assert.equal(menu.hand.length, expectedHand.length, "the waiting seat's own hand did not come through");
for (const card of menu.hand) {
assert.equal(card.playNow, null, 'a waiting seat was offered a way to play a card');
assert.equal(card.spots, 0, 'a waiting seat was offered somewhere to place a card');
}
});
});
describe('the map says whose railroad is whose', () => {
it('the Frame names its own viewer, which nothing on it did before', () => {
// Every private field is already scoped to one player — hand, Office Area, revenue, option —
// but a page rendering that could not say WHICH player, so it could not tell you which of four
// railroads was yours.
const s = game(4);
for (const viewer of [0, 1, 2, 3] as PlayerIndex[]) {
const f = snapshot(s, [], null, null, null, false, viewer);
assert.equal(f.viewer, viewer);
assert.equal(f.viewerSeat, seatOf(s, viewer), 'viewerSeat must be the seat, not the player index');
}
});
it('carries the opening D12 that decided the west-to-east chain', () => {
const s = game(4);
const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex);
assert.equal(f.openingRolls.division.length, 4, 'one division roll per player');
assert.equal(f.openingRolls.superintendent.length, 4);
// The rule the rolls implement: ascending by roll, west to east — so sorting the players by
// their roll must reproduce the seating exactly (§4.4).
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat).map((p) => p.index);
const byRoll = [...f.players]
.map((p) => p.index)
.sort((a, b) => f.openingRolls.division[a]! - f.openingRolls.division[b]! || b - a);
assert.deepEqual(bySeat, byRoll, 'seating does not follow the opening rolls');
});
it('is not always the host at the eastern end — the roll decides', () => {
// The question this answers: player 0 is the lobby host, and the eastern end is the LAST seat.
// If the two were the same thing, every seed would put player 0 there.
const easternPlayer = (seed: number): number => {
const s = game(4, seed);
const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex);
return [...f.players].sort((a, b) => b.seat - a.seat)[0]!.index;
};
const seen = new Set([101, 202, 303, 404, 505, 606].map(easternPlayer));
assert.ok(seen.size > 1, `the eastern end was always player ${[...seen][0]} across six seeds`);
});
it('labels each Office with its owner, marking whose move it is and which one is yours', () => {
const s = game(3);
const viewer = 1 as PlayerIndex;
const f = snapshot(s, [], null, null, null, false, viewer);
const svg = divisionSvg(f.division, { players: f.players, actor: f.actor, viewer: f.viewer });
const owners = [...svg.matchAll(/<text class="bs-name([^"]*bs-owner[^"]*)"[^>]*>([^<]*)<\/text>/g)].map(
(m) => ({ classes: m[1]!, text: m[2]! }),
);
assert.equal(owners.length, 3, 'expected one owner-labelled Office per player');
// Every player is named somewhere, in seat order.
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat);
assert.deepEqual(
owners.map((o) => o.text.replace(' (you)', '')),
bySeat.map((p) => p.name),
);
const you = owners.find((o) => o.classes.includes('bs-you'));
assert.ok(you, 'the viewer’s own Office is not marked');
assert.ok(you!.text.endsWith('(you)'), 'colour alone cannot say which railroad is the reader’s');
assert.equal(
you!.text.replace(' (you)', ''),
f.players.find((p) => p.index === viewer)!.name,
'the (you) mark is on the wrong Office',
);
const turn = owners.filter((o) => o.classes.includes('bs-turn'));
assert.equal(turn.length, f.actor === null ? 0 : 1, 'exactly one Office is the current actor’s');
if (f.actor !== null) {
assert.equal(turn[0]!.text.replace(' (you)', ''), f.players.find((p) => p.index === f.actor)!.name);
}
});
it('draws no owner marks at all when given no roster, so the replay still renders', () => {
const s = game(3);
const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex);
assert.equal(divisionSvg(f.division).includes('bs-owner'), false);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* §7's redaction test — "the single most important test in the plan."
*
* Everything else about `Frame` degrades gracefully; a redaction bug hands one player's hand to
* another and cannot be walked back once it has been seen. `test/multiplayer.test.ts`'s "the view
* shows one seat at a time" section already proves `snapshot(s, ..., viewer)` gives each seat its
* own hand, board and Revenue — spot-checks that today's code does the right thing. This is the
* different, exhaustive check: serialize a seat's whole `Frame` and assert none of some OTHER seat's
* actual secret data appears anywhere in it, so a future careless edit is caught rather than assumed
* safe. No server needed — `snapshot()` and a multi-player `GameState` are all this exercises.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/** Plays a real multi-player game partway — enough for every seat to hold a real, distinct hand. */
function midGame(players: number, seed: number): GameState {
const s = createGame({
id: `redact-${players}`,
seed,
config,
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
});
const r = playGame(s, developerBot, pump, 400);
// A partial or finished game both exercise real hands — either is fine for this check.
void r;
return s;
}
describe('redaction — a seat\'s Frame never carries another seat\'s secrets', () => {
it('never contains another seat\'s actual hand-card ids', () => {
for (const players of [3, 4]) {
const s = midGame(players, 1000 + players);
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
const serialized = JSON.stringify(snapshot(s, [], null, null, null, false, viewer));
for (let other = 0 as PlayerIndex; other < players; other++) {
if (other === viewer) continue;
for (const cardId of s.decks.hands.get(other) ?? []) {
assert.ok(
!serialized.includes(`"${cardId}"`),
`${players}p seed ${1000 + players}: seat ${viewer}'s Frame contains seat ${other}'s ` +
`hand card id "${cardId}"`,
);
}
}
}
}
});
it('never contains the Home Office deck\'s order or contents, only its count', () => {
for (const players of [3, 4]) {
const s = midGame(players, 2000 + players);
// The deck's own card ids are the thing that must never leak — distinct from any hand's ids,
// since a card once dealt is removed from `homeOffice` (state.ts).
const deckIds = new Set(s.decks.homeOffice);
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
const frame = snapshot(s, [], null, null, null, false, viewer);
assert.equal(frame.deck, s.decks.homeOffice.length, 'deck field is not a plain count');
const serialized = JSON.stringify(frame);
for (const cardId of deckIds) {
assert.ok(
!serialized.includes(`"${cardId}"`),
`${players}p seed ${2000 + players}: seat ${viewer}'s Frame contains a Home Office deck id "${cardId}"`,
);
}
}
}
});
it('never carries the seed or rngState — Frame has no field for either', () => {
// A structural guarantee, not a runtime one: confirmed here so a future field addition to Frame
// that reintroduces one of these is at least forced past a reader of this test, if not the type
// system directly (see Frame in src/sim/view.ts, which carries neither today).
const s = midGame(3, 3003);
const frame = snapshot(s, [], null, null, null, false, 0);
assert.ok(!('seed' in frame), 'Frame gained a seed field');
assert.ok(!('rngState' in frame), 'Frame gained an rngState field');
const serialized = JSON.stringify(frame);
assert.ok(!serialized.includes(String(s.seed)), 'the seed value leaked into the Frame some other way');
});
it('only the viewer\'s own hand and handCount are non-public — everything else matches across seats', () => {
// The redaction surface is four fields (§7), not sixty event types. Cross-check that seats agree
// on everything else a Frame carries about shared state.
const s = midGame(3, 4004);
const frames = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p as PlayerIndex));
for (const f of frames) {
assert.deepEqual(f.timetable, frames[0]!.timetable, 'the public timetable differs by seat');
assert.deepEqual(f.deck, frames[0]!.deck, 'the deck count differs by seat');
assert.deepEqual(
f.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })),
frames[0]!.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })),
'public standing (names, Revenue, hand COUNTS) differs by seat',
);
}
});
});
+10 -4
View File
@@ -9,6 +9,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiveRevenueFloor } from '../src/engine/content.ts';
import type { GameEvent } from '../src/engine/events.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig } from '../src/engine/state.ts';
@@ -17,10 +18,15 @@ import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
import { summarize } from '../src/sim/stats.ts';
// Mirrors `record()`'s own default exactly (`replay.ts`) — "does not drift from the engine" below
// plays the same seed through both paths and compares outcomes, so they must share one floor.
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: collectiveRevenueFloor(1, 5),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -52,8 +58,8 @@ const SAMPLES: GameEvent[] = [
{ type: 'carPassed', player: 0, trayId: 't0' },
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
{ type: 'clearanceGiven', trainId: 't1', allow: false },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } },
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } },
{ type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
+253
View File
@@ -0,0 +1,253 @@
/**
* The lobby (`src/server/lobby.ts`) — pure logic, no sockets, no filesystem, exercised directly.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { GameConfig } from '../../src/engine/state.ts';
import {
createLobby,
freshGameCode,
joinLobby,
playerCountAllowed,
reassignHost,
setBotSeat,
startLobby,
} from '../../src/server/lobby.ts';
const competitive: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const solitaire: GameConfig = { ...competitive, mode: 'solitaire' };
describe('creating and joining', () => {
it('seats the host at 0 and lays out the whole table at once', () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 3);
assert.equal(session.player, 0);
assert.equal(lobby.hostToken, session.token);
// The table is its full size immediately — the empty chairs exist and are waiting, rather
// than being appended as people arrive.
assert.equal(lobby.seats.length, 3);
assert.deepEqual(lobby.seats[0], { kind: 'human', token: session.token, displayName: 'Alice' });
assert.deepEqual(lobby.seats.slice(1), [null, null]);
assert.deepEqual(lobby.joinOrder, [session.token]);
});
it('fills the next empty seat, in order', () => {
const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
const j2 = joinLobby(l1, 'Bob');
assert.ok(j2.ok);
if (!j2.ok) return;
assert.equal(j2.session.player, 1);
const j3 = joinLobby(j2.lobby, 'Carol');
assert.ok(j3.ok);
if (!j3.ok) return;
assert.equal(j3.session.player, 2);
assert.deepEqual(j3.lobby.joinOrder, [s1.token, j2.session.token, j3.session.token]);
});
it('refuses a join once every chair is taken', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0001', 4).lobby;
for (const name of ['Bob', 'Carol', 'Dave']) {
const r = joinLobby(lobby, name);
assert.ok(r.ok);
if (r.ok) lobby = r.lobby;
}
const fifth = joinLobby(lobby, 'Eve');
assert.deepEqual(fifth, { ok: false, code: 'LOBBY_FULL' });
});
it('refuses a 2nd join to a one-chair table', () => {
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002', 1);
const second = joinLobby(lobby, 'Bob');
assert.deepEqual(second, { ok: false, code: 'LOBBY_FULL' });
});
it('rejoins into a seat an earlier player vacated, not past the end', () => {
// Joining always fills the FIRST empty seat, so a bot-seat cleared back to empty (setBotSeat)
// is exactly as joinable as one nobody ever filled.
let lobby = createLobby(competitive, 'Alice', 'RAIL-0003', 3).lobby;
lobby = setBotSeat(lobby, 1, true);
lobby = setBotSeat(lobby, 1, false);
const r = joinLobby(lobby, 'Bob');
assert.ok(r.ok);
if (!r.ok) return;
assert.equal(r.session.player, 1, 'should take the reopened chair 1');
assert.equal(r.lobby.seats.length, 3, 'joining must never resize the table');
assert.equal(r.lobby.seats[2], null);
});
it('never grows the table, whoever asks', () => {
// The old model appended a seat for anyone who turned up, which is how a lobby could end up
// holding more chairs than the host ever asked for.
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0013', 2);
const bob = joinLobby(lobby, 'Bob');
assert.ok(bob.ok);
if (!bob.ok) return;
assert.equal(bob.lobby.seats.length, 2);
assert.deepEqual(joinLobby(bob.lobby, 'Carol'), { ok: false, code: 'LOBBY_FULL' });
});
});
describe('bot seats', () => {
it('fills only an empty seat, and clears only a bot seat', () => {
const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004', 2);
const withBot = setBotSeat(l0, 1, true);
assert.deepEqual(withBot.seats[1], { kind: 'bot' });
// Filling an already-bot seat again is a no-op, not a crash.
const stillBot = setBotSeat(withBot, 1, true);
assert.deepEqual(stillBot.seats[1], { kind: 'bot' });
// Clearing seat 0 (a human) does nothing — a host is removed by leaving, not overwritten.
const untouched = setBotSeat(withBot, 0, false);
assert.deepEqual(untouched.seats[0], l0.seats[0]);
const cleared = setBotSeat(withBot, 1, false);
assert.equal(cleared.seats[1], null);
});
it('refuses a chair that is not at the table, instead of padding one in', () => {
// Padding is what used to put a hole in the seats array: dropping a bot into chair 3 of a
// 2-chair table grew it to 4 with a null at 2, and Start then refused for reasons the host
// had no way to see.
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0014', 2);
assert.equal(setBotSeat(lobby, 3, true), lobby);
assert.equal(setBotSeat(lobby, 2, true), lobby);
assert.equal(lobby.seats.length, 2);
});
});
describe('host transfer', () => {
it('passes to the earliest-joined remaining human seat when the host departs', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0005', 2).lobby;
const hostToken = lobby.hostToken;
const j2 = joinLobby(lobby, 'Bob');
assert.ok(j2.ok);
if (!j2.ok) return;
lobby = j2.lobby;
// Alice's own seat is cleared (simulating her leaving the table entirely, not just dropping
// her connection) so the transfer target is unambiguous in this test.
lobby = { ...lobby, seats: [null, lobby.seats[1]!] };
const after = reassignHost(lobby, hostToken);
assert.equal(after.hostToken, j2.session.token);
});
it('does nothing when the departing token is not the host', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0006', 2);
const after = reassignHost(lobby, 'not-a-real-token');
assert.equal(after, lobby);
});
it('leaves hostToken alone when no other human seat exists', () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0007', 2);
const after = reassignHost(lobby, session.token);
assert.equal(after.hostToken, session.token);
});
});
describe('starting', () => {
it('refuses a non-host caller', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0008', 2);
joinLobby(lobby, 'Bob');
assert.deepEqual(startLobby(lobby, 'someone-elses-token'), { ok: false, code: 'NOT_HOST' });
});
it('refuses to start while a chair is still empty', () => {
const lobby = createLobby(competitive, 'Alice', 'RAIL-0009', 3).lobby;
const j2 = joinLobby(lobby, 'Bob');
assert.ok(j2.ok);
if (!j2.ok) return;
assert.deepEqual(startLobby(j2.lobby, j2.lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
});
it('refuses a solo human at a table sized for more', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010', 2);
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
});
it('starts a full 2-player lobby, naming humans by their display name and numbering the bot', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0011', 2).lobby;
lobby = setBotSeat(lobby, 1, true);
const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot 1'], botSeats: [1] });
});
it('numbers bots so two of them at one table can be told apart', () => {
// They are two different railroads on the Division map, and a map that labels both "Bot"
// cannot answer "which one is that".
let lobby = createLobby(competitive, 'Alice', 'RAIL-0016', 3).lobby;
lobby = setBotSeat(setBotSeat(lobby, 1, true), 2, true);
const r = startLobby(lobby, lobby.hostToken);
assert.ok(r.ok);
if (!r.ok) return;
assert.deepEqual(r.playerNames, ['Alice', 'Bot 1', 'Bot 2']);
assert.deepEqual(r.botSeats, [1, 2]);
});
it('starts a solitaire lobby of exactly 1', () => {
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012', 1);
const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice'], botSeats: [] });
});
it('seat index is player index, with no compaction to shift it', () => {
// The seats array is never resized or squeezed, so the chair a player joined into is the
// player index the game gives them — which is what every PlayerSession already recorded at
// join time, and what /api/stream and /api/intent route by.
let lobby = createLobby(competitive, 'Alice', 'RAIL-0015', 4).lobby;
const bob = joinLobby(lobby, 'Bob');
assert.ok(bob.ok);
if (!bob.ok) return;
lobby = setBotSeat(setBotSeat(bob.lobby, 2, true), 3, true);
const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bob', 'Bot 1', 'Bot 2'], botSeats: [2, 3] });
assert.equal(bob.session.player, 1, "Bob's stored player index still names his chair");
});
});
describe('playerCountAllowed', () => {
it('solitaire is exactly 1', () => {
assert.equal(playerCountAllowed('solitaire', 1), true);
assert.equal(playerCountAllowed('solitaire', 0), false);
assert.equal(playerCountAllowed('solitaire', 2), false);
});
it('competitive and coop are 2-4', () => {
for (const mode of ['competitive', 'coop'] as const) {
assert.equal(playerCountAllowed(mode, 1), false);
assert.equal(playerCountAllowed(mode, 2), true);
assert.equal(playerCountAllowed(mode, 4), true);
assert.equal(playerCountAllowed(mode, 5), false);
}
});
});
describe('game codes', () => {
it('skips codes the caller marks taken', () => {
let calls = 0;
const code = freshGameCode((c) => {
calls++;
return calls < 3; // taken twice, free on the third
});
assert.equal(typeof code, 'string');
assert.equal(calls, 3);
});
it('is speakable — a word, a dash, four digits', () => {
const code = freshGameCode(() => false);
assert.match(code, /^[A-Z]+-\d{4}$/);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { GameConfig } from '../../src/engine/state.ts';
import type { SavedGame } from '../../src/server/session.ts';
import { appendTiming, loadGame, writeGame } from '../../src/server/persistence.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const saved: SavedGame = {
seed: 42,
config,
playerNames: ['Alice', 'Bob'],
history: [{ type: 'localOps.choose', option: 'draw' }],
status: 'active',
createdAt: 1000,
botSeats: [],
};
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(join(tmpdir(), 'station-master-persist-'));
try {
return await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
describe('game persistence (Phase 3)', () => {
it('writes and reads back exactly what was written', () =>
withTempDir(async (dir) => {
await writeGame(dir, saved, '1.2.3');
const result = await loadGame(dir, '1.2.3');
assert.equal(result.found, true);
if (!result.found) return;
assert.equal(result.ok, true);
if (!result.ok) return;
assert.deepEqual(result.saved, saved);
}));
it('refuses a version mismatch explicitly, naming both versions', () =>
withTempDir(async (dir) => {
await writeGame(dir, saved, '1.2.3');
const result = await loadGame(dir, '9.9.9');
assert.equal(result.found, true);
if (!result.found) return;
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.storedVersion, '1.2.3');
assert.equal(result.currentVersion, '9.9.9');
}));
it('reports not-found rather than throwing when nothing has been saved yet', () =>
withTempDir(async (dir) => {
const result = await loadGame(dir, '1.2.3');
assert.deepEqual(result, { found: false });
}));
it('a later writeGame fully replaces the earlier one, not merges with it', () =>
withTempDir(async (dir) => {
await writeGame(dir, saved, '1.2.3');
const grown: SavedGame = { ...saved, history: [...saved.history, { type: 'draw.end' }] };
await writeGame(dir, grown, '1.2.3');
const result = await loadGame(dir, '1.2.3');
assert.equal(result.found, true);
if (!result.found) return;
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.saved.history.length, 2);
}));
it('never leaves a stray .tmp file behind after a write', () =>
withTempDir(async (dir) => {
await writeGame(dir, saved, '1.2.3');
await assert.rejects(() => readFile(join(dir, 'game.json.tmp')));
}));
it('appendTiming accumulates across calls rather than overwriting', () =>
withTempDir(async (dir) => {
await appendTiming(dir, { player: 0, phase: 'localOps', day: 1, stage: 1, startedAt: 1, endedAt: 2 });
await appendTiming(dir, { player: 1, phase: 'localOps', day: 1, stage: 1, startedAt: 2, endedAt: 3 });
const text = await readFile(join(dir, 'turn-timings.json'), 'utf8');
const timings = JSON.parse(text) as unknown[];
assert.equal(timings.length, 2);
}));
});
+302
View File
@@ -0,0 +1,302 @@
/**
* The game session host (`src/server/session.ts`) — pure logic, no sockets, exercised directly.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts';
import type { Push } from '../../src/server/session.ts';
import { createSession, resumeSession } from '../../src/server/session.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
describe('the game session host', () => {
it('gives a fresh connect a full Frame — nothing nulled', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex);
assert.notEqual(push.frame!.cells, null, 'a first connect nulled the board');
assert.notEqual(push.frame!.division, null, 'a first connect nulled the division');
});
it('only the current actor gets a real Menu; every other seat gets null', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const a = session.connect(0 as PlayerIndex);
const b = session.connect(1 as PlayerIndex);
const actorPushes = [a, b].filter((p) => p.menu !== null);
assert.equal(actorPushes.length, 1, 'more than one seat (or zero) was given a real Menu');
});
it('rejects an intent from a seat that is not the current actor, with NOT_YOUR_TURN', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
// Whichever seat is NOT the current actor should be refused, regardless of the intent's content.
const bPush = session.connect(1 as PlayerIndex);
const notActor = (bPush.menu === null ? 1 : 0) as PlayerIndex;
const result = session.intent(notActor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(result.accepted, false);
if (!result.accepted) assert.equal(result.code, 'NOT_YOUR_TURN');
});
it('accepts a legal intent from the current actor and pushes every seat', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
const result = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(result.accepted, true);
if (result.accepted) assert.equal(result.pushes.size, 2, 'not every connected seat was pushed to');
});
it('rejects an illegal intent with its real RejectionCode, and does not remember the seq', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
// Discarding before ever drawing is illegal on an opening hand at the limit — a safe "this is
// definitely rejected" fixture that does not depend on exactly which cards were dealt.
const bad = session.intent(actor, 1, { type: 'card.discard', cardId: 'not-a-real-card', toSlot: 0 });
assert.equal(bad.accepted, false);
if (!bad.accepted) assert.ok(bad.code.length > 0, 'a rejection carried no code at all');
// The same seq, now with a legal intent, must still go through — a rejection is not "applied".
const retry = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(retry.accepted, true, 'a seq burned by an earlier rejection could not be reused');
});
it('treats a repeated seq as an already-applied no-op, not a second application', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
const first = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(first.accepted, true);
const second = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(second.accepted, true, 'a resend of an already-applied seq was rejected instead of ignored');
if (second.accepted) assert.equal(second.pushes.size, 0, 'a resend produced pushes as if newly applied');
});
it('deltas the board on a second push when it has not changed since the first', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
session.connect(1 as PlayerIndex); // both seats need a baseline Frame before a delta means anything
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
const result = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(result.accepted, true);
if (!result.accepted) return;
// Choosing "draw" doesn't move a single card on the board — the division/cells should be nulled
// on this push for a seat that already had them from `connect`.
const push = result.pushes.get(actor)!;
assert.equal(push.frame!.division, null, 'the board was resent even though nothing on it changed');
});
it('only sends narration NEW since the last push to that specific seat', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
// A first connect is this seat's first contact, so it gets everything narrated so far (the
// "Game Begins" intro and whatever `drain()` said entering the first Stage) — not an empty log.
assert.ok(aPush.lines.length > 0, 'a first connect got no narration at all, not even the game-begins intro');
const first = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(first.accepted, true);
if (!first.accepted) return;
const firstLines = first.pushes.get(actor)!.lines;
assert.ok(firstLines.length > 0, 'the acting seat got no narration for its own action');
const second = session.intent(actor, 2, { type: 'draw.end' });
assert.equal(second.accepted, true);
if (!second.accepted) return;
const secondLines = second.pushes.get(actor)!.lines;
assert.ok(secondLines.length > 0, 'a second real action produced no narration at all');
for (const line of firstLines) {
assert.ok(!secondLines.includes(line), 'the second push repeated narration already sent in the first');
}
});
});
describe('turn timings (lobby-and-sessions.md §5)', () => {
it('records a wall-clock span once the acting player, phase, Day or Stage changes', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
let pushes = new Map<PlayerIndex, Push>([
[0 as PlayerIndex, session.connect(0 as PlayerIndex)],
[1 as PlayerIndex, session.connect(1 as PlayerIndex)],
]);
const seq = new Map<PlayerIndex, number>([[0 as PlayerIndex, 1], [1 as PlayerIndex, 1]]);
const timings = [];
for (let step = 0; step < 60 && timings.length === 0; step++) {
const acting = [0, 1].find((seat) => pushes.get(seat as PlayerIndex)!.menu !== null) as PlayerIndex | undefined;
const menu = acting === undefined ? null : pushes.get(acting)!.menu;
if (acting === undefined || !menu || menu.options.length === 0) break;
const n = seq.get(acting)!;
seq.set(acting, n + 1);
const result = session.intent(acting, n, menu.options[0]!);
assert.equal(result.accepted, true, `step ${step}: ${JSON.stringify(menu.options[0])} rejected`);
if (!result.accepted) break;
if (result.timing) timings.push(result.timing);
pushes = result.pushes;
}
assert.ok(timings.length > 0, 'no turn timing ever closed across 60 real steps of actual play');
const [timing] = timings;
assert.ok(timing!.endedAt >= timing!.startedAt, 'a span ended before it started');
assert.ok([0, 1].includes(timing!.player), 'a timing named a player outside the table');
});
});
describe('bot seats (Phase 4 — D8, lobby-and-sessions.md §2)', () => {
it('a bot never becomes the observable current actor — it plays before anyone can see it waiting', () => {
// Seat 0 acts first (the opening Superintendent), so marking it a bot exercises `driveBots()`
// at CONSTRUCTION time — before any external `intent()` has run at all.
const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]);
assert.equal(session.isBot(0 as PlayerIndex), true);
assert.equal(session.isBot(1 as PlayerIndex), false);
const botPush = session.connect(0 as PlayerIndex);
const humanPush = session.connect(1 as PlayerIndex);
assert.equal(botPush.menu, null, 'the bot seat was handed a real decision to make');
assert.notEqual(humanPush.menu, null, 'nobody was left with a turn to take — the bot never played');
});
it('botSeats round-trips through exportSave/resumeSession', () => {
const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]);
const resumed = resumeSession(session.exportSave());
assert.equal(resumed.isBot(0 as PlayerIndex), true);
assert.equal(resumed.connect(0 as PlayerIndex).menu, null, 'a resumed bot seat still never gets a real decision');
});
it('a bot seat is driven forward after a human intent too, not only at construction', () => {
// Two bots and one human: whichever of the two non-human seats comes up next after the human's
// own move must be played automatically, with no external `intent()` for either of them.
const session = createSession(11, config, ['Alice', 'Bot', 'Bot'], [1 as PlayerIndex, 2 as PlayerIndex]);
const before = session.exportSave().history.length;
const applied = session.intent(0 as PlayerIndex, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(applied.accepted, true);
assert.equal(session.connect(1 as PlayerIndex).menu, null, 'bot seat 1 was left with a real decision');
assert.equal(session.connect(2 as PlayerIndex).menu, null, 'bot seat 2 was left with a real decision');
// Not a strict proof either bot actually moved (the human's own turn may not have ended yet),
// but the history can only ever have grown, never shrunk, and never rejected mid-drive.
assert.ok(session.exportSave().history.length >= before + 1);
});
});
describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => {
it('exportSave carries enough to reconstruct the exact same game', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex);
const actor = (push.menu !== null ? 0 : 1) as PlayerIndex;
const applied = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(applied.accepted, true);
const saved = session.exportSave();
assert.equal(saved.seed, 42);
assert.deepEqual(saved.playerNames, ['Alice', 'Bob']);
assert.equal(saved.history.length, 1);
assert.equal(saved.status, 'active');
assert.ok(saved.createdAt > 0, 'createdAt was not set');
const resumed = resumeSession(saved);
const before = session.connect(actor);
const after = resumed.connect(actor);
assert.deepEqual(after.frame, before.frame, 'resumeSession did not reconstruct the same board/state');
});
it('a resumed session keeps enforcing whose turn it is', () => {
const session = createSession(7, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex);
const actor = (push.menu !== null ? 0 : 1) as PlayerIndex;
session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
const resumed = resumeSession(session.exportSave());
const notActor = (actor === 0 ? 1 : 0) as PlayerIndex;
const rejected = resumed.intent(notActor, 1, { type: 'draw.end' });
assert.equal(rejected.accepted, false);
if (!rejected.accepted) assert.equal(rejected.code, 'NOT_YOUR_TURN');
});
it('marks status finished only once the game actually is', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
assert.equal(session.exportSave().status, 'active');
});
});
describe('summary() — what an administrator sees without replaying the game', () => {
it('describes a fresh game: who is at the table, where it has got to, and who it waits on', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const s = session.summary();
assert.equal(s.playerCount, 2);
assert.deepEqual(s.playerNames, ['Alice', 'Bob']);
assert.equal(s.status, 'active');
assert.equal(s.day, 1);
assert.equal(s.stage, 1);
assert.equal(typeof s.phase, 'string');
assert.ok(s.waitingOn, 'a game in play must be waiting on somebody');
assert.equal(s.waitingOn!.name, s.playerNames[s.waitingOn!.seat]);
});
it('does not hand back a copy of the history the way exportSave must', () => {
// The health check polls this on a timer, so it answering with every intent of every game
// would make a question about none of them cost a copy of all of them.
const session = createSession(42, config, ['Alice', 'Bob']);
assert.equal('history' in session.summary(), false);
});
it('moves lastMoveAt when a move is accepted, and leaves it alone when one is refused', async () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const created = session.summary();
assert.equal(created.lastMoveAt, created.createdAt, 'an untouched game has not moved since it began');
const actor = (session.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex;
const idle = (1 - actor) as PlayerIndex;
// A rejection is not a move — a player poking at a game they cannot act in must not make it
// look alive to whoever is deciding whether it has stalled.
session.intent(idle, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(session.summary().lastMoveAt, created.lastMoveAt, 'a refused intent moved the clock');
await new Promise((r) => setTimeout(r, 2));
const accepted = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(accepted.accepted, true);
assert.ok(session.summary().lastMoveAt > created.lastMoveAt, 'an accepted intent did not move the clock');
});
it('carries lastMoveAt across a restart, and falls back to createdAt for a save without one', async () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const actor = (session.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex;
await new Promise((r) => setTimeout(r, 2));
session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
const saved = session.exportSave();
assert.equal(resumeSession(saved).summary().lastMoveAt, saved.lastMoveAt);
// A game written before the field existed still has to load, and reads as untouched since it
// began rather than as having just moved.
const { lastMoveAt: _dropped, ...older } = saved;
const revived = resumeSession(older).summary();
assert.equal(revived.lastMoveAt, saved.createdAt);
});
it('reports a finished game as waiting on nobody', () => {
// Every seat a bot, so the game plays itself to a finish inside the constructor.
const session = createSession(4242, config, ['A', 'B'], [0 as PlayerIndex, 1 as PlayerIndex]);
const s = session.summary();
assert.equal(s.status, 'finished');
assert.equal(s.waitingOn, null, 'a finished game must not name somebody to wait for');
});
});
+25 -62
View File
@@ -22,7 +22,6 @@ import {
deckComposition,
isFreightHouse,
lengthProfile,
MAINLINE_DECK,
mainlineCardCount,
nextOfficeTier,
officeProfile,
@@ -35,8 +34,11 @@ import { subdivisions } from '../src/engine/state.ts';
const solitaireConfig: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -182,30 +184,23 @@ describe('card catalogue (component 1)', () => {
}
});
it('names the Freight House and nothing else as the two-way industry', () => {
it('identifies the both-direction industries the card reference names', () => {
/**
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". ONE card
* answers to that.
* `card-reference.md`: "'Freight House' is not a card. It is the collective term for a freight
* facility that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery." The table
* agrees: both are "Both", and only the Power Plant is inbound-only.
*
* This briefly asserted three. `card-reference.md` reads "'Freight House' is not a card. It is
* the collective term for a freight facility that loads *and* unloads — the Grocer's Warehouse
* and the Oil Refinery", and on that premise the Refinery and the Grocer's were both made
* `flow: 'both'`. The premise is dead: `glossary.md` and `rules-v0.2.md` corrected the Freight
* House to a card of its own, dealt like any other industry, so §9.3 names it and the table's
* "Both" column loses its only argument.
* The engine had the Refinery as outbound-only and the Grocer's as inbound-only, so §9.3's
* "Passenger Facilities and Freight Houses permit cars to move each direction" named neither of
* them — and every Modifier grant on the missing direction was silently dropped, which is how
* "grocer's warehouse didn't get extra outbound slot for truck dock" was reported.
*
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
* the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
* and so does the modifier set — all three Refinery modifiers grant outbound.
* `freightHouse` is still in this list because the engine deals it as a CARD, which the rules say
* it is not. That is a deck-composition question, recorded in TODO.md, not something to quietly
* delete six cards over.
*/
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
assert.deepEqual(houses.sort(), ['freightHouse']);
const refinery = FREIGHT_PROFILES.find((f) => f.kind === 'refinery')!;
assert.equal(refinery.flow, 'outbound');
assert.deepEqual([refinery.baseOut, refinery.baseIn], [1, 0]);
const grocers = FREIGHT_PROFILES.find((f) => f.kind === 'grocersWarehouse')!;
assert.equal(grocers.flow, 'inbound');
assert.deepEqual([grocers.baseOut, grocers.baseIn], [0, 1]);
assert.deepEqual(houses.sort(), ['freightHouse', 'grocersWarehouse', 'refinery']);
});
it('starts every industry at one car out and one loader', () => {
@@ -307,12 +302,16 @@ describe('card catalogue (component 1)', () => {
}
});
it('uses the Gap 10e revised victory targets', () => {
it('keeps the three day-count presets sim/CLI tooling still relies on', () => {
// Gap 10e's fixed targets retired 2026-08-20 — `minCombinedRevenue` is a free `GameConfig`
// variable now, not a `length`-keyed lookup. `LENGTH_PROFILES` survives only as a convenience
// preset for tools that still want a short/standard/campaign argument (`harness.ts`, `compare.ts`,
// `replay.ts`).
assert.deepEqual(
LENGTH_PROFILES.map((p) => [p.target, p.days]),
[[10, 3], [20, 5], [45, 10]],
LENGTH_PROFILES.map((p) => [p.length, p.days]),
[['short', 3], ['standard', 5], ['campaign', 10]],
);
assert.equal(lengthProfile('standard').target, 20);
assert.equal(lengthProfile('standard').days, 5);
});
it('scales fixed supplies with player count', () => {
@@ -408,42 +407,6 @@ describe('game setup (component 2)', () => {
}
});
it('deals the Mainline cards from the printed deck, without replacement', () => {
/**
* `buildDivision` drew uniformly from the nine card TYPES with replacement, so a Division could
* be dealt two Interchanges (or two Tunnels), and Plains — printed twice in the deck — carried
* the same weight as cards printed once. That became a rules question rather than a flavour one
* when an Extra gained the right to start "at the Interchange if one is on the board" (§7): the
* board has to hold at most one for that to mean anything.
*
* Swept over many seeds because a single deal cannot tell a deck from a die.
*/
const seen = new Map<string, number>();
for (let seed = 0; seed < 400; seed++) {
for (const players of [1, 2, 3, 4]) {
const g = createGame({
id: 'deck', seed,
config: players === 1 ? solitaireConfig : { ...solitaireConfig, mode: 'competitive' },
playerNames: Array.from({ length: players }, (_, i) => `P${i}`),
});
const cards = g.division.nodes.flatMap((n) => (n.kind === 'mainline' ? [n.card] : []));
assert.equal(cards.length, mainlineCardCount(players));
const counts = new Map<string, number>();
for (const c of cards) {
const n = (counts.get(c) ?? 0) + 1;
counts.set(c, n);
seen.set(c, (seen.get(c) ?? 0) + 1);
// Plains is the one card printed twice; nothing else may be dealt twice at all.
assert.ok(n <= (c === 'plains' ? 2 : 1), `${c} dealt ${n} times at seed ${seed}`);
}
}
}
// Every card in the deck reachable, so the deal is not quietly missing one.
for (const kind of new Set(MAINLINE_DECK)) {
assert.ok((seen.get(kind) ?? 0) > 0, `${kind} was never dealt in 400 seeds`);
}
});
it('opens with the whole railroad as one Subdivision', () => {
// §8 — every Office is a Whistle Post, which is not a Control Point.
const g = newSolitaireGame();
+36 -45
View File
@@ -22,8 +22,11 @@ import { anomalies, strategyBuckets } from '../src/sim/stats.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'short',
days: 3,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -155,7 +158,7 @@ describe('the revenue chain works end to end (regression)', () => {
// more cars in total (22 vs 19 over forty games). Widened so it fails for the reason it names.
let spotted = 0;
for (const seed of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: 'g', seed, config: { ...config, days: 5 }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const card of s.officeAreas.get(0)!.grid.values()) {
if (card.facility?.kind === 'freight') spotted += card.facility.industryTrack.cars.length;
@@ -172,7 +175,7 @@ describe('the revenue chain works end to end (regression)', () => {
// the old 52-card placeholder it was 10 in 52.
let placed = 0;
for (const seed of [11, 23, 47, 91, 137, 211]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
const s = createGame({ id: 'g', seed, config: { ...config, days: 10 }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const card of s.officeAreas.get(0)!.grid.values()) {
if (card.geometry.kind !== 'facility') continue;
@@ -206,7 +209,7 @@ describe('the revenue chain works end to end (regression)', () => {
// "downward only" is gone, and with it the rule that rejected every square above row 0.
const rows = new Set<number>();
for (const seed of [3, 9, 27, 81]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
const s = createGame({ id: 'g', seed, config: { ...config, days: 10 }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
}
@@ -272,7 +275,7 @@ describe('the deck is closed — no card is created or destroyed', () => {
// Every card is in exactly one place: the Home Office deck, a Department pile, the Salvage Yard,
// a hand, or on the board — grid cells, enhancements laid on them, and Mainline modifiers.
for (const seed of [3, 17, 91]) {
const s = createGame({ id: `cc-${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: `cc-${seed}`, seed, config: { ...config, days: 5 }, playerNames: ['bot'] });
const total = s.cards.size;
playGame(s, developerBot, pump);
@@ -294,7 +297,7 @@ describe('the deck is closed — no card is created or destroyed', () => {
// The sharper form of the same guard. A discard moves a card from a hand to a Department pile
// and a draw moves one back, so the count across deck + Departments + Salvage Yard + hands can
// only fall by cards actually placed on the board — never by one being overwritten.
const s = createGame({ id: 'cc', seed: 5, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: 'cc', seed: 5, config: { ...config, days: 5 }, playerNames: ['bot'] });
const before = s.cards.size;
playGame(s, developerBot, pump);
const loose =
@@ -386,22 +389,13 @@ describe('switching accomplishes something (regression)', () => {
// no switching at all (§9.2 works coaches straight off the A/D track), so an entire Local
// Operations action was wasted.
/**
* ACROSS SEEDS, because one game cannot tell a fixed bug from a lucky deal — and the sample has
* to be big enough that it cannot tell a lucky DEAL from a fixed bug either.
*
* It was 16 hand-picked seeds against a bar of 70% clean, on a measurement of 13/16. Dealing the
* Mainline cards from the printed deck instead of rolling them (`buildDivision`) re-dealt every
* one of those boards and the same 16 came back 11/16, which read as a regression and was not
* one: re-measured over 80 seeds the rate is **70.0% clean, worst run 5** — the identical
* behaviour, and 13/16 was the lucky draw. A bar sitting exactly on the true rate fails half the
* time it is moved.
*
* So: a wider sweep, and a bar well below the measured rate. What the test is really guarding is
* the every-game waste it was written for, which shows up as a rate near ZERO, not as a few
* points of drift. The ceiling on the worst run is the sharp half of the assertion and is
* unchanged. The residual is recorded in TODO.md with the rest of the bot work.
* ACROSS SEEDS, because one game cannot tell a fixed bug from a lucky deal. Measured over these
* 16: thirteen show no oscillation at all and three reach a run of five, so the shuttling is a
* minority behaviour rather than the every-game waste this test was written to catch. The bar is
* therefore a RATE — most games clean — plus a ceiling on how bad the worst may get. The residual
* is recorded in TODO.md with the rest of the bot work.
*/
const seeds = Array.from({ length: 48 }, (_, i) => i + 1);
const seeds = [1234, 5, 77, 430, 202, 999, 21, 555, 4321, 31337, 60606, 7777, 123456, 888, 31, 42];
let clean = 0;
let worstAnywhere = 0;
for (const seed of seeds) {
@@ -433,7 +427,7 @@ describe('switching accomplishes something (regression)', () => {
}
assert.ok(
clean >= seeds.length * 0.55,
clean >= seeds.length * 0.7,
`only ${clean}/${seeds.length} games were free of aimless shuttling`,
);
assert.ok(worstAnywhere <= 5, `a crew oscillated ${worstAnywhere + 1} times without doing any work`);
@@ -483,10 +477,8 @@ describe('switching accomplishes something (regression)', () => {
* grew faster, which is traffic rather than aimlessness, and there are two new sources of it:
* Extras now start at the Division Point their NUMBER sends them to, so westbound Extras exist
* at all (measured 32 west / 29 east across 60 deals, against every single one launching
* eastbound from the West Division Point before); and the Grocer's Warehouse briefly shipped as
* well as received, which was more switching worth doing. That second source is gone again in
* v0.4.9e — the Grocer's is inbound-only, as it always was on the sheet — and the ratio still
* clears the floor, so the figure is left where it is rather than re-tuned to one release.
* eastbound from the West Division Point before); and the Grocer's Warehouse ships as well as
* receives, so there is more switching worth doing.
*
* A crew that shuttles for its own sake would show this ratio climbing while `work` stood still.
* Logged in TODO.md with the rest of the bot drift rather than quietly absorbed.
@@ -552,7 +544,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
const s = createGame({
id: `sd-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
playGame(s, developerBot, pump);
@@ -650,7 +642,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
const s = createGame({
id: `cs-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
const r = playGame(s, developerBot, pump);
@@ -734,7 +726,7 @@ describe('the bot does not throw away its own freight (regression)', () => {
const s = createGame({
id: `uj-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
const r = playGame(s, developerBot, pump);
@@ -763,7 +755,7 @@ describe('the bot does not throw away its own freight (regression)', () => {
// The other half of the regression above. Gutting the fallback entirely would satisfy "never
// unjams a green box" while leaving a real jam to block the industry forever — a load stranded
// on WORK keeps the track locked, which now stops trains passing as well as stopping (§9.3).
const s = createGame({ id: 'jam', seed: 5, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: 'jam', seed: 5, config: { ...config, days: 5 }, playerNames: ['bot'] });
const area = s.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple' },
@@ -808,7 +800,7 @@ describe('Enhancements can reach the board at all (regression)', () => {
const s = createGame({
id: `st-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
playGame(s, developerBot, pump);
@@ -845,7 +837,7 @@ describe('Enhancements can reach the board at all (regression)', () => {
const s = createGame({
id: `il-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
playGame(s, developerBot, pump);
@@ -883,7 +875,7 @@ describe('rolling stock returns to service (regression)', () => {
const s = createGame({
id: 'refill',
seed: 4,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
@@ -922,7 +914,7 @@ describe('measurement discipline', () => {
// the observer hook cannot drift from the log, so probes have one trustworthy way in and no
// reason to hand-roll the drive loop again.
const seen: string[] = [];
const s = createGame({ id: 'obs', seed: 4242, config: { ...config, length: 'standard' }, playerNames: ['b'] });
const s = createGame({ id: 'obs', seed: 4242, config: { ...config, days: 5 }, playerNames: ['b'] });
const out = playGame(s, developerBot, pump, 50_000, (e) => seen.push(e.type));
assert.ok(out.events.length > 0, 'a finished game must produce events');
@@ -934,7 +926,7 @@ describe('measurement discipline', () => {
// Asserted across several seeds because a single game may never draw a Depot card.
let upgrades = 0;
for (let seed = 0; seed < 25; seed++) {
const s = createGame({ id: `u${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['b'] });
const s = createGame({ id: `u${seed}`, seed, config: { ...config, days: 5 }, playerNames: ['b'] });
const out = playGame(s, developerBot, pump);
upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length;
}
@@ -954,7 +946,7 @@ describe('the bot does not lay track that cannot work (regression)', () => {
const s = createGame({
id: `lay-${seed}`,
seed,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['Solitaire'],
});
const laid: Lay[] = [];
@@ -1007,7 +999,7 @@ describe('the bot does not lay track that cannot work (regression)', () => {
for (const seed of SEEDS) {
const s = createGame({
id: `dp-${seed}`, seed,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['Solitaire'],
});
const spy = {
@@ -1081,13 +1073,12 @@ describe('the freight figures count both halves (regression)', () => {
// that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it,
// and whether the bot manages all three on a given deal is luck, not the thing under test.
/**
* FORTY DEALS, up from twelve, and the reason was a rules correction rather than flakiness.
* FORTY DEALS, up from twelve, and the reason is a rules correction rather than flakiness.
*
* The Grocer's Warehouse was briefly a both-direction facility, so the bot shipped from it as
* well as receiving and deals producing at least one completed unload fell from 12 in 40 to 6 in
* 40. v0.4.9e put it back to inbound-only, which is what the sheet always printed. The wider
* sample is kept: the precondition it protects — that some deal in the batch actually completes
* an unload — is worth having whichever way the rule goes.
* The Grocer's Warehouse is a BOTH-direction facility now — `card-reference.md` always said so —
* where the engine had it inbound-only. So the bot can ship from it as well as receive, and it
* often does: deals producing at least one completed unload went from 12 in 40 to 6 in 40, while
* unloads themselves are unharmed (30 completed across the 40 measured after the change).
*
* The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than
* unloads started — and `unloads > 0` is only the precondition that makes the comparison mean
@@ -1097,7 +1088,7 @@ describe('the freight figures count both halves (regression)', () => {
const seed = 1000 + i * 7919;
const s = createGame({
id: `fu-${seed}`, seed,
config: { ...config, length: 'standard' },
config: { ...config, days: 5 },
playerNames: ['bot'],
});
const r = playGame(s, developerBot, pump);
+11 -1
View File
@@ -128,7 +128,7 @@ function gameWith(area: OfficeArea): GameState {
const s = createGame({
id: 'g', seed: 5,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['p'],
@@ -563,6 +563,16 @@ describe('placement and drop-off', () => {
assert.ok(canDropCarsAt(area, at(0, 1)), 'ordinary Operational Rail');
});
it('allows a coach, and only a coach, to be dropped at the Office (v0.5.0 §A.4 exception)', () => {
const area = areaFrom(
{ [coordKey(at(0, 0))]: officeCard(), [coordKey(at(0, 1))]: straight() },
at(0, 0),
);
assert.ok(!canDropCarsAt(area, at(0, 0), 1, false), 'not asking for the coach exception');
assert.ok(canDropCarsAt(area, at(0, 0), 1, true), 'one coach');
assert.ok(canDropCarsAt(area, at(0, 0), 2, true), 'more than one coach');
});
it('forbids dropping cars on a locked industry track', () => {
const area = areaFrom(
{
+43 -5
View File
@@ -19,8 +19,11 @@ import { trainRules } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
@@ -46,9 +49,9 @@ function switching(
): string {
const area = areaOf(s, 0);
const office = area.officeCoord;
// The crew stands one square west of the Office and works westward. It may NOT stand on the Office
// itself: §A.4 forbids leaving Rolling Stock on the Office track, so every drop there is refused
// whatever the train's own card says.
// The crew stands one square west of the Office and works westward, away from the Office square's
// own special-cased drop rule (§A.4 forbids everything but a coach there, v0.5.0) — tests that want
// that exception exercise it directly instead.
const here = { row: office.row, col: office.col - 1 };
area.grid.set(coordKey(here), straight());
area.grid.set(coordKey({ row: office.row, col: office.col - 2 }), straight(westCars));
@@ -202,6 +205,41 @@ describe('§7 — the Local keeps its coach (trains 7/8)', () => {
switching(s, 9, false, [boxcar(), coach()]);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('may set the coach out at the Office — the one place §A.4 now allows it (v0.5.0)', () => {
const s = game();
const area = areaOf(s, 0);
const office = area.officeCoord;
s.trays.set('t', {
id: 't',
trainNumber: 7,
trainIsExtra: false,
engineAt: 0,
consist: [boxcar(), coach()],
direction: 'west',
facing: 'w',
position: { at: 'grid', seat: 0, coord: office },
movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
assert.equal(
check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }),
null,
'the coach may be cut loose at the Office',
);
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
const officeCard = area.grid.get(coordKey(office))!;
assert.deepEqual(
officeCard.standing.map((c) => c.type),
['coach'],
'the coach is parked on the Office card',
);
assert.equal(s.trays.get('t')!.consist.length, 1, 'the coach left the tray');
});
});
describe('§7 — trains Porters may not work', () => {
+470 -153
View File
@@ -42,7 +42,21 @@ import {
} from '../src/web/game.ts';
const root = join(import.meta.dirname, '..');
/**
* `dist/` is built once by the `pretest` npm script (`scripts/build-web.ts`), before `node --test`
* ever starts — every test below that reads `dist` assumes it is already complete, not that some
* other test in this file built it. `npm run test` directly (skipping `npm test`'s `pretest` hook)
* will not have run it.
*/
const dist = join(root, 'dist');
/**
* A directory the actual "run the build command" test below builds into, kept separate from the
* shared `dist/` above. Two suites racing to write and read the SAME `dist/` — the build command
* doing a full `rmSync` + rebuild while other tests in this file read it mid-run — used to fail 9
* times in one measured run and 0 the next. `dist/` is now single-writer (`pretest`, once, before
* anything reads it); this directory is the ONLY thing the build-command test itself may touch.
*/
const distTest = join(root, 'dist-test');
/** Play a whole game by always taking the first offered action. */
function playThrough(seed: number, maxTurns = 20_000) {
@@ -764,15 +778,30 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
.map((m) => ({ x1: Number(m[1]), y1: Number(m[2]), x2: Number(m[3]), y2: Number(m[4]) }));
};
/** Undirected segment angles in degrees, deduplicated. 0 is horizontal. */
const angles = (links: string[]): number[] => [
...new Set(
draw(links).map((r) => {
const a = (Math.atan2(r.y2 - r.y1, r.x2 - r.x1) * 180) / Math.PI;
return Math.round((((a % 180) + 180) % 180) * 10) / 10;
/**
* A 45° leg is a curved `<path>` now (v0.5.0), not straight `<line>`s — see `curvedRail` in
* `board-svg.ts`. Each returned array is the sampled polyline for one of the leg's two rails, in
* order from the through edge to the diagonal edge.
*/
const drawCurve = (links: string[]): { x: number; y: number }[][] => {
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
};
const svg = officeSvg([cell] as never, 0);
return [...svg.matchAll(/<path class="bs-rail" fill="none" d="M([^"]+)"\/>/g)].map((m) =>
m[1]!.split(' L').map((pair) => {
const [x, y] = pair.trim().split(' ').map(Number);
return { x: x!, y: y! };
}),
),
].sort((a, b) => a - b);
);
};
/** Undirected angle in degrees between two points, 0–180. */
const angleOf = (a: { x: number; y: number }, b: { x: number; y: number }): number => {
const deg = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
return ((deg % 180) + 180) % 180;
};
it('runs the through track dead centre, edge to edge', () => {
// The measurement from the printed sheet: on every one of its nine rows the east-west rail sits
@@ -788,36 +817,46 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
}
});
it('leaves at exactly 45°, through the middle of the north or south edge', () => {
it('leaves the through edge level and the diagonal edge at 45°, curved rather than kinked', () => {
// The TRUE tangent is exactly 0° at the through edge and exactly 45°/135° at the diagonal edge —
// that is what the curve's control points are chosen to hit (see `curvedRail`'s call site). What
// is rendered is a sampled polyline, so the first and last drawn segment only APPROXIMATE that
// limit; a few degrees off at the sample count in use, well inside the tolerance below.
for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) {
const found = angles(links);
assert.ok(found.includes(0), `${links} has no level run along the centre line`);
assert.ok(
found.includes(45) || found.includes(135),
`${links} draws its leg at ${found.join('/')}° — the card prints 45°`,
);
assert.equal(found.length, 2, `${links} draws ${found.join('/')}° — a leg is a run and one 45° angle`);
const [rail] = drawCurve(links);
assert.ok(rail && rail.length > 2, `${links} drew no curved leg`);
const start = angleOf(rail[0]!, rail[1]!);
const end = angleOf(rail[rail.length - 2]!, rail[rail.length - 1]!);
assert.ok(start <= 6 || start >= 174, `${links} leaves the through edge at ${start}°, not level`);
const offDiagonal = Math.min(Math.abs(end - 45), Math.abs(end - 135));
assert.ok(offDiagonal <= 6, `${links} meets the diagonal edge at ${end}°, not 45°/135°`);
}
});
it('draws the two diagonals as two different diagonals', () => {
// The matching rule made visible. `ne` and `sw` lie on one line and `nw` and `se` on the other,
// so a stacked pair on the same diagonal reads as one unbroken rail and a mismatched pair reads
// as the V it is. Drawn alike, the picture would claim a join the engine refuses.
assert.deepEqual(angles(['ne']), angles(['sw']), 'ne and sw must lie on the same diagonal');
assert.deepEqual(angles(['nw']), angles(['se']), 'nw and se must lie on the same diagonal');
assert.notDeepEqual(angles(['ne']), angles(['nw']), 'the two diagonals must be distinguishable');
// so a stacked pair on the same diagonal reads as one continuous curve and a mismatched pair
// reads as the V it is. Drawn alike, the picture would claim a join the engine refuses.
const family = (links: string[]): 'rising' | 'falling' => {
const [rail] = drawCurve(links);
const end = angleOf(rail![rail!.length - 2]!, rail![rail!.length - 1]!);
return Math.abs(end - 45) < Math.abs(end - 135) ? 'rising' : 'falling';
};
assert.equal(family(['ne']), family(['sw']), 'ne and sw must lie on the same diagonal');
assert.equal(family(['nw']), family(['se']), 'nw and se must lie on the same diagonal');
assert.notEqual(family(['ne']), family(['nw']), 'the two diagonals must be distinguishable');
});
it('meets the card edge at its midpoint, so abutting cards line up', () => {
// A leg that met the edge anywhere else would join to nothing, however right its angle.
// A leg that met the edge anywhere else would join to nothing, however right its angle. Exact,
// not approximate: the curve's LAST sampled point is always the true edge point (`edge` in
// `curvedRail`'s call site, not a discretised approximation of it), so averaging the two rails'
// endpoints cancels their perpendicular offset exactly, the same way it did when the leg was two
// straight segments.
const W = 166;
const H = 96;
// Only the sloping segments, and only the end of each that lands on the south edge.
const atEdge = draw(['sw'])
.filter((r) => r.y1 !== r.y2)
.map((r) => (Math.abs(r.y1 - H) < Math.abs(r.y2 - H) ? r.x1 : r.x2));
assert.equal(atEdge.length, 2, 'the leg is two rails');
const rails = drawCurve(['sw']);
assert.equal(rails.length, 2, 'the leg is two rails');
const atEdge = rails.map((r) => r[r.length - 1]!.x);
const mid = (atEdge[0]! + atEdge[1]!) / 2;
assert.ok(Math.abs(mid - W / 2) < 0.5, `the leg crosses the south edge at x=${mid}, not the middle (${W / 2})`);
});
@@ -1171,10 +1210,12 @@ describe('the page explains itself', () => {
});
it('states the objective and whether you are keeping up', () => {
// SOLO_CONFIG's minCombinedRevenue is collectiveRevenueFloor(1, 5) = 15 (game.ts) — the old
// fixed target of 20 retired 2026-08-20 with the victory-condition redesign.
const f = view(newGame(430));
assert.equal(f.objective.target, 20);
assert.equal(f.objective.target, 15);
assert.equal(f.objective.days, 5);
assert.match(f.objective.note, /of 20/);
assert.match(f.objective.note, /of 15/);
assert.match(f.objective.note, /Days? left/);
});
@@ -1325,11 +1366,17 @@ describe('replays are saves', () => {
describe('the static build', () => {
it('builds, and needs nothing but a static host', () => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
// Its OWN directory (`dist-test/`), not the shared `dist/` every other test in this file reads
// — see the comment on `distTest` above.
execFileSync('node', ['scripts/build-web.ts'], {
cwd: root,
stdio: 'pipe',
env: { ...process.env, BUILD_DIST_DIR: 'dist-test' },
});
assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
assert.ok(existsSync(join(distTest, 'index.html')), 'no index.html');
assert.ok(existsSync(join(distTest, 'web/main.js')), 'no entry script');
assert.ok(existsSync(join(distTest, 'engine/apply.js')), 'the engine did not emit');
});
it('emits no import that a browser cannot resolve', () => {
@@ -1579,6 +1626,173 @@ describe('the static build', () => {
);
});
it('remembers auto-focus, sound and zoom across a reload, separately from the save', async () => {
// A lighter DOM stub than the highlight test above — this never touches board highlighting or
// action buttons, only the settings-bearing elements and `localStorage` itself.
const els = new Map<string, Record<string, unknown>>();
const injected: Record<string, unknown>[] = [];
const make = (): Record<string, unknown> => {
let html = '';
const listeners = new Map<string, ((e?: unknown) => void)[]>();
const ownClasses = new Set<string>();
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
title: '', returnValue: '', open: false,
addEventListener: (type: string, fn: (e?: unknown) => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal() {
(node as { open: boolean }).open = true;
},
close(value?: string) {
(node as { open: boolean; returnValue: string }).open = false;
if (value !== undefined) (node as { returnValue: string }).returnValue = value;
for (const fn of listeners.get('close') ?? []) fn();
},
classList: {
add: (c: string) => void ownClasses.add(c),
remove: (c: string) => void ownClasses.delete(c),
contains: (c: string) => ownClasses.has(c),
toggle: (c: string) => (ownClasses.has(c) ? ownClasses.delete(c) : ownClasses.add(c)),
},
// No real `<svg>` child: `applyZoom` looks for one, finds none on this stub, and no-ops —
// the same way it does on a board card the stub never renders. Sizing the actual SVG needs a
// real DOM (jsdom or a browser), which this test suite does not carry; what IS checked here —
// the persisted setting, the button labels, the click wiring — is everything observable
// without one. `querySelectorAll` empty rather than absent: several unrelated render steps
// (crew markers, hand buttons, department piles) iterate whatever their element returns.
querySelector: () => null,
querySelectorAll: () => [],
};
Object.defineProperty(node, 'innerHTML', {
get: () => html,
set: (v: string) => void (html = v),
});
return node;
};
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(),
addEventListener: () => {},
body: { appendChild: () => {} },
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
};
g['location'] = { search: '?seed=555' };
const store = new Map<string, string>();
// Seeded BEFORE import: settings load once, at module top level, from whatever is already there.
store.set(
'station-master.settings.v1',
JSON.stringify({ districtMode: 'open', soundOn: true, zoom: 1.25 }),
);
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = class {
search: string;
constructor(search: string) {
this.search = search;
}
get(k: string): string | null {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
}
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
// The seeded settings took effect on load, before any click.
assert.match(String(els.get('sound')!['textContent']), /sound/, 'sound started ON, as seeded');
assert.equal(els.get('zoomlabel')!['textContent'], '125%', 'zoom started at the seeded level');
// Zooming in from 125% goes to 150% and persists; the button disables at the top of the range.
(els.get('zoomin')!['onclick'] as () => void)();
assert.equal(els.get('zoomlabel')!['textContent'], '150%');
assert.equal(els.get('zoomin')!['disabled'], true, 'already at the highest preset');
assert.equal(
(JSON.parse(store.get('station-master.settings.v1')!) as { zoom: number }).zoom,
1.5,
'the new zoom level was written back to localStorage',
);
// Muting persists too, independently of zoom.
(els.get('sound')!['onclick'] as () => void)();
assert.match(String(els.get('sound')!['textContent']), /muted/);
assert.equal(
(JSON.parse(store.get('station-master.settings.v1')!) as { soundOn: boolean }).soundOn,
false,
);
});
it('falls back to the defaults when settings are missing or corrupt, rather than throwing', async () => {
const els = new Map<string, Record<string, unknown>>();
const make = (): Record<string, unknown> => {
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
title: '', returnValue: '', open: false,
addEventListener: () => {},
showModal() {},
close() {},
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
querySelector: () => null,
querySelectorAll: () => [],
};
let html = '';
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
};
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(),
addEventListener: () => {},
body: { appendChild: () => {} },
head: { appendChild: () => {} },
};
g['location'] = { search: '?seed=556' };
g['localStorage'] = {
// Neither a JSON parse error nor a thrown access should reach the page — a full or disabled
// localStorage must not take the game down with it, the same guard the save already has.
getItem: () => {
throw new Error('storage disabled');
},
setItem: () => {},
removeItem: () => {},
};
g['URLSearchParams'] = class {
search: string;
constructor(search: string) {
this.search = search;
}
get(k: string): string | null {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
}
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
assert.match(String(els.get('sound')!['textContent']), /muted/, 'sound defaults OFF');
assert.equal(els.get('zoomlabel')!['textContent'], '100%', 'zoom defaults to 100%');
});
it('busts the cache on every module, so a deploy cannot half-load', () => {
// The first real deploy served a fresh index.html against a CACHED main.js: the HTML had
// dropped an element the old script still asked for, so the page threw `missing element:
@@ -2301,6 +2515,149 @@ describe('the static build', () => {
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
});
it('opens the multiplayer door from the splash, straight into the lobby', () => {
// This door sat `disabled` and labelled "Coming soon" from before the server existed until
// v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a
// real server in front of them saw the same dead card as everyone else.
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(splash, /href="\.\/play\.html\?lobby"/, 'the splash does not link to the lobby');
assert.doesNotMatch(splash, /Coming soon/, 'the multiplayer door still says it is unbuilt');
// The probe addresses all three by id; renaming one silently un-wires it, which is precisely
// the class of break that put "Coming soon" on a working feature for two releases.
for (const id of ['door-multiplayer', 'door-multiplayer-go', 'door-multiplayer-blurb']) {
assert.ok(splash.includes(`id="${id}"`), `the splash is missing #${id}`);
}
});
/**
* The probe decides whether the door above is real, so both of its answers are worth a test —
* and the OPEN one especially: a false "no server here" is invisible to the player and is the
* exact failure this release exists to remove.
*/
it('closes the multiplayer door only when nothing answers the health probe', async () => {
const served = new Set(
[...readFileSync(join(dist, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const loadSplash = async (fetchImpl: () => Promise<unknown>): Promise<Record<string, unknown>> => {
const els = new Map<string, Record<string, unknown>>();
const make = (): Record<string, unknown> => {
const classes = new Set<string>();
return {
textContent: '', innerHTML: '', hidden: false, href: './play.html?lobby',
classList: { add: (c: string) => void classes.add(c), remove: (c: string) => void classes.delete(c), has: (c: string) => classes.has(c) },
removeAttribute: (k: string) => { if (k === 'href') delete (els.get('door-multiplayer') ?? {})['href']; },
addEventListener: () => {}, appendChild: () => {},
};
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(), addEventListener: () => {},
body: { appendChild: () => {} }, head: { appendChild: () => {} },
};
g['fetch'] = fetchImpl;
await import(`file://${join(dist, 'web/splash.js')}?t=${Date.now()}${Math.random()}`);
// The probe settles a microtask or two after load; nothing in the page waits on it.
await new Promise((r) => setTimeout(r, 0));
return els.get('door-multiplayer')!;
};
const answered = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: true, service: 'station-master' }) }),
);
assert.equal(
(answered['classList'] as { has: (c: string) => boolean }).has('disabled'), false,
'the door closed even though a Station Master server answered',
);
const silent = await loadSplash(() => Promise.reject(new Error('nothing there')));
assert.equal(
(silent['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'the door stayed open with no server behind it',
);
assert.equal(silent['href'], undefined, 'the closed door is still clickable');
// A host that answers EVERY path 200 — with its own index page, or with some unrelated
// service's JSON — sails straight past an `ok` check, so the body has to name itself. Both
// shapes below reach the door by a DIFFERENT route through the probe than the rejection
// above does, which is the whole reason they are here: an earlier version of this test
// asserted only the rejection path and passed with the naming check deleted outright.
const unnamed = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ service: 'something-else', ok: true }) }),
);
assert.equal(
(unnamed['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'a 200 from some other service was taken for a Station Master server',
);
const unparseable = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.reject(new Error('not json')) }),
);
assert.equal(
(unparseable['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'a 200 that is not JSON at all was taken for a server',
);
});
/**
* `?lobby` is what the door above hands to `main.ts`. Asserted on the BUILT bundle rather than
* the source, because the one thing that broke here was a browser-vs-Node API difference
* (`params.has` against the stub below), which only a load of the real output catches.
*/
it('routes ?lobby straight to the lobby screen instead of dealing a solitaire game', async () => {
const shown: string[] = [];
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const els = new Map<string, Record<string, unknown>>();
const make = (id: string): Record<string, unknown> => ({
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false, innerHTML: '',
title: '', hidden: false, classList: { add: () => {}, remove: () => {}, has: () => false },
appendChild: () => {}, addEventListener: () => {}, removeAttribute: () => {},
querySelector: () => null, querySelectorAll: () => [],
setAttribute: (k: string, v: string) => { if (k === 'hidden') shown.push(`${id}=${v}`); },
});
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make(id));
return els.get(id);
},
createElement: () => make('?'), addEventListener: () => {},
body: { appendChild: () => {} }, head: { appendChild: () => {} },
};
g['location'] = { search: '?lobby' };
const store = new Map<string, string>();
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = class {
search: string;
constructor(search: string) { this.search = search; }
get(k: string): string | null {
// A bare flag with no `=value` is still present — `?lobby` is exactly that shape.
if (new RegExp(`[?&]${k}(?=$|[&=])`).test(this.search)) {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? '';
}
return null;
}
};
// The lobby screen opens an EventSource as soon as it is shown; nothing here drives a game.
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
assert.equal(els.get('lobby')!['hidden'], false, 'the lobby screen stayed hidden under ?lobby');
assert.equal(els.get('gameui')!['hidden'], true, 'it dealt a solitaire game instead of opening the lobby');
});
it('serves a page that loads the game as a module', () => {
const html = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
@@ -2318,8 +2675,11 @@ describe('the Division map shows the whole route', () => {
seed: 7,
config: {
mode: players === 1 ? 'solitaire' : 'competitive',
victory: 'highestAfterDays',
length: 'standard',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
@@ -2334,7 +2694,7 @@ describe('the Division map shows the whole route', () => {
id: 'div-run',
seed: 7,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
@@ -2387,7 +2747,7 @@ describe('the Division map shows the whole route', () => {
const s = createEngineGame({
id: 'div-chips', seed: 7,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
@@ -2716,7 +3076,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
const s = createEngineGame({
id: 'eng', seed: 1038389,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
@@ -2766,7 +3126,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
const s = createEngineGame({
id: 'yards', seed: 1038389,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
@@ -2818,17 +3178,21 @@ describe('the New Game dialog', () => {
value,
checked: false,
}));
const extraRadios = ['divisionPointsOnly', 'ownOffice', 'anyOffice'].map((value) => ({
// The mode radios are real DOM inputs, so `onchange` has to actually fire when a test flips
// `checked` — `applyModePreset` is wired to it, not polled.
const modeRadios = ['solitaire', 'competitive', 'coop'].map((value) => ({
value,
checked: value === 'anyOffice',
checked: value === 'solitaire',
onchange: null as (() => void) | null,
}));
let html = '';
const node: Record<string, unknown> = {
id, value: '', textContent: '', title: '', returnValue: '', open: false,
style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
checked: false, disabled: false,
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
radios,
extraRadios,
modeRadios,
addEventListener: (type: string, fn: () => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal: () => void ((node as { open: boolean }).open = true),
@@ -2839,16 +3203,12 @@ describe('the New Game dialog', () => {
// Only the radio-group selectors the dialog actually uses; anything else is not this
// element's business and answering it with a guess would hide a typo in the real selector.
querySelectorAll: (sel: string) =>
sel === 'input[name="ng-hand"]'
? radios
: sel === 'input[name="ng-extra"]'
? extraRadios
: [],
sel === 'input[name="ng-hand"]' ? radios : sel === 'input[name="ng-mode"]' ? modeRadios : [],
querySelector: (sel: string) =>
sel === 'input[name="ng-hand"]:checked'
? (radios.find((r) => r.checked) ?? null)
: sel === 'input[name="ng-extra"]:checked'
? (extraRadios.find((r) => r.checked) ?? null)
: sel === 'input[name="ng-mode"]:checked'
? (modeRadios.find((r) => r.checked) ?? null)
: null,
};
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
@@ -2902,6 +3262,53 @@ describe('the New Game dialog', () => {
assert.deepEqual(checked.map((r) => r.value), ['sixRandom'], 'the opening hand in play was not preselected');
});
it('always reopens on Solitaire, Deal enabled, PvP forced off — the only mode a prior game could be', async () => {
const { els } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
const modeRadios = dlg['modeRadios'] as { value: string; checked: boolean }[];
assert.deepEqual(
modeRadios.filter((r) => r.checked).map((r) => r.value),
['solitaire'],
'the dialog did not reopen on Solitaire',
);
assert.equal(els.get('ng-deal')!['disabled'], false, 'Deal was disabled for the one dealable mode');
assert.equal(els.get('ng-pvp')!['checked'], false, 'PvP defaulted on for Solitaire');
assert.equal(els.get('ng-pvp')!['disabled'], true, "Solitaire's PvP checkbox was left editable");
});
it('picking Competitive suggests a 4-player floor, turns PvP on, and disables Deal', async () => {
const { els } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
const modeRadios = dlg['modeRadios'] as { value: string; checked: boolean; onchange: (() => void) | null }[];
const competitive = modeRadios.find((r) => r.value === 'competitive')!;
for (const r of modeRadios) r.checked = r === competitive;
competitive.onchange!();
assert.equal(els.get('ng-minrev')!['value'], '60', 'the suggested floor is not 3 * 4 players * 5 days');
assert.equal(els.get('ng-pvp')!['checked'], true, 'Competitive did not default PvP on');
assert.equal(els.get('ng-pvp')!['disabled'], false, "Competitive's PvP checkbox was left disabled");
assert.equal(els.get('ng-deal')!['disabled'], true, 'Deal was enabled for a mode with nowhere to go yet');
});
it('picking Co-op forces PvP off — no valid target for those cards when everyone is on one side', async () => {
const { els } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
const modeRadios = dlg['modeRadios'] as { value: string; checked: boolean; onchange: (() => void) | null }[];
const coop = modeRadios.find((r) => r.value === 'coop')!;
for (const r of modeRadios) r.checked = r === coop;
coop.onchange!();
assert.equal(els.get('ng-pvp')!['checked'], false, 'Co-op defaulted PvP on');
assert.equal(els.get('ng-pvp')!['disabled'], true, "Co-op's PvP checkbox was left editable");
assert.equal(els.get('ng-deal')!['disabled'], true, 'Deal was enabled for a mode with nowhere to go yet');
});
it('puts the seed and all three settings into the URL when it deals', async () => {
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
@@ -2915,7 +3322,10 @@ describe('the New Game dialog', () => {
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&extra=anyOffice&passenger=5&freight=0&transit=2');
assert.equal(
nav.search,
'?seed=99&hand=threeTrackThreeOther&passenger=5&freight=0&transit=2&days=5&minrev=15&colday=3&coltotal=5',
);
});
it('deals nothing on cancel, and nothing on Esc', async () => {
@@ -2934,8 +3344,10 @@ describe('the New Game dialog', () => {
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
// Dealing a random seed, disliking it and dealing again at the same settings produces the same
// search string — and assigning `location.search` the value it already holds does nothing.
const url = '?hand=threeRandom&extra=anyOffice&passenger=1&freight=1&transit=0';
// search string — and assigning `location.search` the value it already holds does nothing. Spells
// out the victory dials explicitly (rather than leaving them to default) so the URL Deal produces
// is byte-identical to the one the page loaded with.
const url = '?hand=threeRandom&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
const { els, nav } = await load(url);
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
@@ -2964,7 +3376,11 @@ describe('the New Game dialog', () => {
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, '?hand=threeRandom&extra=anyOffice&passenger=1&freight=1&transit=0', 'a bad seed was carried into the URL');
assert.equal(
nav.search,
'?hand=threeRandom&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5',
'a bad seed was carried into the URL',
);
});
});
@@ -3207,105 +3623,6 @@ describe('two crews switching are told apart', () => {
});
});
describe('two trains at one platform are told apart (v0.4.9e)', () => {
/**
* REPORTED from the v0.4.9d playtest: "operating two trains in a station — the select button does
* not work. Regardless of which you pick, it is always one train, not the other."
*
* `porter.board` and `porter.detrain` carried no tray, so there was ONE button per platform however
* many trains were standing at it, and the reducer filled whichever tray came first out of
* `adOccupancy`. Clicking a roster chip changed what the board drew and nothing else — which is
* exactly what "the select button does not work" describes.
*
* Driven through `actionMenu` rather than `legalActions` because the second half of the failure was
* at this layer: the menu collapses identical labels within a crew, and "board passengers at (0,0)"
* describes both trains.
*/
const twoAtPlatform = (): { game: Game; trays: string[] } => {
const game = newGame(4242);
const s = game.state;
const area = areaOf(s, 0);
const f = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!.facility!;
// A Station's worth of platform: Porters, slots, and two fares waiting.
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
const trays: string[] = [];
for (const trainNumber of [7, 9]) {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord },
movesUsed: 0,
} as never);
area.adOccupancy.push(id);
trays.push(id);
}
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
return { game, trays };
};
it('offers boarding once per train, with the train named on the button', () => {
const { game } = twoAtPlatform();
const labels = actionMenu(game)
.direct.flatMap((g) => g.actions)
.map((a) => a.label)
.filter((l) => /^board passengers/.test(l));
assert.equal(labels.length, 2, `expected one button per train, got ${JSON.stringify(labels)}`);
assert.ok(labels.some((l) => /Train 7/.test(l)), `no button names Train 7: ${JSON.stringify(labels)}`);
assert.ok(labels.some((l) => /Train 9/.test(l)), `no button names Train 9: ${JSON.stringify(labels)}`);
});
it('boards the train whose button was pressed', () => {
const { game, trays } = twoAtPlatform();
const { options } = actionGroups(game);
const nine = options.findIndex(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[1],
);
assert.ok(nine >= 0, 'no boarding option names the second train');
submit(game, options[nine]!);
assert.equal(game.state.trays.get(trays[1]!)!.consist[0]!.loaded, true, 'Train 9 did not get them');
assert.equal(game.state.trays.get(trays[0]!)!.consist[0]!.loaded, false, 'Train 7 was filled instead');
});
it('will not detrain the passengers it has just put aboard', () => {
// The other half of the same playtest: "passenger stations — passengers just boarded cannot be
// immediately unloaded." They could, for a Porter action and full Revenue, without the train
// moving an inch.
const { game, trays } = twoAtPlatform();
const board = actionGroups(game).options.find(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
)!;
submit(game, board);
const detrains = actionGroups(game).options.filter((o) => o.type === 'porter.detrain');
assert.equal(detrains.length, 0, 'detraining was still offered for passengers who boarded here');
assert.equal(
check(game.state, 0, { type: 'porter.detrain', at: areaOf(game.state, 0).officeCoord, trayId: trays[0]! }),
'LOADED_IN_THIS_DISTRICT',
);
});
it('says on the coach that it was loaded here', () => {
// The printed game turns the chip upside down in the tray; this is the screen's version of that.
const { game, trays } = twoAtPlatform();
submit(game, actionGroups(game).options.find(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
)!);
const f = view(game);
const office = f.cells.find((c) => c.kind === 'office')!;
const train = office.trains.find((t) => t.trayId === trays[0]);
assert.ok(train, 'the boarded train is not on the Office card');
assert.ok(
train!.cars.some((c) => /loaded here/.test(c)),
`the coach does not say where it was loaded: ${JSON.stringify(train!.cars)}`,
);
});
});
describe('the Superintendent ruling names the train it is ruling on', () => {
/**
* REPORTED from play: "when the Superintendent has to rule on a train to allow or hold, it should