Compare commits
11
Commits
37b1e5b969
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40f07b0710 | ||
|
|
51710498f5 | ||
|
|
bfd2708ecc | ||
|
|
689de2ff0f | ||
|
|
2fbfe11977 | ||
|
|
62b6ed7e1b | ||
|
|
e76bd77099 | ||
|
|
c3c5cbfeec | ||
|
|
f9c4d9fa92 | ||
|
|
3a5a909ec0 | ||
|
|
859817d173 |
@@ -15,6 +15,7 @@ Thumbs.db
|
|||||||
# "tsc: not found" and 20 tests failed for a reason that had nothing to do with the code.
|
# "tsc: not found" and 20 tests failed for a reason that had nothing to do with the code.
|
||||||
node_modules
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
|
dist-test/
|
||||||
build/
|
build/
|
||||||
target/
|
target/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
+659
@@ -19,6 +19,665 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.6.0 — 2026-08-21
|
||||||
|
|
||||||
|
Three queued items, and the last of them is the one that matters most.
|
||||||
|
|
||||||
|
### A release no longer destroys every game in progress
|
||||||
|
|
||||||
|
Four consecutive releases killed every game on the box — one of them a release that changed only
|
||||||
|
how the board is drawn. The reasoning behind the refusal was always right: a move that was legal
|
||||||
|
under the old rules may not be under the new ones, and half-replaying a save is worse than refusing
|
||||||
|
it. The **test** was wrong. It compared `engineVersion` for exact equality, and that stamp is the
|
||||||
|
*package* version, which moves for a CSS fix.
|
||||||
|
|
||||||
|
Whether a save still replays has an exact answer, so it is now asked directly. `loadGame` reads the
|
||||||
|
file and judges nothing; `tryResumeSession` replays the intents and reports the first one the engine
|
||||||
|
refuses, if any. A save stamped with a version this server has never run resumes fine, provided its
|
||||||
|
moves replay — verified against a file hand-stamped `0.4.9-ancient`. One that genuinely does not
|
||||||
|
replay is still refused, but the log now names the move rather than two version strings: *"move 3 of
|
||||||
|
8 (`localOps.choose`) is rejected by the current rules with `OPTION_ALREADY_CHOSEN`"*.
|
||||||
|
|
||||||
|
`fromMultiplayerSave` had to stop lying first. It has always stopped at the first unacceptable
|
||||||
|
intent and done so **in silence**, which was survivable only because the version gate meant a doomed
|
||||||
|
replay was never attempted. Now that the replay *is* the check, it returns where it stopped and why.
|
||||||
|
|
||||||
|
Deliberately not done: resuming a partly-replayable game at its last good move. That silently
|
||||||
|
rewinds a game to a position nobody played to, while every browser holding a later Frame carries on
|
||||||
|
unaware. Refusing leaves the file intact, so putting the previous version back still recovers it.
|
||||||
|
|
||||||
|
### Employee Rotation is real, and Sister Trains is gone
|
||||||
|
|
||||||
|
Two of the four optional-rule flags were read by nothing at all.
|
||||||
|
|
||||||
|
**Employee Rotation** is implemented — "at the end of the day, all players move one chair to the
|
||||||
|
left and take over the next station up the line. Take your points (and the Fedora) with you." It is
|
||||||
|
four lines in `advance.ts`, because the seat/player split (D9) exists for precisely this rule:
|
||||||
|
`seating` is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is
|
||||||
|
travel with the player for free, and the Office, district, grid and any trains standing in it stay
|
||||||
|
with the chair. Inheriting the state of the district you move into is the point of the rule, not a
|
||||||
|
side effect. "Left" is `seat + 1`, matching `playerLeftOf`.
|
||||||
|
|
||||||
|
**Sister Trains** is deleted rather than implemented. Q9 records that the Second Section card
|
||||||
|
supersedes it — and that card is built — so the flag was a toggle for a rule the game no longer has.
|
||||||
|
|
||||||
|
### The lobby asks what game you want to play
|
||||||
|
|
||||||
|
Creating a game asked for a display name, a mode and a table size; every other dial came from
|
||||||
|
`defaultMultiplayerConfig`, hardcoded. A **Game settings** block now carries the same set the
|
||||||
|
solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue
|
||||||
|
floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules.
|
||||||
|
Mode and table size set the defaults and everything stays editable, exactly as the solitaire dialog
|
||||||
|
already behaved.
|
||||||
|
|
||||||
|
The seed is honoured: name one and the table deals that railroad, so a game can be reproduced or
|
||||||
|
compared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.5.6 — 2026-08-21
|
||||||
|
|
||||||
|
Three things off the first proper look at a live table.
|
||||||
|
|
||||||
|
### Seats are counted from 1
|
||||||
|
|
||||||
|
The lobby listed chairs as Seat 0 to Seat 3. Zero-based is right *inside* — it indexes `seating`,
|
||||||
|
the seats array and every route, and none of that changes — but nobody sitting down at a table
|
||||||
|
calls their chair "seat 0". The displayed number is now the one a player would say out loud.
|
||||||
|
|
||||||
|
There were four of these, not one: the lobby list, the topline's `Seat N` for a remote session, the
|
||||||
|
presence banner's fallback name, and the admin summary's. All go through a single `seatLabel`, and
|
||||||
|
a test fails the build if any `Seat ${…}` interpolates a raw seat again — the conversion has to
|
||||||
|
happen at exactly one place or the two conventions drift. (The StartOS package's **Games in
|
||||||
|
Progress** action had the same leak and is fixed alongside.)
|
||||||
|
|
||||||
|
`seatLabel` lives in `view.ts` rather than `web/game.ts`, because the page may not import values
|
||||||
|
from that module — they are the local engine by another name, and `test/session.test.ts` fails the
|
||||||
|
build for it. Putting it there was the first attempt; the test was right and the placement was
|
||||||
|
wrong.
|
||||||
|
|
||||||
|
### The current player's name was unreadable
|
||||||
|
|
||||||
|
`.bs-name.bs-turn` carried `font-weight:700` over a base of 600. At 11px a monospace face has to be
|
||||||
|
synthesised the rest of the way, and the extra ink lands as blur rather than as weight — so the one
|
||||||
|
name you most need to read was the one you could not. The weight bump is gone; amber against
|
||||||
|
`#e6e9ee` was always doing the work, and blue "(you)" and amber "their move" stay clearly distinct
|
||||||
|
without it.
|
||||||
|
|
||||||
|
### The seating chain says its piece once
|
||||||
|
|
||||||
|
The west-to-east line under the Division map explains who is where and why, which is a question you
|
||||||
|
have once — at the start, when the chain has just been rolled and the names are new. It now shows
|
||||||
|
only during Day 1 Stage 1. By Stage 2 the map itself has been answering it for a while, and a
|
||||||
|
permanent line restating it is a permanent line to read past.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.5.5 — 2026-08-21
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### A remembered session for a game the server no longer has
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The only escape was clearing site data, and nothing on screen said so.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
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
|
||||||
|
page that is broken, which is precisely what this looked like.
|
||||||
|
|
||||||
|
### Recorded, not fixed
|
||||||
|
|
||||||
|
`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.5.4 — 2026-08-21
|
||||||
|
|
||||||
|
Six things found by playing the StartOS build, all of them about the game telling you what it
|
||||||
|
already knows.
|
||||||
|
|
||||||
|
### A disabled button that did not look disabled
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### The game code is the invitation
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 Division map names its districts
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Supporting changes
|
||||||
|
|
||||||
|
`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`.
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.5.3 — 2026-08-21
|
||||||
|
|
||||||
|
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 host picks the table size, and a gap stops being expressible
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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`.
|
||||||
|
|
||||||
|
### Administration: what is running, and how to end it
|
||||||
|
|
||||||
|
`/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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Three deliberate choices in that:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
Three bugs from the same playtest session, patched directly onto 0.4.9a rather than the in-progress
|
||||||
|
0.5.0 line: a display bug that made a switching train look like it had vanished, an engine bug that
|
||||||
|
silently ignored which car the player chose to unload, and a UI bug that could render a legal
|
||||||
|
decision with zero buttons — indistinguishable from a hang.
|
||||||
|
|
||||||
|
### A switching train vanished from the board the moment it left the Office
|
||||||
|
|
||||||
|
**REPORTED:** Train 3 stood at the Office; dropping the loaded boxcar off the back at the Freight
|
||||||
|
House made the train "vanish off the face of the earth," regardless of which way it moved
|
||||||
|
afterward. The game state was never wrong — replaying the reported save
|
||||||
|
(`docs/station-master-seed116956197-day2.json`) through the engine directly showed `tray3` fully
|
||||||
|
intact, in the right place, with the right consist, for the rest of the game. Nothing was ever lost
|
||||||
|
from `state.trays`.
|
||||||
|
|
||||||
|
The bug was in `officeSvg` (`board-svg.ts`): `selectedTrain` — the value that decides whether a
|
||||||
|
card draws a train's crew badge (its label, engine arrow, and cars) — was assigned only inside the
|
||||||
|
`cell.adTracks !== null` branch, which is true for the Office card and nothing else. A train
|
||||||
|
standing on any other card, which is every card it stands on while actually being switched, got no
|
||||||
|
badge at all. It disappeared on the very first move off the Office, not only at the Freight House —
|
||||||
|
that was simply the first place a player was likely to pause and look. Traced to the Roster Pass
|
||||||
|
(37b1e5b), which rewrote `CellView.train` into `CellView.trains[]` to show every train sharing the
|
||||||
|
Office's A/D tracks and, in doing so, nested the general consist-drawing logic inside that
|
||||||
|
Office-only branch instead of leaving it to run for any card with a train on it.
|
||||||
|
|
||||||
|
**Fixed** by hoisting the `selectedTrain` assignment out of the `adTracks !== null` guard so it
|
||||||
|
runs for every card; the A/D roster-chip loop itself stays inside the guard, since only the Office
|
||||||
|
has more than one A/D track to draw chips for. New test: a train parked on an ordinary facility
|
||||||
|
card, `adTracks: null`, now has to draw its crew badge — it did not before this fix.
|
||||||
|
|
||||||
|
### Unloading always took the westmost car, whichever one was picked
|
||||||
|
|
||||||
|
**REPORTED:** three loaded cars stood at a Freight House; asking to unload the east (right-hand)
|
||||||
|
car unloaded the west one instead, every time, no matter which car was actually chosen.
|
||||||
|
|
||||||
|
`laborer.beginUnload` already carried the player's choice as `carIndex`, and `check()` validated
|
||||||
|
that specific car — but the event it produced, `unloadBegan`, carried only the car's `carType`, not
|
||||||
|
which one it was. The reducer that actually performs the swap re-derived the target itself with
|
||||||
|
`industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in track
|
||||||
|
order regardless of what was requested — so the player's choice was thrown away between `check` and
|
||||||
|
the reducer, and the westmost loaded car came off every time.
|
||||||
|
|
||||||
|
**Fixed** by adding `carIndex` to the `unloadBegan` event and using it directly in the reducer
|
||||||
|
(`src/engine/apply.ts`, `case 'unloadBegan'`), instead of re-deriving a car by scanning for
|
||||||
|
`loaded`. New test in `apply.test.ts`: three loaded cars of three different types, asking to unload
|
||||||
|
index 2 (east) now leaves indices 0 and 1 alone and empties exactly the one requested — it did not
|
||||||
|
before this fix.
|
||||||
|
|
||||||
|
### A legal decision could render with zero buttons — the game looked hung
|
||||||
|
|
||||||
|
**REPORTED:** working the Freight House mid-cycle (unloading three cars, then reloading them), the
|
||||||
|
game "seemed to have hung up. Cannot advance." No trains were on the Division or the Limits.
|
||||||
|
|
||||||
|
Replaying the reported save turned up no engine deadlock at any point — every step had a legal
|
||||||
|
action — but the exact final state (an Extra train due to start, no tray yet being made up) exposed
|
||||||
|
a UI bug: `newTrain.startExtra`'s action group is titled by `trainCardTitle`, which begins "Making
|
||||||
|
up Extra X22…" — the same prefix `renderActions()` (`main.ts`) used to strip from the action list on
|
||||||
|
the assumption it always belonged to the separate yard-chip car-placement panel. With no tray being
|
||||||
|
filled yet, that panel (`menu.makeUp`) is `null`, so nothing else rendered the Extra's "choose where
|
||||||
|
it starts" decision either — a legal, present, correctly-computed option with no button anywhere on
|
||||||
|
the page. Clicking nothing did nothing, which is indistinguishable from a hang.
|
||||||
|
|
||||||
|
**Fixed** by matching the exact title of the one group `menu.makeUp` actually covers
|
||||||
|
(`g.title !== menu.makeUp?.title`) instead of a title-prefix regex, so any other group that happens
|
||||||
|
to start "Making up …" — however it got that title — stays on screen. The existing softlock
|
||||||
|
regression test (`never strands the player with a legal move and no way to make it`) now mirrors the
|
||||||
|
real render filter instead of only checking the raw menu, so this class of bug fails that test
|
||||||
|
directly; a new deterministic test constructs the exact reported scenario (a pending Extra, no
|
||||||
|
active make-up) and asserts its group survives the filter.
|
||||||
|
|
||||||
|
590 tests, 0 failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.4.9a — 2026-08-20
|
||||||
|
|
||||||
|
Four small tweaks to the splash page (`index.html`, `splash.ts`), no engine changes.
|
||||||
|
|
||||||
|
### Box art opens full size on click
|
||||||
|
|
||||||
|
The thumbnail beside the title was too small to read. Clicking (or Enter/Space on keyboard focus)
|
||||||
|
now opens it in a full-size lightbox overlay; click the overlay or press Escape to close.
|
||||||
|
|
||||||
|
### A third card balances the bottom row
|
||||||
|
|
||||||
|
The doors row was nested beside the box art, two cards wide, leaving the width under the box art
|
||||||
|
unused. Moved to a full-width row below the hero (three columns at roughly the same card width as
|
||||||
|
before) and added **Play multiplayer**, modeled on the Play Solitaire card but describing the
|
||||||
|
not-yet-built feature, with "Coming soon" in place of a link. Its text is shorter than Play
|
||||||
|
Solitaire's so the row height — and the page's vertical size — doesn't change.
|
||||||
|
|
||||||
|
### Footer's "runs in your browser" claim scoped to solitaire
|
||||||
|
|
||||||
|
Now reads "solitaire runs entirely in your browser" rather than an unqualified claim that would no
|
||||||
|
longer hold once multiplayer needs a server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.4.9 — 2026-08-19
|
||||||
|
|
||||||
|
A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the first
|
||||||
|
real audio beyond a placeholder. Six changes: one display fix, two rules corrections, one engine bug
|
||||||
|
found while explaining a report rather than reported directly, three new sound cues, and the splash
|
||||||
|
page's box art.
|
||||||
|
|
||||||
|
### Office coordinates read X,Y now, not Y,X
|
||||||
|
|
||||||
|
Reported as confusing for anyone reading them the ordinary way: "at game start the office is 0,0;
|
||||||
|
the western limit should be -1,0, the eastern +1,0; a restaurant due north should be 0,1." The
|
||||||
|
engine's internal `GridCoord{row,col}` already has exactly that geometry — row increases north, col
|
||||||
|
increases east (`track.ts:59`) — so this was a **display-only fix**, not a data-model change: every
|
||||||
|
place a coordinate is shown to a player now prints `(col,row)` instead of `(row,col)`. The on-card
|
||||||
|
label in `board-svg.ts` (the one everyone sees), the switching-crew tooltip and button label in
|
||||||
|
`main.ts`, the rejected-option and switching-group text in `game.ts` and `view.ts`, and the
|
||||||
|
blocked-move text in `narrate.ts`. Internal `Map` keys built from `${row},${col}` are untouched —
|
||||||
|
nothing reads them but lookup code.
|
||||||
|
|
||||||
|
### "No switching" now means no adding or dropping cars — not "never touch it"
|
||||||
|
|
||||||
|
Reported from play: a train sitting at the Office with NO SWITCHING printed on its card (the two
|
||||||
|
expresses, Light Engine, Campaign, Circus, Military) could not be moved onto Secondary Track to
|
||||||
|
clear the mainline for other traffic, even though nothing on the card says that. `switchingRefusal()`
|
||||||
|
(`apply.ts`) blocked `switch.move` outright for these six cards; it now only refuses a move that
|
||||||
|
would COUPLE a fresh car — the same way `rules.dropOnly` was already handled for X13 — so the crew
|
||||||
|
can still be shunted clear, it just cannot pick up, set out or sort. `switch.dropCars` and
|
||||||
|
`switch.sortConsist` stay blocked. The switching-moves list (`view.ts`) no longer excludes these
|
||||||
|
trains, and the card description text was rewritten from "it runs the Division and does not shunt"
|
||||||
|
to "may not add or drop cars, but may still be moved clear of the mainline."
|
||||||
|
|
||||||
|
### Q3, corrected: Expedite is about WHERE a train may be left, not WHEN it leaves
|
||||||
|
|
||||||
|
**The reported symptom:** Train 1 arrived at an Office and was forced out at the end of the same
|
||||||
|
Stage — the read since v0.4.2 — leaving no Local Operations turn to switch it. **The rule question
|
||||||
|
underneath it:** why should "Expedite" mean a train is rushed out early at all, rather than simply
|
||||||
|
never being left parked off the station?
|
||||||
|
|
||||||
|
Corrected to the latter. An expedited train now arrives and stands exactly like any other train,
|
||||||
|
released by the ordinary §8.1 "stood a full Stage at the Office" rule — it can be switched normally
|
||||||
|
in between. Deleted entirely: `arriveAtOffice`'s `departsThisStage` flag and the whole
|
||||||
|
expedited-departures pass that used to run in `shiftChange`, plus the now-dead `pass: 'mainline' |
|
||||||
|
'expedite'` parameter on `moveTrain`.
|
||||||
|
|
||||||
|
In its place: a new fault. At the top of every Mainline Phase, any expedited train sitting on the
|
||||||
|
grid but not on the Office square itself — left on Secondary Track to clear a switching move, say —
|
||||||
|
costs 1 Revenue (`EXPEDITE_FAULT_PENALTY`, `expediteFault`), charged again every Phase it is still
|
||||||
|
caught there. A train the ordinary rules are legitimately holding AT the station — even one §8.1 is
|
||||||
|
asking the Superintendent to rule on — is not a fault; only being left elsewhere in the district is.
|
||||||
|
|
||||||
|
**This resolves "3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE"** (`TODO.md`) as a side effect: the
|
||||||
|
Express can now actually reach a Local Operations turn to use its printed "may drop or pick up one
|
||||||
|
freight car at every location," which the old same-stage departure made structurally impossible.
|
||||||
|
|
||||||
|
**Measured (30 fresh bot games, standard length):** the fault fires, but the bot has no strategy for
|
||||||
|
it yet — one game left Train 4 (3/4 Express) parked off the station from Day 3 Stage 10 to the end of
|
||||||
|
the game, taking the -1 penalty 26 times. Not an engine bug: the mechanism is working as designed and
|
||||||
|
correctly reveals that the bot doesn't know to bring an expedited train home before ending a
|
||||||
|
switching turn. Logged in `TODO.md` under Bot Performance for a future pass.
|
||||||
|
|
||||||
|
### `evaluateClearance` now checks every occupant, not just the first one it finds
|
||||||
|
|
||||||
|
Found while explaining a second playtest report from the same game: the Superintendent was asked to
|
||||||
|
rule on a same-direction train instead of being automatically held against an opposite-direction one
|
||||||
|
also occupying the card. Root cause: the occupant loop (`advance.ts`) returned on whichever occupant
|
||||||
|
it examined first, in `node.transits` insertion order — correct while a Subdivision held at most one
|
||||||
|
train, but a card can now legitimately hold two at once, which is exactly what the
|
||||||
|
Telegraph/Telephone/Radio dispatch exception creates (a facing train dispatched past another sits on
|
||||||
|
the same card as it). Fixed to two passes: every occupant is checked for an opposite-direction
|
||||||
|
absolute bar first (subject to the dispatch exception) before any same-direction judgment call is
|
||||||
|
offered. Pinned with a regression test that fails against the old single-pass code
|
||||||
|
(`enhancements.test.ts`).
|
||||||
|
|
||||||
|
### Three more sounds, synthesised like the rest
|
||||||
|
|
||||||
|
`arrive`, `depart` and `crash` join `stage`/`couple`/`drop`/`schedule`/`completed`/`day`/`train` in
|
||||||
|
`sound.ts` — a train pulling into an Office (decelerating chuffs, a soft bell tap), one highballing
|
||||||
|
out of one (accelerating chuffs, a falling horn note), and a collision (dissonant tones bent sharply
|
||||||
|
down over a noise boom and a sub-bass thump), wired to `trainArrived`, `trainHighballed` (Office
|
||||||
|
departures only, so it doesn't double up with a fresh make-up's "All aboard" or a full division run's
|
||||||
|
`completed`), and `trainsDestroyed`. Three real WAV clips were sourced for this and considered, but
|
||||||
|
carried no license information anywhere — no `LICENSE` file, no embedded metadata, no note of where
|
||||||
|
they came from — so they were not committed; the synthesised versions are good enough to keep as the
|
||||||
|
real thing rather than a placeholder.
|
||||||
|
|
||||||
|
### The splash page has its box art
|
||||||
|
|
||||||
|
`docs/StationMasterSplashScreen.png` (1122×1402, 3.0 MB) is now a 560×700 JPEG at 145 KB
|
||||||
|
(`public/images/`, copied verbatim into `dist/images/` by a small addition to `build-web.ts`) and
|
||||||
|
sits beside the title, tagline, blurb and both buttons in a side-by-side hero on `index.html`,
|
||||||
|
stacking to image-above-text under the existing mobile breakpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.4.8 — 2026-08-19
|
## 0.4.8 — 2026-08-19
|
||||||
|
|
||||||
Four reports from the same game, all about squares: which ones a card may go on, which one a button
|
Four reports from the same game, all about squares: which ones a card may go on, which one a button
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
@@ -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/lobby/create, /api/lobby/join lobby
|
||||||
POST /api/intent { gameId, seq, intent }
|
POST /api/intent { gameId, seq, intent }
|
||||||
GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume
|
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
|
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
|
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
|
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
|
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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **D20** | **Replay reveals everything once the game ends** | Most useful for learning and for arguing about it afterwards; costs nothing extra to retain. |
|
||||||
|
|
||||||
|
|||||||
@@ -42,17 +42,28 @@ Operational Rail wheel icon, an industry track of the stated length, Laborer ico
|
|||||||
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
|
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
|
||||||
| Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 |
|
| Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 |
|
||||||
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
|
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
|
||||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | 2 |
|
| Grocer's Warehouse | Boxcar | Both | 1 | 1 | 1 | 3 | 3 |
|
||||||
| Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4 | 2 |
|
| Oil Refinery | Tank car | Both | 1 | 1 | 1 | 4 | 3 |
|
||||||
| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 |
|
| 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;
|
Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant;
|
||||||
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases —
|
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases —
|
||||||
outbound-only, inbound-only, and both.
|
outbound-only, inbound-only, and both.
|
||||||
|
|
||||||
**"Freight House"** (§9.3, Appendix A) is not a card. It is the collective term for a freight facility
|
**"Freight House" IS a card** (corrected v0.5.0) — a sixth industry, dealt 6 copies, one Laborer and
|
||||||
that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery. §9.3's "Passenger Facilities
|
one slot each direction. An earlier pass here read §9.3/Appendix A's "Passenger Facilities and
|
||||||
and Freight Houses permit cars to move each direction" therefore names exactly those two.
|
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
|
### Throughput — why these Laborer counts
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ Every defined term, alphabetized for lookup. The core comes from the Definitions
|
|||||||
| **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 |
|
| **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 |
|
||||||
| **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 |
|
| **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 |
|
| **Facility** | A business which loads/unloads cargo and freight. | §2.5 |
|
||||||
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.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** | Not a card — the collective term for a freight facility permitting both directions, namely the Grocer's Warehouse and the Oil Refinery. | §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 |
|
| **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 |
|
| **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 |
|
| **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 |
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
**Settled (v0.5.0): orientation is never a player choice, and this overrides the card's own printed
|
||||||
returns a ready state, so there is no point at which a player can be asked. Orientation is currently
|
"Player sets orientation."** A Heavy Grade sits on the shared west-to-east Division chain, between
|
||||||
**rolled** — deterministic from the seed, and roughly even (51/49 east/west across 400 games) — and
|
two players (§4.2) or beyond an end Division Point next to one — never inside a single player's own
|
||||||
should become a real player choice when setup gains an interactive phase.
|
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
|
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
|
||||||
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
|
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
|
||||||
|
|||||||
@@ -718,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
|
keeps that power scarce. Capacity modifiers are stronger than they sound, since banking room rather
|
||||||
than worker count is what limits output.
|
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
|
**Sub-decision — what a "Freight House" is. SUPERSEDED (v0.5.0).** §9.3 and Appendix A refer to
|
||||||
facility list never defines one. It is **not a card**: it is the collective term for a freight
|
Freight Houses, but §9's facility list never defines one. This originally concluded it was **not a
|
||||||
facility that both loads and unloads — under 10a, the Grocer's Warehouse and the Oil Refinery. This
|
card** — the collective term for a freight facility that both loads and unloads, under 10a the
|
||||||
reconciles every reference without adding a card type or disturbing Gap 4a's deck composition.
|
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
|
### 10e — Revenue/Day revalidated, and the Gap 6 targets revised
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
(§11). A Whistle Post is not a Passenger Facility, so a player who has not upgraded has no
|
||||||
passenger business at all.
|
passenger business at all.
|
||||||
- **Freight Facilities:** Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power
|
- **Freight Facilities:** Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power
|
||||||
Plants. Per-card values in §12.5.
|
Plants, Freight Houses. Per-card values in §12.5.
|
||||||
- **Freight House:** **[Gap 10d]** not a facility type of its own, but the collective term for a
|
- **Freight House:** a Freight Facility card in its own right (corrected v0.5.0 — **[Gap 10d]**
|
||||||
freight facility permitting both directions — the Grocer's Warehouse and the Oil Refinery.
|
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
|
- **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
|
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]**.
|
effects may only be used on one Facility per **Stage** **[Gap 7]**.
|
||||||
@@ -753,8 +754,9 @@ Modifier effects, and track geometries — is catalogued in
|
|||||||
| Extra Platform | +1 green and +1 red slot (passenger) |
|
| Extra Platform | +1 green and +1 red slot (passenger) |
|
||||||
| Section Gang | +1 Laborer or +1 Porter |
|
| Section Gang | +1 Laborer or +1 Porter |
|
||||||
|
|
||||||
**"Freight House"** (§9.3, Appendix A) is not a card — it is the collective term for a freight
|
**"Freight House"** (§9.3, Appendix A) is a Freight Facility card (corrected v0.5.0 — previously
|
||||||
facility that both loads and unloads, namely the Grocer's Warehouse and the Oil Refinery.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.4.8",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.4.8",
|
"version": "0.5.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.4.8",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"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",
|
"build:web": "node scripts/build-web.ts",
|
||||||
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
||||||
"deploy:web": "node scripts/deploy-web.ts"
|
"deploy:web": "node scripts/deploy-web.ts"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
+19
-2
@@ -10,12 +10,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.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 });
|
rmSync(dist, { recursive: true, force: true });
|
||||||
mkdirSync(dist, { recursive: true });
|
mkdirSync(dist, { recursive: true });
|
||||||
@@ -151,6 +157,17 @@ if (existsSync(replaySrc)) {
|
|||||||
}
|
}
|
||||||
writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1));
|
writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static images — the splash artwork and whatever joins it. Copied verbatim, unlike the replays
|
||||||
|
* above: an image needs no validation, only a place in `dist/` to be served from.
|
||||||
|
*/
|
||||||
|
const imageSrc = join(root, 'public/images');
|
||||||
|
if (existsSync(imageSrc)) {
|
||||||
|
const imageOut = join(dist, 'images');
|
||||||
|
mkdirSync(imageOut, { recursive: true });
|
||||||
|
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
|
||||||
|
}
|
||||||
|
|
||||||
// A tiny note for whoever unzips this later and wonders what it needs.
|
// A tiny note for whoever unzips this later and wonders what it needs.
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(dist, 'README.txt'),
|
join(dist, 'README.txt'),
|
||||||
|
|||||||
+161
-138
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
COLLISION_PENALTY,
|
COLLISION_PENALTY,
|
||||||
|
EXPEDITE_FAULT_PENALTY,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
enhancementRule,
|
enhancementRule,
|
||||||
crossingStages,
|
crossingStages,
|
||||||
@@ -26,9 +27,7 @@ import {
|
|||||||
MOVES_PER_LOCAL_OPS_NIGHT,
|
MOVES_PER_LOCAL_OPS_NIGHT,
|
||||||
STAGES_PER_DAY,
|
STAGES_PER_DAY,
|
||||||
STAGES_PER_SHIFT,
|
STAGES_PER_SHIFT,
|
||||||
collectiveRevenueFloor,
|
|
||||||
houseRules,
|
houseRules,
|
||||||
lengthProfile,
|
|
||||||
officeProfile,
|
officeProfile,
|
||||||
REGIONS_PER_MAINLINE_CARD,
|
REGIONS_PER_MAINLINE_CARD,
|
||||||
mainlineProfile,
|
mainlineProfile,
|
||||||
@@ -272,10 +271,23 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
return { events, needsInput: true };
|
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);
|
const filling = trainNeedingCars(s);
|
||||||
if (filling) {
|
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 };
|
return { events, needsInput: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +319,38 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
|
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Q3 — A STATION MASTER FAULT: an expedited train not at the station when a Mainline Phase begins
|
||||||
|
* was not kept ready to highball, wherever in the district it has been left — switched onto
|
||||||
|
* Secondary Track to clear a move, say. Checked against positions as they stand BEFORE this phase
|
||||||
|
* moves anything, and charged every Phase it is still caught away: the fault is in leaving it
|
||||||
|
* there, not a one-time slip. A train the ordinary §8.1 rules are holding at the Office itself is
|
||||||
|
* unaffected — this only bites when the train is not even in the queue to leave.
|
||||||
|
*/
|
||||||
|
for (const [, tray] of order) {
|
||||||
|
if (!isExpedited(tray) || tray.position.at !== 'grid') continue;
|
||||||
|
const area = areaAtSeat(s, tray.position.seat);
|
||||||
|
const { coord } = tray.position;
|
||||||
|
if (coord.row === area.officeCoord.row && coord.col === area.officeCoord.col) continue;
|
||||||
|
const owner = playerAtSeat(s, tray.position.seat);
|
||||||
|
const p = s.players[owner];
|
||||||
|
if (!p) continue;
|
||||||
|
p.revenue -= EXPEDITE_FAULT_PENALTY;
|
||||||
|
events.push({
|
||||||
|
type: 'expediteFault',
|
||||||
|
player: owner,
|
||||||
|
trainNumber: tray.trainNumber ?? 0,
|
||||||
|
where: `(${coord.col},${coord.row})`,
|
||||||
|
});
|
||||||
|
events.push({
|
||||||
|
type: 'revenueChanged',
|
||||||
|
player: owner,
|
||||||
|
delta: -EXPEDITE_FAULT_PENALTY,
|
||||||
|
total: p.revenue,
|
||||||
|
reason: 'expedited train left off the station',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const [id, tray] of order) {
|
for (const [id, tray] of order) {
|
||||||
if (s.movedThisPhase.has(id)) continue;
|
if (s.movedThisPhase.has(id)) continue;
|
||||||
const where = tray.position;
|
const where = tray.position;
|
||||||
@@ -329,13 +373,13 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
(tray.position.at !== 'grid' ||
|
(tray.position.at !== 'grid' ||
|
||||||
where.at !== 'grid' ||
|
where.at !== 'grid' ||
|
||||||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
|
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
|
||||||
if (!tray.departsThisStage && stillThere) {
|
if (stillThere) {
|
||||||
tray.stopPointClaimed = true;
|
tray.stopPointClaimed = true;
|
||||||
// The point goes to whoever is SITTING in the district it stopped in.
|
// The point goes to whoever is SITTING in the district it stopped in.
|
||||||
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
|
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
|
||||||
const label =
|
const label =
|
||||||
tray.position.at === 'grid'
|
tray.position.at === 'grid'
|
||||||
? `(${tray.position.coord.row},${tray.position.coord.col})`
|
? `(${tray.position.coord.col},${tray.position.coord.row})`
|
||||||
: tray.position.at === 'mainline'
|
: tray.position.at === 'mainline'
|
||||||
? `Mainline card ${tray.position.index}`
|
? `Mainline card ${tray.position.index}`
|
||||||
: `the ${tray.position.side} Division Point`;
|
: `the ${tray.position.side} Division Point`;
|
||||||
@@ -475,14 +519,18 @@ function spendDispatchBonus(
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
|
|
||||||
/**
|
/**
|
||||||
* Does this train leave in the same Stage it arrives (§7)?
|
* Q3 — is this train subject to the "kept ready" fault (§7)?
|
||||||
|
*
|
||||||
|
* Expedite does not change WHEN a train leaves — it is released by the ordinary §8.1 rules like any
|
||||||
|
* other train, and may be switched normally while it stands. What it means is that it must not be
|
||||||
|
* held anywhere but the station: a train the player parks on Secondary Track to clear a switching
|
||||||
|
* move faults if it is not back on the Office square by the next Mainline Phase (`mainlinePhase`).
|
||||||
*
|
*
|
||||||
* Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign
|
* Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign
|
||||||
* Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while
|
* Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while
|
||||||
* the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first
|
* the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first
|
||||||
* stop, so the train is slow once and fast thereafter.
|
* stop, so the train is exempt once and subject to the fault thereafter.
|
||||||
*/
|
*/
|
||||||
function isExpedited(tray: CrewTray): boolean {
|
function isExpedited(tray: CrewTray): boolean {
|
||||||
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
|
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
|
||||||
@@ -491,18 +539,7 @@ function isExpedited(tray: CrewTray): boolean {
|
|||||||
return rules.stopThenExpedite === true && tray.speechMade === true;
|
return rules.stopThenExpedite === true && tray.speechMade === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveTrain(
|
function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): MoveOutcome {
|
||||||
s: GameState,
|
|
||||||
id: TrayId,
|
|
||||||
tray: CrewTray,
|
|
||||||
events: GameEvent[],
|
|
||||||
/**
|
|
||||||
* Which pass is asking. The Mainline Phase moves every train once; Supervisor Shift then asks the
|
|
||||||
* Expedited arrivals to go again. The train cannot tell the difference, but the player must — see
|
|
||||||
* `trainHighballed.why`.
|
|
||||||
*/
|
|
||||||
pass: 'mainline' | 'expedite' = 'mainline',
|
|
||||||
): MoveOutcome {
|
|
||||||
const dir = step(tray.direction);
|
const dir = step(tray.direction);
|
||||||
|
|
||||||
if (tray.position.at === 'divisionPoint') {
|
if (tray.position.at === 'divisionPoint') {
|
||||||
@@ -588,10 +625,7 @@ function moveTrain(
|
|||||||
trainNumber: tray.trainNumber ?? 0,
|
trainNumber: tray.trainNumber ?? 0,
|
||||||
from: 'the Office',
|
from: 'the Office',
|
||||||
to: 'the Mainline',
|
to: 'the Mainline',
|
||||||
why:
|
why: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
|
||||||
pass === 'expedite'
|
|
||||||
? 'its card prints EXPEDITE, so it leaves at the end of the Stage it arrived in — you got the Cargo phase with it, but no Local Operations turn to switch it'
|
|
||||||
: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
|
|
||||||
});
|
});
|
||||||
return 'moved';
|
return 'moved';
|
||||||
}
|
}
|
||||||
@@ -751,28 +785,44 @@ function evaluateClearance(
|
|||||||
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TWO PASSES, NOT ONE — an opposite-direction occupant is an absolute bar and has to be checked
|
||||||
|
* against EVERY occupant before any same-direction judgment call is offered.
|
||||||
|
*
|
||||||
|
* A single pass returned on whichever occupant it examined first, in `node.transits` insertion
|
||||||
|
* order — which is fine while a Subdivision holds at most one train, but the dispatch exception
|
||||||
|
* below means it now legitimately can hold two: a Telegraph-cleared facing train sits on the same
|
||||||
|
* card as whatever it was cleared past. Found from a playtest report where the Superintendent was
|
||||||
|
* asked to rule on a same-direction train instead of being auto-held against an uncleared
|
||||||
|
* opposite-direction one also on the card — the loop had reached the same-direction occupant
|
||||||
|
* first simply because it entered the transit list first, and returned before ever looking at
|
||||||
|
* the other.
|
||||||
|
*/
|
||||||
|
for (const { tray: other } of occupants) {
|
||||||
|
if (other === id) continue;
|
||||||
|
const otherTray = s.trays.get(other);
|
||||||
|
if (!otherTray || otherTray.direction === tray.direction) continue;
|
||||||
|
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
|
||||||
|
//
|
||||||
|
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
|
||||||
|
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
|
||||||
|
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
|
||||||
|
// way to pass the order, so the train simply holds.
|
||||||
|
if (spendDispatchBonus(s, tray, otherTray, events) > 0) continue;
|
||||||
|
return 'blocked';
|
||||||
|
}
|
||||||
|
|
||||||
for (const { tray: other, onCard } of occupants) {
|
for (const { tray: other, onCard } of occupants) {
|
||||||
if (other === id) continue;
|
if (other === id) continue;
|
||||||
const otherTray = s.trays.get(other);
|
const otherTray = s.trays.get(other);
|
||||||
if (!otherTray) continue;
|
if (!otherTray || otherTray.direction !== tray.direction) continue;
|
||||||
const onNode = s.division.nodes[onCard];
|
const onNode = s.division.nodes[onCard];
|
||||||
|
|
||||||
if (otherTray.direction !== tray.direction) {
|
|
||||||
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
|
|
||||||
//
|
|
||||||
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
|
|
||||||
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
|
|
||||||
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
|
|
||||||
// way to pass the order, so the train simply holds.
|
|
||||||
if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear';
|
|
||||||
return 'blocked';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
|
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
|
||||||
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
||||||
// where ABS Signals protects everything on the card.
|
// where ABS Signals protects everything on the card.
|
||||||
//
|
//
|
||||||
// Both of these now read the card the OTHER train is standing on rather than the card being
|
// Both of these read the card the OTHER train is standing on rather than the card being
|
||||||
// entered. They were the same card while this only looked one card ahead; across a Subdivision
|
// entered. They were the same card while this only looked one card ahead; across a Subdivision
|
||||||
// they are not, and the protection belongs where the train it protects actually is.
|
// they are not, and the protection belongs where the train it protects actually is.
|
||||||
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
||||||
@@ -854,8 +904,13 @@ function arriveAtOffice(
|
|||||||
|
|
||||||
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
|
// §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.
|
// 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));
|
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');
|
collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the Running Track', 'the Running Track');
|
||||||
return 'moved';
|
return 'moved';
|
||||||
}
|
}
|
||||||
@@ -871,27 +926,9 @@ function arriveAtOffice(
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Q3 — AN EXPEDITED TRAIN DEPARTS THE STAGE IT ARRIVED, BUT NOT BEFORE THE WORK IS DONE.
|
* X17 Campaign Train — the speeches happen at the first Office it reaches, and every arrival after
|
||||||
*
|
* that runs expedited (`isExpedited` reads `speechMade`). Setting it here is idempotent on every
|
||||||
* It used to leave inside this very Mainline Phase — a second `moveTrain` on the spot. That made
|
* later arrival, so it needs no guard against re-firing.
|
||||||
* "departs the Stage it arrives" true and the coaches worthless: Load/Unload runs AFTER Mainline,
|
|
||||||
* so an expedited train was gone before a single Porter could reach it. Measured over 60 games,
|
|
||||||
* Train 2 arrived 32 times and stood at an Office during Load/Unload exactly none of them, and
|
|
||||||
* every coach-carrying express is expedited (1/2 Crack Limited, 5/6 The Sparrow, 19 Military).
|
|
||||||
* The Crack Limited's own card says "stop at Terminals only", which it could never do.
|
|
||||||
*
|
|
||||||
* So it stands here through Load/Unload and leaves at the end of the Stage instead. It still does
|
|
||||||
* not lay over to the next Stage — the keyword is honoured — and it is still subject to every
|
|
||||||
* §8.1 condition when it goes, so it can be held.
|
|
||||||
*/
|
|
||||||
if (isExpedited(tray)) {
|
|
||||||
tray.departsThisStage = true;
|
|
||||||
return 'moved';
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* X17 Campaign Train — the speeches happen at the first Office it reaches. Recorded AFTER the
|
|
||||||
* expedite test above, so this arrival is the ordinary stop the card pays for and every later one
|
|
||||||
* runs expedited.
|
|
||||||
*/
|
*/
|
||||||
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) {
|
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) {
|
||||||
tray.speechMade = true;
|
tray.speechMade = true;
|
||||||
@@ -935,6 +972,7 @@ function collide(
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.collisionsToday += 1;
|
s.collisionsToday += 1;
|
||||||
|
s.collisionsTotal += 1;
|
||||||
const player = s.players[faultPlayer];
|
const player = s.players[faultPlayer];
|
||||||
if (player) {
|
if (player) {
|
||||||
player.revenue -= COLLISION_PENALTY;
|
player.revenue -= COLLISION_PENALTY;
|
||||||
@@ -1018,40 +1056,6 @@ function retireTrain(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||||
/**
|
|
||||||
* EXPEDITED DEPARTURES — the last thing that happens in a Stage (Q3, and `arriveAtOffice`).
|
|
||||||
*
|
|
||||||
* Run here rather than as a phase of its own because there is no decision in it: the trains that
|
|
||||||
* arrived expedited this Stage try to leave, in train-number order like any other movement, and
|
|
||||||
* §8.1 may still hold them. Being after Load/Unload is the whole point — it is what gives the
|
|
||||||
* Porters their one Stage with the train.
|
|
||||||
*/
|
|
||||||
const expedited = [...s.trays.entries()]
|
|
||||||
.filter(([, t]) => t.departsThisStage)
|
|
||||||
.sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0));
|
|
||||||
for (const [id, tray] of expedited) {
|
|
||||||
tray.departsThisStage = false;
|
|
||||||
/**
|
|
||||||
* An expedited train that would need a §8.1 ruling STAYS, and runs normally from then on.
|
|
||||||
*
|
|
||||||
* Said in the log, because it is the difference between "this train leaves at the end of the
|
|
||||||
* Stage" and "this train is still here next turn", and a player watching one Expedited train
|
|
||||||
* vanish and the next one linger has no way to tell why. Measured on one seed: with ordinary
|
|
||||||
* traffic running, Train 6 was held this way and collected four Local Operations turns.
|
|
||||||
*/
|
|
||||||
if (moveTrain(s, id, tray, events, 'expedite') === 'needsClearance') {
|
|
||||||
events.push({
|
|
||||||
type: 'trainHeld',
|
|
||||||
trainNumber: tray.trainNumber ?? 0,
|
|
||||||
reason:
|
|
||||||
'it EXPEDITES and was due out at the end of this Stage, but the Subdivision ahead needs a ' +
|
|
||||||
'clearance ruling and Supervisor Shift has no Superintendent to give one. It stays, and ' +
|
|
||||||
'runs like an ordinary train from now on — so you DO get a Local Operations turn with it',
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
|
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
|
||||||
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
||||||
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
||||||
@@ -1073,6 +1077,7 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
s.clock.stage = 1;
|
s.clock.stage = 1;
|
||||||
s.collisionsToday = 0;
|
s.collisionsToday = 0;
|
||||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||||
|
rotateSeats(s, events);
|
||||||
const finished = checkVictory(s, events);
|
const finished = checkVictory(s, events);
|
||||||
if (finished) return { events, needsInput: false };
|
if (finished) return { events, needsInput: false };
|
||||||
} else {
|
} else {
|
||||||
@@ -1080,63 +1085,81 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// §3.4 — Competitive only: three collisions in one Day and everyone loses.
|
// §3.4 — every mode but competitive-and-coop-only: a Day's collisions against `maxCollisionsPerDay`
|
||||||
if (s.config.mode === 'competitive' && s.collisionsToday >= 3) {
|
// and the game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not
|
||||||
s.status = 'finished';
|
// scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to
|
||||||
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
|
// collide, not a bigger shared budget.
|
||||||
return { events, needsInput: false };
|
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 };
|
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.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* EMPLOYEE ROTATION (Appendix B) — "at the end of the day, all players move one chair to the left
|
||||||
|
* and take over the next station up the line. Take your points (and the Fedora) with you."
|
||||||
|
*
|
||||||
|
* This is the rule the whole seat/player split exists for (D9, and `state.ts`'s note on
|
||||||
|
* `SeatIndex`), which is why it is four lines: `seating` is the only thing that moves. Everything
|
||||||
|
* keyed by PLAYER — Revenue, hands, the Superintendent, whose turn it is — travels with them for
|
||||||
|
* free, and everything keyed by SEAT — the Office, the district, the grid, trains standing in it —
|
||||||
|
* stays exactly where it is. Inheriting the state of the district you move into is the point of the
|
||||||
|
* rule, not a side effect of it.
|
||||||
|
*
|
||||||
|
* "Left" is `seatOf + 1`, matching `playerLeftOf`, which is the convention the rest of the engine
|
||||||
|
* already turns the table by.
|
||||||
|
*/
|
||||||
|
function rotateSeats(s: GameState, events: GameEvent[]): void {
|
||||||
|
if (!s.config.optionalRules.employeeRotation || s.seating.length < 2) return;
|
||||||
|
const n = s.seating.length;
|
||||||
|
const next: PlayerIndex[] = new Array<PlayerIndex>(n);
|
||||||
|
for (let seat = 0; seat < n; seat++) next[(seat + 1) % n] = s.seating[seat]!;
|
||||||
|
s.seating = next;
|
||||||
|
events.push({ type: 'seatsRotated', day: s.clock.day, seating: [...next] });
|
||||||
|
}
|
||||||
|
|
||||||
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
|
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
|
||||||
const profile = lengthProfile(s.config.length);
|
|
||||||
const daysElapsed = s.clock.day - 1;
|
const daysElapsed = s.clock.day - 1;
|
||||||
|
if (daysElapsed < s.config.days) return false;
|
||||||
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;
|
|
||||||
|
|
||||||
s.status = 'finished';
|
s.status = 'finished';
|
||||||
|
|
||||||
if (s.config.mode === 'competitive') {
|
const combined = totalRevenue(s);
|
||||||
// §3.5 — all players' Revenue combined must clear the floor, or everyone loses.
|
if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) {
|
||||||
if (totalRevenue(s) < collectiveRevenueFloor(s.players.length, profile.days)) {
|
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||||
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',
|
|
||||||
};
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solitaire and Co-op: the target doubles as a MINIMUM. Below it you lose regardless of score.
|
if (s.config.mode === 'coop') {
|
||||||
const target = s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
|
s.outcome = { result: 'win', winner: null, reason: 'daysElapsed' };
|
||||||
const score = s.config.mode === 'coop' ? totalRevenue(s) : (s.players[0]?.revenue ?? 0);
|
return true;
|
||||||
s.outcome =
|
}
|
||||||
score >= target
|
|
||||||
? { result: 'win', winner: null, reason: 'daysElapsed' }
|
const best = Math.max(...s.players.map((p) => p.revenue));
|
||||||
: { result: 'loss', winner: null, reason: 'revenueFloor' };
|
s.outcome = {
|
||||||
|
result: 'win',
|
||||||
|
winner: s.players.findIndex((p) => p.revenue === best),
|
||||||
|
reason: 'daysElapsed',
|
||||||
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-18
@@ -486,11 +486,14 @@ function freightBudgetLeft(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a train may do switching work at all (§7).
|
* Whether a train may set out or sort cars (§7).
|
||||||
*
|
*
|
||||||
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
|
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
|
||||||
* Military trains. They run the Division; they do not shunt. This covers moving, setting out and
|
* Military trains — which means they may not ADD or DROP cars, not that they may never be touched: a
|
||||||
* sorting alike, because all three are switching.
|
* train held at the Office may still need to clear onto Secondary Track ahead of other traffic. So
|
||||||
|
* this covers `switch.dropCars` and `switch.sortConsist` only; `switch.move` handles `noSwitching`
|
||||||
|
* itself, the same way it handles `dropOnly`, because the restriction has to bite on the pick-up a
|
||||||
|
* move would make, not on the move itself.
|
||||||
*/
|
*/
|
||||||
function switchingRefusal(tray: CrewTray): RejectionCode | null {
|
function switchingRefusal(tray: CrewTray): RejectionCode | null {
|
||||||
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
||||||
@@ -626,8 +629,6 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||||
const tray = s.trays.get(i.trayId);
|
const tray = s.trays.get(i.trayId);
|
||||||
if (!tray) return 'NO_SUCH_TRAY';
|
if (!tray) return 'NO_SUCH_TRAY';
|
||||||
const noSwitch = switchingRefusal(tray);
|
|
||||||
if (noSwitch) return noSwitch;
|
|
||||||
const from = trayCoord(s, i.trayId);
|
const from = trayCoord(s, i.trayId);
|
||||||
if (!from) return 'ILLEGAL_MOVE';
|
if (!from) return 'ILLEGAL_MOVE';
|
||||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||||
@@ -652,6 +653,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
*/
|
*/
|
||||||
const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length);
|
const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length);
|
||||||
if (fresh.length > 0) {
|
if (fresh.length > 0) {
|
||||||
|
/**
|
||||||
|
* "NO SWITCHING" MEANS NO ADDING OR DROPPING CARS, not "never move". A no-switching train
|
||||||
|
* held at the Office may still need to clear onto Secondary Track ahead of other traffic —
|
||||||
|
* §7 governs coupling, setting out and sorting, and a move that picks nothing up is none of
|
||||||
|
* those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent:
|
||||||
|
* the restriction has to bite on the pick-up itself, the same reasoning as the comment above.
|
||||||
|
*/
|
||||||
|
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
||||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||||
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
||||||
const freight = fresh.filter(isFreight).length;
|
const freight = fresh.filter(isFreight).length;
|
||||||
@@ -688,25 +697,24 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
? tray.consist.slice(0, i.count)
|
? tray.consist.slice(0, i.count)
|
||||||
: tray.consist.slice(tray.consist.length - i.count);
|
: tray.consist.slice(tray.consist.length - i.count);
|
||||||
const dropRules = rulesOf(tray);
|
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
|
* Trains 7/8 Local — "coach must remain on station track if switching" — the coach may never
|
||||||
* never set out during switching at all.
|
* 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
|
||||||
* The intended reading was "set out only at the Office", but §A.4 makes that unimplementable:
|
* "station track" meant on the card all along. So the coach is refused everywhere except the
|
||||||
* `canDropCarsAt` refuses the Office square outright — "the Office track is Operational Rail,
|
* Office, rather than everywhere.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach')) {
|
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach') && !atOffice) {
|
||||||
return 'COACH_MUST_STAY';
|
return 'COACH_MUST_STAY';
|
||||||
}
|
}
|
||||||
const droppedFreight = cut.filter(isFreight).length;
|
const droppedFreight = cut.filter(isFreight).length;
|
||||||
if (droppedFreight > 0 && !freightBudgetLeft(s, player, tray, here, droppedFreight)) {
|
if (droppedFreight > 0 && !freightBudgetLeft(s, player, tray, here, droppedFreight)) {
|
||||||
return 'FREIGHT_WORKED_HERE';
|
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': {
|
case 'switch.sortConsist': {
|
||||||
@@ -1654,7 +1662,13 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
case 'laborer.beginUnload': {
|
case 'laborer.beginUnload': {
|
||||||
const f = facilityAt(s, player, i.at)!;
|
const f = facilityAt(s, player, i.at)!;
|
||||||
return [
|
return [
|
||||||
{ type: 'unloadBegan', player, at: i.at, carType: f.industryTrack.cars[i.carIndex]!.type },
|
{
|
||||||
|
type: 'unloadBegan',
|
||||||
|
player,
|
||||||
|
at: i.at,
|
||||||
|
carType: f.industryTrack.cars[i.carIndex]!.type,
|
||||||
|
carIndex: i.carIndex,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2197,7 +2211,17 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
|
|
||||||
case 'unloadBegan': {
|
case 'unloadBegan': {
|
||||||
const f = facilityAt(s, e.player, e.at)!;
|
const f = facilityAt(s, e.player, e.at)!;
|
||||||
const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
|
/**
|
||||||
|
* THE CAR THE PLAYER PICKED, not merely "a loaded one".
|
||||||
|
*
|
||||||
|
* REPORTED: with several loaded cars spotted on one industry track, unloading always took the
|
||||||
|
* WESTMOST car regardless of which one was chosen. This used to re-derive the target with
|
||||||
|
* `industryTrack.cars.findIndex(c => c.loaded)`, which returns the same answer — the first
|
||||||
|
* loaded car in track order — no matter which `carIndex` the intent actually named; `check`
|
||||||
|
* had already validated that specific car, and the choice was thrown away between there and
|
||||||
|
* here. `e.carIndex` is exactly what `laborer.beginUnload`'s reducer read off the intent.
|
||||||
|
*/
|
||||||
|
const ci = e.carIndex;
|
||||||
if (ci >= 0) {
|
if (ci >= 0) {
|
||||||
/**
|
/**
|
||||||
* §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
|
* §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
|
||||||
|
|||||||
+32
-7
@@ -876,7 +876,13 @@ export function mainlineCardCount(players: number): number {
|
|||||||
return players + 1;
|
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 {
|
export function crewTrayCount(players: number): number {
|
||||||
return players + 3;
|
return players + 3;
|
||||||
}
|
}
|
||||||
@@ -1003,20 +1009,39 @@ export const MOVES_PER_LOCAL_OPS = 6;
|
|||||||
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||||
export const LABORER_ACTIONS_PER_LOAD = 4;
|
export const LABORER_ACTIONS_PER_LOAD = 4;
|
||||||
export const COLLISION_PENALTY = 5;
|
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.
|
||||||
|
* Charged every Phase it is caught there, not just once, since the fault is being left that way.
|
||||||
|
*/
|
||||||
|
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 {
|
export function collectiveRevenueFloor(players: number, days: number): number {
|
||||||
return 3 * players * days;
|
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 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[] = [
|
export const LENGTH_PROFILES: readonly LengthProfile[] = [
|
||||||
{ length: 'short', target: 10, days: 3 },
|
{ length: 'short', days: 3 },
|
||||||
{ length: 'standard', target: 20, days: 5 },
|
{ length: 'standard', days: 5 },
|
||||||
{ length: 'campaign', target: 45, days: 10 },
|
{ length: 'campaign', days: 10 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function lengthProfile(length: GameLength): LengthProfile {
|
export function lengthProfile(length: GameLength): LengthProfile {
|
||||||
|
|||||||
+17
-4
@@ -26,6 +26,8 @@ import type { CardId, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId }
|
|||||||
export type GameEvent =
|
export type GameEvent =
|
||||||
// -- clock
|
// -- clock
|
||||||
| { type: 'stageBegan'; day: number; stage: number }
|
| { type: 'stageBegan'; day: number; stage: number }
|
||||||
|
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
||||||
|
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
||||||
| { type: 'phaseBegan'; phase: string }
|
| { type: 'phaseBegan'; phase: string }
|
||||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||||
// -- local operations
|
// -- local operations
|
||||||
@@ -124,6 +126,13 @@ export type GameEvent =
|
|||||||
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
||||||
/** A train whose card pays for standing still (X18 Circus) collected on it. */
|
/** A train whose card pays for standing still (X18 Circus) collected on it. */
|
||||||
| { type: 'trainStoodStill'; trainNumber: number; where: string }
|
| { type: 'trainStoodStill'; trainNumber: number; where: string }
|
||||||
|
/**
|
||||||
|
* Q3 — A STATION MASTER FAULT. An expedited train is not to be held anywhere but the station: if
|
||||||
|
* it is still off the Office square — parked on Secondary Track, say, to clear a switching move —
|
||||||
|
* when a Mainline Phase begins, that is a failure to keep it ready to highball, and it costs
|
||||||
|
* Revenue. `where` names the square it was found on. Always followed by a `revenueChanged`.
|
||||||
|
*/
|
||||||
|
| { type: 'expediteFault'; player: PlayerIndex; trainNumber: number; where: string }
|
||||||
/**
|
/**
|
||||||
* `why` is the RULE that let it go now, not a restatement of the move.
|
* `why` is the RULE that let it go now, not a restatement of the move.
|
||||||
*
|
*
|
||||||
@@ -134,9 +143,9 @@ export type GameEvent =
|
|||||||
*/
|
*/
|
||||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string }
|
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string }
|
||||||
/**
|
/**
|
||||||
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train
|
* `expedited` flags a train that must not be left standing off the station (Q3) — it may be
|
||||||
* departs the same Stage it arrives, so there is no Local Operations turn in which to work it;
|
* switched normally like any other arrival, but it has to be back on the Office square before the
|
||||||
* every other train stands until the next Mainline Phase and can be switched in between.
|
* next Mainline Phase begins, or `expediteFault` fires.
|
||||||
*/
|
*/
|
||||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
|
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
|
||||||
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||||
@@ -160,7 +169,11 @@ export type GameEvent =
|
|||||||
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
|
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
|
||||||
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||||
| { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
| { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||||
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
/**
|
||||||
|
* `carIndex` carries the SPECIFIC car the player picked off the industry track — see the comment
|
||||||
|
* on `laborer.beginUnload` in `apply.ts` for why the reducer must not re-derive it.
|
||||||
|
*/
|
||||||
|
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number }
|
||||||
// -- consequences
|
// -- consequences
|
||||||
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
|
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
|
||||||
| { type: 'phaseEnded'; player: PlayerIndex; phase: string };
|
| { type: 'phaseEnded'; player: PlayerIndex; phase: string };
|
||||||
|
|||||||
+16
-13
@@ -53,13 +53,16 @@ export type SetupOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
|
/** 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
|
* 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`:
|
* 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.
|
* 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,
|
* 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.
|
* 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.
|
* for these two categories today.
|
||||||
*/
|
*/
|
||||||
void mode;
|
void mode;
|
||||||
const opponentCardsInDeck = false;
|
const cardsImplemented = false;
|
||||||
|
const opponentCardsInDeck = pvpCardsAllowed && cardsImplemented;
|
||||||
const cards: Card[] = [];
|
const cards: Card[] = [];
|
||||||
let n = 0;
|
let n = 0;
|
||||||
const push = (kind: Card['kind']): void => {
|
const push = (kind: Card['kind']): void => {
|
||||||
@@ -226,17 +230,15 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
|||||||
const mainline = (): DivisionNode => {
|
const mainline = (): DivisionNode => {
|
||||||
const card = kinds[rng.nextInt(kinds.length)]!;
|
const card = kinds[rng.nextInt(kinds.length)]!;
|
||||||
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
|
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') {
|
if (mainlineProfile(card).speed.kind === 'grade') {
|
||||||
/**
|
/**
|
||||||
* TEMPORARY, and flagged in TODO. The card prints "(Up)" and "Player sets orientation", so
|
* SETTLED, not provisional (v0.5.0, Jesse's call) — this overrides the card's own printed
|
||||||
* which way a Heavy Grade climbs is the player's decision — but it is dealt during setup, and
|
* "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two
|
||||||
* setup has no decision point at all. Rolled from the seed until it gains one, because the
|
* players (§4.2), or beyond an end Division Point next to one — never inside one player's own
|
||||||
* orientation MATTERS: it decides which direction climbs, and therefore what a Brakeman or
|
* district — so whichever direction climbs advantages one neighbour over the other, and there
|
||||||
* Helpers card is worth.
|
* 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';
|
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
|
||||||
}
|
}
|
||||||
@@ -317,7 +319,7 @@ export function createGame(opts: SetupOptions): GameState {
|
|||||||
*/
|
*/
|
||||||
const rules = houseRules(config);
|
const rules = houseRules(config);
|
||||||
const deal = OPENING_DEALS[rules.startingHand];
|
const deal = OPENING_DEALS[rules.startingHand];
|
||||||
const deck = buildDeck(config.mode);
|
const deck = buildDeck(config.mode, config.pvpCardsAllowed);
|
||||||
const cards = new Map<CardId, Card>();
|
const cards = new Map<CardId, Card>();
|
||||||
for (const c of deck) cards.set(c.id, c);
|
for (const c of deck) cards.set(c.id, c);
|
||||||
|
|
||||||
@@ -409,6 +411,7 @@ export function createGame(opts: SetupOptions): GameState {
|
|||||||
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
||||||
movedThisPhase: new Set(),
|
movedThisPhase: new Set(),
|
||||||
collisionsToday: 0,
|
collisionsToday: 0,
|
||||||
|
collisionsTotal: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
outcome: null,
|
outcome: null,
|
||||||
};
|
};
|
||||||
|
|||||||
+47
-20
@@ -12,7 +12,6 @@ import type {
|
|||||||
Hand,
|
Hand,
|
||||||
MainlineKind,
|
MainlineKind,
|
||||||
FreightKind,
|
FreightKind,
|
||||||
GameLength,
|
|
||||||
HouseRuleOverrides,
|
HouseRuleOverrides,
|
||||||
ModifierKind,
|
ModifierKind,
|
||||||
OfficeTier,
|
OfficeTier,
|
||||||
@@ -326,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 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.
|
* 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;
|
engineAt: number;
|
||||||
/**
|
/**
|
||||||
@@ -384,17 +389,12 @@ export type CrewTray = {
|
|||||||
* X17 Campaign Train — "one turn at station (speeches) then expedite".
|
* X17 Campaign Train — "one turn at station (speeches) then expedite".
|
||||||
*
|
*
|
||||||
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
|
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
|
||||||
* then on the train runs expedited, departing every Office in the Stage it arrives. Recorded on the
|
* then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left
|
||||||
* tray for the same reason as `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs
|
* off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
|
||||||
* once, so there is no later visit to hang it on.
|
* `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs once, so there is no later
|
||||||
|
* visit to hang it on.
|
||||||
*/
|
*/
|
||||||
speechMade?: boolean;
|
speechMade?: boolean;
|
||||||
/**
|
|
||||||
* Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to
|
|
||||||
* the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after
|
|
||||||
* Load/Unload, which is what gives the Porters their one Stage with the train.
|
|
||||||
*/
|
|
||||||
departsThisStage?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -545,15 +545,44 @@ export type Player = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GameMode = 'solitaire' | 'competitive' | 'coop';
|
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 = {
|
export type GameConfig = {
|
||||||
mode: GameMode;
|
mode: GameMode;
|
||||||
victory: VictoryCondition;
|
/** How many Days the game runs. */
|
||||||
length: GameLength;
|
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: {
|
optionalRules: {
|
||||||
reducedVisibility: boolean;
|
reducedVisibility: boolean;
|
||||||
sisterTrains: boolean;
|
|
||||||
employeeRotation: boolean;
|
employeeRotation: boolean;
|
||||||
emergencyToolbox: boolean;
|
emergencyToolbox: boolean;
|
||||||
};
|
};
|
||||||
@@ -567,11 +596,7 @@ export type GameConfig = {
|
|||||||
houseRules?: HouseRuleOverrides;
|
houseRules?: HouseRuleOverrides;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OutcomeReason =
|
export type OutcomeReason = 'daysElapsed' | 'collisionFloor' | 'revenueFloor';
|
||||||
| 'targetReached'
|
|
||||||
| 'daysElapsed'
|
|
||||||
| 'collisionFloor'
|
|
||||||
| 'revenueFloor';
|
|
||||||
|
|
||||||
export type Outcome = {
|
export type Outcome = {
|
||||||
result: 'win' | 'loss';
|
result: 'win' | 'loss';
|
||||||
@@ -747,8 +772,10 @@ export type GameState = {
|
|||||||
turns: Map<PlayerIndex, TurnState>;
|
turns: Map<PlayerIndex, TurnState>;
|
||||||
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
|
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
|
||||||
movedThisPhase: Set<TrayId>;
|
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;
|
collisionsToday: number;
|
||||||
|
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||||
|
collisionsTotal: number;
|
||||||
status: 'setup' | 'active' | 'finished';
|
status: 'setup' | 'active' | 'finished';
|
||||||
outcome: Outcome | null;
|
outcome: Outcome | null;
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-3
@@ -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.
|
* 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 {
|
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1, coachesOnly = false): boolean {
|
||||||
if (sameCoord(coord, area.officeCoord)) return false;
|
|
||||||
const card = cardAt(area, coord);
|
const card = cardAt(area, coord);
|
||||||
|
if (sameCoord(coord, area.officeCoord)) {
|
||||||
|
return coachesOnly && !!card && isOperationalRail(card) && spaceOn(card) >= count;
|
||||||
|
}
|
||||||
if (!card || !isOperationalRail(card)) return false;
|
if (!card || !isOperationalRail(card)) return false;
|
||||||
return spaceOn(card) >= count;
|
return spaceOn(card) >= count;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
seed?: number | null;
|
||||||
|
};
|
||||||
|
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 seed = typeof body.seed === 'number' && Number.isFinite(body.seed) ? Math.trunc(body.seed) : null;
|
||||||
|
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players, seed);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// The host's seed if they named one; otherwise a fresh random deal.
|
||||||
|
const session = createSession(
|
||||||
|
lobby.seed ?? 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* 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 { tryResumeSession } 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));
|
||||||
|
if (loaded.found) {
|
||||||
|
const resumed = tryResumeSession(loaded.saved);
|
||||||
|
if (resumed.ok) {
|
||||||
|
initialGames.set(entry.gameId, resumed.session);
|
||||||
|
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
||||||
|
} else {
|
||||||
|
/**
|
||||||
|
* The save does not replay under these rules, which is the only thing that has ever actually
|
||||||
|
* mattered — and now the only thing asked. Says which move it choked on, because "some
|
||||||
|
* version differs" was never enough to act on: the file is left untouched, so an operator who
|
||||||
|
* wants the game back can put the previous version on and finish it.
|
||||||
|
*/
|
||||||
|
const f = resumed.failure;
|
||||||
|
console.error(
|
||||||
|
`Refusing to resume ${entry.gameId} (${entry.gameCode}): move ${f.stoppedAt + 1} of ${f.of} ` +
|
||||||
|
`(${f.intent}) is rejected by the current rules with ${f.code}. Saved under engine ` +
|
||||||
|
`version ${loaded.storedVersion}, this server is running ${engineVersion}. The file is ` +
|
||||||
|
`left untouched.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `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.');
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/**
|
||||||
|
* The seed the host asked for, or null for one picked at `Lobby.Start`. Chosen here rather than
|
||||||
|
* at start because the same seed and the same settings deal the same railroad — which is only
|
||||||
|
* useful if the person setting the game up can name it.
|
||||||
|
*/
|
||||||
|
seed: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
seed: number | null = null,
|
||||||
|
): 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(),
|
||||||
|
seed,
|
||||||
|
};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
/** The version that wrote the file, for diagnostics — it is no longer what decides. */
|
||||||
|
| { found: true; saved: SavedGame; storedVersion: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* READS THE SAVE. DOES NOT JUDGE IT.
|
||||||
|
*
|
||||||
|
* This used to refuse any save whose `engineVersion` was not an exact match for the running one,
|
||||||
|
* on the reasoning that a move legal under old rules may not be legal under new ones (D7). The
|
||||||
|
* reasoning is sound and the test was not: the stamp is the PACKAGE version, which moves for
|
||||||
|
* reasons that have nothing to do with the rules, so four consecutive releases destroyed every
|
||||||
|
* game in progress — one of them a release that changed only how the board is drawn.
|
||||||
|
*
|
||||||
|
* Whether a save still replays is a question with an exact answer, so it is now asked directly:
|
||||||
|
* `tryResumeSession` replays the intents and reports the first one the engine refuses, if any.
|
||||||
|
* The version is kept and reported because it is useful in a failure, but it decides nothing.
|
||||||
|
*/
|
||||||
|
export async function loadGame(dataDir: 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;
|
||||||
|
const { engineVersion, ...saved } = payload;
|
||||||
|
return { found: true, saved, storedVersion: engineVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* 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, seatLabel } 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 ${seatLabel(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.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* A resume that could not complete, and exactly where it gave up. `index.ts` turns this into the
|
||||||
|
* refusal it logs, so the operator is told which move the current rules will not accept rather
|
||||||
|
* than only that some version string differs.
|
||||||
|
*/
|
||||||
|
export type ResumeFailure = { stoppedAt: number; of: number; intent: string; code: string };
|
||||||
|
|
||||||
|
export function tryResumeSession(saved: SavedGame): { ok: true; session: GameSession } | { ok: false; failure: ResumeFailure } {
|
||||||
|
const { game, stopped } = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
|
||||||
|
if (stopped) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
failure: { stoppedAt: stopped.index, of: saved.history.length, intent: stopped.intent.type, code: stopped.code },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, session: build(game, saved) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws on a save the current rules will not replay. Kept for callers that have already
|
||||||
|
* established the save is good — the server boots through `tryResumeSession`, which answers
|
||||||
|
* instead of throwing.
|
||||||
|
*/
|
||||||
|
export function resumeSession(saved: SavedGame): GameSession {
|
||||||
|
const r = tryResumeSession(saved);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`save does not replay under the current rules: intent ${r.failure.stoppedAt + 1} of ` +
|
||||||
|
`${r.failure.of} (${r.failure.intent}) was rejected with ${r.failure.code}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return r.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(game: Game, saved: SavedGame): GameSession {
|
||||||
|
return buildSession(
|
||||||
|
game,
|
||||||
|
saved.playerNames,
|
||||||
|
saved.createdAt,
|
||||||
|
new Set(saved.botSeats),
|
||||||
|
saved.lastMoveAt ?? saved.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
+150
-16
@@ -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
|
* 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.
|
* 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.
|
* THE WHOLE DIVISION, west to east, as one continuous route.
|
||||||
*
|
*
|
||||||
@@ -97,6 +113,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
tip: string;
|
tip: string;
|
||||||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||||||
seat: number | null;
|
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. */
|
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||||
regions: number;
|
regions: number;
|
||||||
w: number;
|
w: number;
|
||||||
@@ -116,12 +134,34 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
if (n.kind === 'office') {
|
if (n.kind === 'office') {
|
||||||
const cap = n.capacity;
|
const cap = n.capacity;
|
||||||
const ad = n.trains.flat();
|
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 ?? []) {
|
for (const rc of n.running ?? []) {
|
||||||
const isOffice = rc.kind === 'office';
|
const isOffice = rc.kind === 'office';
|
||||||
|
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
|
||||||
push({
|
push({
|
||||||
kind: 'run',
|
kind: 'run',
|
||||||
label: rc.label,
|
label: isOffice && owner ? owner.name : rc.label,
|
||||||
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
|
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
|
* 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
|
* 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, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
|
||||||
: rc.trains,
|
: rc.trains,
|
||||||
cap: isOffice ? cap : null,
|
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,
|
seat: n.seat ?? null,
|
||||||
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
// 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.
|
// occupies a card outright rather than a part of one.
|
||||||
@@ -279,7 +324,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
const full = c.cap !== null && c.trains.length >= c.cap;
|
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 += `<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 += `<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);
|
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>`;
|
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
|
||||||
|
|
||||||
@@ -474,6 +527,65 @@ export function officeSvg(
|
|||||||
return out;
|
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 —
|
// 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.
|
// 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 } => {
|
const port = (p: string): { x: number; y: number } => {
|
||||||
@@ -534,25 +646,30 @@ export function officeSvg(
|
|||||||
out += rail(port(from).x, port(from).y, port(to).x, port(to).y);
|
out += rail(port(from).x, port(from).y, port(to).x, port(to).y);
|
||||||
} else {
|
} else {
|
||||||
/**
|
/**
|
||||||
* A 45° LEG, drawn as the card prints it: along the centre line from the east or west edge
|
* A 45° LEG — drawn as a smooth curve (v0.5.0) that still passes through the same FROG the
|
||||||
* to the FROG, then out at exactly 45° through the middle of the north or south edge.
|
* 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
|
* 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)
|
* travels half its height sideways. The control points sit halfway from each endpoint to the
|
||||||
* — below the rail on the assumption everything diverged downward — which drew a leg
|
* frog, which is what gives the curve a horizontal tangent at the e/w edge (matching an
|
||||||
* reaching north as a hook that dropped past the rail and came back up, and drew nothing at
|
* abutting straight card's through-rail) and an exact 45° tangent at the n/s edge (matching
|
||||||
* any angle the matching rule cares about.
|
* the card above or below) — see `curvedRail`.
|
||||||
*/
|
*/
|
||||||
const side = vertical === from ? to : from;
|
const side = vertical === from ? to : from;
|
||||||
const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL };
|
const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL };
|
||||||
|
const start = port(side);
|
||||||
const edge = port(vertical);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||||||
|
|
||||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||||
|
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.col},${cell.row}</text>`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE ROSTER, one chip per A/D track — not one per train.
|
* THE ROSTER, one chip per A/D track — not one per train.
|
||||||
@@ -567,13 +684,21 @@ export function officeSvg(
|
|||||||
* Selected train's chip is lit the same amber the crew strip and action buttons already use,
|
* Selected train's chip is lit the same amber the crew strip and action buttons already use,
|
||||||
* and it is the one whose consist is drawn at the rail below. Every occupied chip carries
|
* and it is the one whose consist is drawn at the rail below. Every occupied chip carries
|
||||||
* `data-crew`, so the play page can wire a click to switch which train that is.
|
* `data-crew`, so the play page can wire a click to switch which train that is.
|
||||||
|
*
|
||||||
|
* COMPUTED FOR EVERY CARD, NOT JUST THE OFFICE. This used to sit inside the `adTracks !== null`
|
||||||
|
* branch below, so it was only ever assigned on the Office card — a train standing anywhere else
|
||||||
|
* in the district (which is everywhere it stands while actually being switched) got no chip at
|
||||||
|
* all: no label, no engine arrow, and no cars drawn, though the game state never lost it. REPORTED:
|
||||||
|
* dropping a car at the Freight House made "the train vanish off the face of the earth" — it had
|
||||||
|
* simply left the one square this code drew a crew on. The Office is the only square that can hold
|
||||||
|
* more than one train, so `cell.trains[0]` is exactly right everywhere else too.
|
||||||
*/
|
*/
|
||||||
let selectedTrain: CellView['trains'][number] | null = null;
|
const selectedTrain: CellView['trains'][number] | null =
|
||||||
|
cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
|
||||||
if (cell.adTracks !== null) {
|
if (cell.adTracks !== null) {
|
||||||
const adTracks = cell.adTracks;
|
const adTracks = cell.adTracks;
|
||||||
const gaps = Math.max(0, adTracks - 1);
|
const gaps = Math.max(0, adTracks - 1);
|
||||||
const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks);
|
const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks);
|
||||||
selectedTrain = cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
|
|
||||||
for (let i = 0; i < adTracks; i++) {
|
for (let i = 0; i < adTracks; i++) {
|
||||||
const train = cell.trains[i] ?? null;
|
const train = cell.trains[i] ?? null;
|
||||||
const cx = 6 + i * (chipW + 4);
|
const cx = 6 + i * (chipW + 4);
|
||||||
@@ -872,7 +997,7 @@ export function officeSvg(
|
|||||||
out += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` +
|
out += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` +
|
||||||
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
||||||
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
||||||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.row},${g.col}</text>` +
|
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.col},${g.row}</text>` +
|
||||||
`</g>`;
|
`</g>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,6 +1143,15 @@ export const BOARD_CSS = `
|
|||||||
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||||
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
||||||
.bs-name{fill:#e6e9ee;font:600 11px 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.
|
||||||
|
|
||||||
|
NO WEIGHT BUMP. This was 700 and the name came out fuzzy to the point of being unreadable: the
|
||||||
|
base is already 600, so at 11px a monospace face has to be synthesised the rest of the way, and
|
||||||
|
the extra ink lands as blur rather than as weight. Amber against #e6e9ee is the distinction; it
|
||||||
|
does not need help. */
|
||||||
|
.bs-name.bs-turn{fill:#f0b64a}
|
||||||
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
|
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
|
||||||
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
|
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
|
||||||
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
||||||
|
|||||||
+23
-11
@@ -31,6 +31,12 @@
|
|||||||
|
|
||||||
import { pump } from '../engine/advance.ts';
|
import { pump } from '../engine/advance.ts';
|
||||||
import { createGame } from '../engine/setup.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 { GameLength } from '../engine/content.ts';
|
||||||
import type { GameConfig, GameMode } from '../engine/state.ts';
|
import type { GameConfig, GameMode } from '../engine/state.ts';
|
||||||
import type { BotPolicy, BotTweaks } from './bot.ts';
|
import type { BotPolicy, BotTweaks } from './bot.ts';
|
||||||
@@ -56,17 +62,23 @@ export type PairedResult = {
|
|||||||
variantStats: GameStats[];
|
variantStats: GameStats[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOLO = (length: GameLength, mode: GameMode): GameConfig => ({
|
// Always one bot (`runOne` below), so the revenue floor is `collectiveRevenueFloor(1, days)`.
|
||||||
mode,
|
const SOLO = (length: GameLength, mode: GameMode): GameConfig => {
|
||||||
victory: 'highestAfterDays',
|
const days = lengthProfile(length).days;
|
||||||
length,
|
return {
|
||||||
optionalRules: {
|
mode,
|
||||||
reducedVisibility: false,
|
days,
|
||||||
sisterTrains: false,
|
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||||
employeeRotation: false,
|
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||||
emergencyToolbox: false,
|
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||||
},
|
pvpCardsAllowed: false,
|
||||||
});
|
optionalRules: {
|
||||||
|
reducedVisibility: false,
|
||||||
|
employeeRotation: false,
|
||||||
|
emergencyToolbox: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One game, one seed, one policy.
|
* One game, one seed, one policy.
|
||||||
|
|||||||
@@ -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
-7
@@ -12,7 +12,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { pump } from '../engine/advance.ts';
|
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 { createGame } from '../engine/setup.ts';
|
||||||
import type { GameLength } from '../engine/content.ts';
|
import type { GameLength } from '../engine/content.ts';
|
||||||
import type { GameConfig, GameMode } from '../engine/state.ts';
|
import type { GameConfig, GameMode } from '../engine/state.ts';
|
||||||
@@ -60,14 +65,17 @@ 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 {
|
return {
|
||||||
mode,
|
mode,
|
||||||
victory: 'highestAfterDays',
|
days,
|
||||||
length,
|
minCombinedRevenue: collectiveRevenueFloor(players, days),
|
||||||
|
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||||
|
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -83,7 +91,7 @@ export function simulate(opts: SimOptions): SimReport {
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `sim-${i}`,
|
id: `sim-${i}`,
|
||||||
seed,
|
seed,
|
||||||
config: configFor(opts.mode, opts.length),
|
config: configFor(opts.mode, opts.length, opts.players.length),
|
||||||
playerNames: opts.players,
|
playerNames: opts.players,
|
||||||
});
|
});
|
||||||
// The probe watches the game as it is played: the funnel gates are conditions at a moment, not
|
// The probe watches the game as it is played: the funnel gates are conditions at a moment, not
|
||||||
@@ -154,7 +162,7 @@ export function formatReport(r: SimReport, length: GameLength, players: number):
|
|||||||
|
|
||||||
// The provisional numbers this exists to test.
|
// The provisional numbers this exists to test.
|
||||||
out.push('\n against the provisional targets:');
|
out.push('\n against the provisional targets:');
|
||||||
out.push(` target ${profile.target} over ${profile.days} Days`);
|
out.push(` ${profile.days} Days`);
|
||||||
out.push(
|
out.push(
|
||||||
` predicted ~5-6 revenue/player/Day steady state; observed mean ` +
|
` predicted ~5-6 revenue/player/Day steady state; observed mean ` +
|
||||||
`${r.revenuePerPlayerPerDay.mean.toFixed(1)}`,
|
`${r.revenuePerPlayerPerDay.mean.toFixed(1)}`,
|
||||||
|
|||||||
+38
-30
@@ -94,6 +94,11 @@ export type NarrateContext = {
|
|||||||
* game, phrased in internal identifiers.
|
* game, phrased in internal identifiers.
|
||||||
*/
|
*/
|
||||||
trainName?: (trayId: TrayId) => string;
|
trainName?: (trayId: TrayId) => string;
|
||||||
|
/**
|
||||||
|
* Resolves a player index to their display name. Optional like the rest: an engine test narrating
|
||||||
|
* events has no roster, and "Player 2" is a truthful fallback rather than a broken one.
|
||||||
|
*/
|
||||||
|
playerName?: (player: PlayerIndex) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||||
@@ -104,6 +109,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
// -- clock
|
// -- clock
|
||||||
case 'stageBegan':
|
case 'stageBegan':
|
||||||
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
|
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
|
||||||
|
case 'seatsRotated':
|
||||||
|
// Named players rather than seat numbers: the rule is that everyone MOVED, and a list of
|
||||||
|
// indices does not say who is now next to whom.
|
||||||
|
return {
|
||||||
|
tone: 'clock',
|
||||||
|
text: `Employee Rotation — everyone moves one chair left. West to East: ${e.seating
|
||||||
|
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
|
||||||
|
.join(' → ')}`,
|
||||||
|
};
|
||||||
case 'phaseBegan':
|
case 'phaseBegan':
|
||||||
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
||||||
// the log read as one undifferentiated column and you could not see where a phase began.
|
// the log read as one undifferentiated column and you could not see where a phase began.
|
||||||
@@ -280,25 +294,25 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
};
|
};
|
||||||
case 'trainArrived':
|
case 'trainArrived':
|
||||||
/**
|
/**
|
||||||
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND — AND IS NOT GONE YET EITHER.
|
* AN EXPEDITED ARRIVAL IS AN ORDINARY ARRIVAL, WITH ONE STANDING OBLIGATION.
|
||||||
*
|
*
|
||||||
* This said "it will highball again next Mainline Phase" to every arrival, which is backwards
|
* This used to say the train stands through Cargo and is forced out at the end of the Stage —
|
||||||
* for an Expedited train. Corrected once to "it leaves again this same Mainline Phase", which
|
* true once, and wrong: Q3 means the train must not be PARKED anywhere but the station, not
|
||||||
* was ALSO wrong and in a way that cost the player work: the expedited departure was moved to
|
* that it is rushed out early. It is switched, worked and released exactly like any other
|
||||||
* Supervisor Shift precisely so the Porters and Laborers get their Stage with the train, so
|
* arrival; the only difference is what happens if it is left on Secondary Track when the next
|
||||||
* Cargo IS available and only Local Operations is not. Telling a player there is "no turn in
|
* Mainline Phase begins (`expediteFault`).
|
||||||
* which to work it" talked them out of the one turn they had.
|
|
||||||
*
|
|
||||||
* So both branches now name the phases: what you can still do with this train, and when it
|
|
||||||
* goes. That is the whole of the reported confusion — "some trains seem to be moving before I
|
|
||||||
* can switch or do other operations on them".
|
|
||||||
*/
|
*/
|
||||||
return {
|
return {
|
||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
text: e.expedited
|
text: e.expedited
|
||||||
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it stands through the Cargo phase and departs at the END OF THIS STAGE. Porters and Laborers can work it now; it is normally gone before your next Local Operations turn, so it cannot be switched — unless §8.1 holds it for a ruling, in which case it stays and runs like any other train.`
|
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so keep it on the Office square: parked anywhere else in the district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
|
||||||
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
|
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
|
||||||
};
|
};
|
||||||
|
case 'expediteFault':
|
||||||
|
return {
|
||||||
|
tone: 'bad',
|
||||||
|
text: `Train ${e.trainNumber} was left at ${e.where}, off the station, when the Mainline Phase began — an expedited train must be kept ready to highball. Station Master fault.`,
|
||||||
|
};
|
||||||
case 'trainDiverted':
|
case 'trainDiverted':
|
||||||
return {
|
return {
|
||||||
tone: 'good',
|
tone: 'good',
|
||||||
@@ -582,7 +596,7 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
|||||||
// the board says it on the card instead.
|
// the board says it on the card instead.
|
||||||
if (b.kind === 'noStopping') continue;
|
if (b.kind === 'noStopping') continue;
|
||||||
out.push({
|
out.push({
|
||||||
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.row},${b.coord.col})`,
|
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.col},${b.coord.row})`,
|
||||||
why: b.why,
|
why: b.why,
|
||||||
// Not "stuck": these are the shape of the district and of the other trains in it, which is
|
// Not "stuck": these are the shape of the district and of the other trains in it, which is
|
||||||
// the puzzle rather than a fault. Amber, not red.
|
// the puzzle rather than a fault. Amber, not red.
|
||||||
@@ -602,12 +616,10 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
|||||||
* said so.
|
* said so.
|
||||||
*
|
*
|
||||||
* Reported again as "passenger trains arrive at my Office and move on before I can load or unload
|
* Reported again as "passenger trains arrive at my Office and move on before I can load or unload
|
||||||
* them", which turned out to be three separate silences. This block only looked at trains carrying
|
* them", which turned out to be two separate silences. This block only looked at trains carrying
|
||||||
* a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card
|
* a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card
|
||||||
* carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all
|
* carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all
|
||||||
* Porters used" — to a player who had used none. And an expedited train leaves at the end of the
|
* Porters used" — to a player who had used none.
|
||||||
* Stage it arrives, so its one Load/Unload phase is now-or-never and nothing said which trains
|
|
||||||
* those are.
|
|
||||||
*
|
*
|
||||||
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel
|
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel
|
||||||
* mentioned at all.
|
* mentioned at all.
|
||||||
@@ -620,8 +632,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
|||||||
.filter((t): t is NonNullable<typeof t> => !!t && t.consist.some((c) => c.type === 'coach'));
|
.filter((t): t is NonNullable<typeof t> => !!t && t.consist.some((c) => c.type === 'coach'));
|
||||||
|
|
||||||
if (coachTrains.length > 0) {
|
if (coachTrains.length > 0) {
|
||||||
const label = (t: (typeof coachTrains)[number]): string =>
|
|
||||||
t.trainNumber === null ? 'the local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`;
|
|
||||||
const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => {
|
const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => {
|
||||||
out.push({ where: 'Office — passengers', why, severity });
|
out.push({ where: 'Office — passengers', why, severity });
|
||||||
};
|
};
|
||||||
@@ -658,17 +668,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
|||||||
say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
|
say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOW OR NEVER. An expedited train leaves at the end of this Stage, so this is the only
|
|
||||||
// Load/Unload phase it will ever stand for — the exact thing the playtest report was about.
|
|
||||||
for (const t of coachTrains) {
|
|
||||||
if (!t.departsThisStage) continue;
|
|
||||||
say(
|
|
||||||
`${label(t)} is EXPEDITED — it leaves at the END of this Stage, so this is the only ` +
|
|
||||||
'Load/Unload phase it will stand for. Work it now or not at all.',
|
|
||||||
'risk',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,6 +747,15 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
|
|||||||
// A train running the whole length of the Division pays every player, and it is the one event
|
// A train running the whole length of the Division pays every player, and it is the one event
|
||||||
// nobody made happen on the turn it lands. It should be heard, not found in the log.
|
// nobody made happen on the turn it lands. It should be heard, not found in the log.
|
||||||
if (e.type === 'trainCompleted') out.push('completed');
|
if (e.type === 'trainCompleted') out.push('completed');
|
||||||
|
// A train pulling into an Office. `trainDiverted` (the Yard Office siding) is deliberately not
|
||||||
|
// included — that one goes straight to a work track without the A/D stop `arrive` depicts.
|
||||||
|
if (e.type === 'trainArrived') out.push('arrive');
|
||||||
|
// Only an OFFICE departure, not every highball: a fresh make-up leaving a Division Point already
|
||||||
|
// sounds `train` ("All aboard"), and a train clearing the whole Division already sounds
|
||||||
|
// `completed` — this is the one case neither of those covers.
|
||||||
|
if (e.type === 'trainHighballed' && e.from === 'the Office') out.push('depart');
|
||||||
|
// §10 — the one event nobody wants to hear and everybody needs to.
|
||||||
|
if (e.type === 'trainsDestroyed') out.push('crash');
|
||||||
if (e.type === 'stageBegan') {
|
if (e.type === 'stageBegan') {
|
||||||
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
||||||
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
|
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
|
||||||
|
|||||||
+20
-7
@@ -21,7 +21,14 @@ import { writeFileSync } from 'node:fs';
|
|||||||
import { advance } from '../engine/advance.ts';
|
import { advance } from '../engine/advance.ts';
|
||||||
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
||||||
import type { GameLength } from '../engine/content.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 { GameEvent } from '../engine/events.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import { legalActions } from '../engine/legal.ts';
|
import { legalActions } from '../engine/legal.ts';
|
||||||
@@ -51,13 +58,16 @@ import { playCue } from '../web/sound.ts';
|
|||||||
export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };
|
export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };
|
||||||
|
|
||||||
export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
|
export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
|
||||||
|
const days = lengthProfile(length).days;
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days,
|
||||||
length,
|
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||||
|
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||||
|
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -263,7 +273,7 @@ export function rehydrateCells(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderHtml(rec: Recording): string {
|
export function renderHtml(rec: Recording): string {
|
||||||
const target = lengthProfile(rec.length);
|
const profile = lengthProfile(rec.length);
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8">
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -360,7 +370,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
|||||||
</style></head><body>
|
</style></head><body>
|
||||||
|
|
||||||
<header>
|
<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">
|
<div class="bar">
|
||||||
<span class="dim">fedora <span id="super">—</span></span>
|
<span class="dim">fedora <span id="super">—</span></span>
|
||||||
<span>revenue <b class="big" id="rev">0</b></span>
|
<span>revenue <b class="big" id="rev">0</b></span>
|
||||||
@@ -502,7 +512,10 @@ function render() {
|
|||||||
|
|
||||||
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
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.
|
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
|
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
|
||||||
|
|
||||||
|
|||||||
+103
-27
@@ -24,6 +24,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ACTION_CARDS,
|
ACTION_CARDS,
|
||||||
ENHANCEMENT_CARDS,
|
ENHANCEMENT_CARDS,
|
||||||
|
HAND_LIMIT,
|
||||||
MAINLINE_MODIFIER_CARDS,
|
MAINLINE_MODIFIER_CARDS,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
MANEUVER_CARDS,
|
MANEUVER_CARDS,
|
||||||
@@ -37,7 +38,6 @@ import {
|
|||||||
industryProfile,
|
industryProfile,
|
||||||
mainlineProfile,
|
mainlineProfile,
|
||||||
modifierProfile,
|
modifierProfile,
|
||||||
lengthProfile,
|
|
||||||
officeProfile,
|
officeProfile,
|
||||||
trainProfile,
|
trainProfile,
|
||||||
houseRules,
|
houseRules,
|
||||||
@@ -275,6 +275,22 @@ export type RunningCardView = {
|
|||||||
trains: TrainChip[];
|
trains: TrainChip[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A seat as a PERSON counts them, from 1.
|
||||||
|
*
|
||||||
|
* Seats are zero-based everywhere inside — `PlayerIndex`, `seating`, the seats array, every route
|
||||||
|
* — and that must not change, since it is what indexes into all of them. But nobody sitting down
|
||||||
|
* at a table calls their chair "seat 0", so the number on screen is the one they would say out
|
||||||
|
* loud. Every user-facing seat goes through here, so the two conventions cannot drift apart.
|
||||||
|
*
|
||||||
|
* It lives here rather than in `web/game.ts` because the page may not import values from that
|
||||||
|
* module — they are the local engine by another name, and `test/session.test.ts` fails the build
|
||||||
|
* for it. This is presentation, which is what `view.ts` is for.
|
||||||
|
*/
|
||||||
|
export function seatLabel(seat: number): number {
|
||||||
|
return seat + 1;
|
||||||
|
}
|
||||||
|
|
||||||
export type DivisionView = {
|
export type DivisionView = {
|
||||||
kind: string;
|
kind: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -348,6 +364,18 @@ export type Frame = {
|
|||||||
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
|
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
|
||||||
*/
|
*/
|
||||||
houseRules: HouseRules;
|
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'];
|
status: GameState['status'];
|
||||||
outcome: GameState['outcome'];
|
outcome: GameState['outcome'];
|
||||||
/**
|
/**
|
||||||
@@ -358,8 +386,32 @@ export type Frame = {
|
|||||||
* player order once §4.4's D12 decided who sits where.
|
* player order once §4.4's D12 decided who sits where.
|
||||||
*/
|
*/
|
||||||
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
|
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`. */
|
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
|
||||||
handCount: number;
|
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 }[];
|
lines: { text: string; tone: string }[];
|
||||||
where: { row: number; col: number } | null;
|
where: { row: number; col: number } | null;
|
||||||
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
|
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
|
||||||
@@ -678,7 +730,8 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
|||||||
|
|
||||||
/** One readable line for a single intent. */
|
/** One readable line for a single intent. */
|
||||||
export function describeIntent(s: GameState, i: Intent): string {
|
export function describeIntent(s: GameState, i: Intent): string {
|
||||||
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
|
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||||
|
const at = (c: { row: number; col: number }): string => `(${c.col},${c.row})`;
|
||||||
// An intent belongs to whoever is acting, so it is described against THEIR district.
|
// An intent belongs to whoever is acting, so it is described against THEIR district.
|
||||||
// The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of
|
// The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of
|
||||||
// whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the
|
// whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the
|
||||||
@@ -1206,6 +1259,12 @@ export function snapshot(
|
|||||||
wasted,
|
wasted,
|
||||||
option: turnOf(s, viewer).option,
|
option: turnOf(s, viewer).option,
|
||||||
houseRules: houseRules(s.config),
|
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,
|
status: s.status,
|
||||||
outcome: s.outcome,
|
outcome: s.outcome,
|
||||||
players: s.players.map((p) => ({
|
players: s.players.map((p) => ({
|
||||||
@@ -1215,7 +1274,15 @@ export function snapshot(
|
|||||||
revenue: p.revenue,
|
revenue: p.revenue,
|
||||||
hand: (s.decks.hands.get(p.index) ?? []).length,
|
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,
|
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),
|
objective: objectiveOf(s, viewer),
|
||||||
runningRow: area.runningRow,
|
runningRow: area.runningRow,
|
||||||
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
|
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
|
||||||
@@ -1229,7 +1296,7 @@ export function snapshot(
|
|||||||
? `${t.position.side} Division Point`
|
? `${t.position.side} Division Point`
|
||||||
: t.position.at === 'mainline'
|
: t.position.at === 'mainline'
|
||||||
? `Mainline card ${t.position.index}`
|
? `Mainline card ${t.position.index}`
|
||||||
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
|
: `Office Area (${t.position.coord.col},${t.position.coord.row})`,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1424,7 +1491,9 @@ export function trainRules(t: {
|
|||||||
* DOES rather than that it exists, because the restriction is the whole character of the card: a
|
* DOES rather than that it exists, because the restriction is the whole character of the card: a
|
||||||
* Military train that cannot be worked by Porters plays nothing like a Local.
|
* Military train that cannot be worked by Porters plays nothing like a Local.
|
||||||
*/
|
*/
|
||||||
if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt');
|
if (p.rules.noSwitching) {
|
||||||
|
parts.push('NO SWITCHING — may not add or drop cars, but may still be moved clear of the mainline');
|
||||||
|
}
|
||||||
if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else');
|
if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else');
|
||||||
if (p.rules.coachStaysOnStationTrack) {
|
if (p.rules.coachStaysOnStationTrack) {
|
||||||
// Said "may only be set out at the Office", which reads as a place you can do it. You cannot:
|
// Said "may only be set out at the Office", which reads as a place you can do it. You cannot:
|
||||||
@@ -1446,15 +1515,12 @@ export function trainRules(t: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (p.rules.expedite) {
|
if (p.rules.expedite) {
|
||||||
// The single most-asked question in play — "why did that train leave before I could switch it?"
|
// It is released and switched exactly like any other train — the restriction is on where it may
|
||||||
// — so it names the phases rather than the Stage. It stands through Cargo and goes at the end of
|
// be LEFT, not on when it leaves.
|
||||||
// the Stage, which is after the Porters and Laborers but before the next Local Operations.
|
|
||||||
parts.push(
|
parts.push(
|
||||||
'EXPEDITED — it does not lay over. It stands through the Cargo phase and departs at the END OF ' +
|
'EXPEDITED — must be kept ready to highball. It works and switches normally, but if it is not ' +
|
||||||
'THE STAGE IT ARRIVES IN, so Porters and Laborers can work it but it is normally gone before ' +
|
'back on the Office square when the next Mainline Phase begins, that is a Station Master ' +
|
||||||
'your next Local Operations turn and cannot be switched. UNLESS §8.1 wants a clearance ruling ' +
|
'fault and costs 1 Revenue.',
|
||||||
'at that moment: there is no Superintendent in Supervisor Shift to give one, so the train ' +
|
|
||||||
'stays and runs like any other from then on.',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once');
|
if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once');
|
||||||
@@ -1633,21 +1699,36 @@ const SIMPLE_CARDS = [
|
|||||||
...ACTION_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'] {
|
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 revenue = s.players[viewer]?.revenue ?? 0;
|
||||||
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
|
const daysLeft = Math.max(0, days - s.clock.day + 1);
|
||||||
const elapsed = profile.days - daysLeft + 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.
|
// 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 onPace = revenue >= expected;
|
||||||
const note =
|
const note =
|
||||||
daysLeft === 0
|
daysLeft === 0
|
||||||
? 'the last Day is over'
|
? '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)`);
|
(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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1736,14 +1817,9 @@ function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
|
|||||||
const out: Frame['moves'] = [];
|
const out: Frame['moves'] = [];
|
||||||
for (const [id, tray] of s.trays) {
|
for (const [id, tray] of s.trays) {
|
||||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||||
/**
|
// A no-switching train may still be moved clear of the mainline (§7) — only coupling, setting
|
||||||
* A crew that may not switch is not a crew you can be switching.
|
// out and sorting are refused, and `check` rejects those the same way it rejects a pick-up X13
|
||||||
*
|
// (dropOnly) is not allowed to make, so this row is not filtered any differently for either.
|
||||||
* `movesFor` is pure track geometry — it answers where a train COULD roll — and six cards print
|
|
||||||
* "no switching", which `check` enforces and it does not. Offering the Circus Train in a "which
|
|
||||||
* train are you switching?" row is offering a train whose every move the rules refuse.
|
|
||||||
*/
|
|
||||||
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.noSwitching) continue;
|
|
||||||
const { to, blocked } = movesFor(s, player, id);
|
const { to, blocked } = movesFor(s, player, id);
|
||||||
out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked });
|
out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked });
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-8
@@ -44,9 +44,13 @@ import {
|
|||||||
variantLabel,
|
variantLabel,
|
||||||
} from '../sim/view.ts';
|
} from '../sim/view.ts';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_DAYS,
|
||||||
DEFAULT_HOUSE_RULES,
|
DEFAULT_HOUSE_RULES,
|
||||||
|
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||||
|
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||||
HAND_LIMIT,
|
HAND_LIMIT,
|
||||||
LEGACY_HOUSE_RULES,
|
LEGACY_HOUSE_RULES,
|
||||||
|
collectiveRevenueFloor,
|
||||||
houseRules,
|
houseRules,
|
||||||
mainlineProfile,
|
mainlineProfile,
|
||||||
trainProfile,
|
trainProfile,
|
||||||
@@ -57,13 +61,21 @@ import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.t
|
|||||||
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
||||||
import type { Frame } from '../sim/view.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 = {
|
export const SOLO_CONFIG: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: DEFAULT_DAYS,
|
||||||
length: 'standard',
|
minCombinedRevenue: collectiveRevenueFloor(1, DEFAULT_DAYS),
|
||||||
|
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||||
|
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -72,9 +84,55 @@ export const SOLO_CONFIG: GameConfig = {
|
|||||||
houseRules: DEFAULT_HOUSE_RULES,
|
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,
|
||||||
|
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. */
|
/** The same config with the New Game dialog's answers in it. */
|
||||||
export function configWith(rules: HouseRuleOverrides): GameConfig {
|
export function configWith(opts: NewGameOptions): GameConfig {
|
||||||
return { ...SOLO_CONFIG, houseRules: houseRules({ houseRules: rules }) };
|
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. */
|
/** A group of legal actions of one kind, ready to put on screen. */
|
||||||
@@ -237,6 +295,23 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
|||||||
return 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.
|
* Run the engine forward until it needs a decision.
|
||||||
*
|
*
|
||||||
@@ -344,7 +419,7 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
|||||||
const tray = game.state.trays.get(trayId);
|
const tray = game.state.trays.get(trayId);
|
||||||
const where =
|
const where =
|
||||||
tray?.position.at === 'grid'
|
tray?.position.at === 'grid'
|
||||||
? `, standing at (${tray.position.coord.row}, ${tray.position.coord.col})`
|
? `, standing at (${tray.position.coord.col}, ${tray.position.coord.row})`
|
||||||
: '';
|
: '';
|
||||||
groups.push({
|
groups.push({
|
||||||
kind: prefix,
|
kind: prefix,
|
||||||
@@ -530,7 +605,16 @@ export type Menu = {
|
|||||||
|
|
||||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
/** 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 {
|
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 direct: ActionGroup[] = [];
|
||||||
const placeableByTitle = new Map<string, Map<string, Placeable>>();
|
const placeableByTitle = new Map<string, Map<string, Placeable>>();
|
||||||
|
|
||||||
@@ -670,7 +754,8 @@ function subjectOf(
|
|||||||
node?: number;
|
node?: number;
|
||||||
links: string[];
|
links: string[];
|
||||||
} | null {
|
} | null {
|
||||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||||
|
const at = (c: { row: number; col: number }): string => `(${c.col}, ${c.row})`;
|
||||||
/**
|
/**
|
||||||
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
|
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
|
||||||
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
|
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
|
||||||
@@ -1078,3 +1163,58 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
|||||||
}
|
}
|
||||||
return 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.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Why the intent a replay stopped at is reported rather than swallowed.
|
||||||
|
*
|
||||||
|
* The loop below has always stopped at the first intent the engine will not accept, and used to do
|
||||||
|
* it in silence — which is the one outcome nobody can afford to guess at, because the result is a
|
||||||
|
* game that looks fine and is short of where it should be. That silence was survivable only
|
||||||
|
* because `loadGame` refused any save whose engine version was not an exact match, so a replay
|
||||||
|
* that could fail was never attempted. Refusing on the version is a proxy question, though, and it
|
||||||
|
* answered "no" for four releases running that changed no rules at all — so the real question gets
|
||||||
|
* asked instead, and its answer has to be legible.
|
||||||
|
*/
|
||||||
|
export type ReplayStop = { index: number; intent: Intent; code: string };
|
||||||
|
|
||||||
|
export function fromMultiplayerSave(
|
||||||
|
seed: number,
|
||||||
|
config: GameConfig,
|
||||||
|
playerNames: string[],
|
||||||
|
history: Intent[],
|
||||||
|
): { game: Game; stopped: ReplayStop | null } {
|
||||||
|
const game = newMultiplayerGame(seed, config, playerNames);
|
||||||
|
for (const [index, intent] of history.entries()) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) {
|
||||||
|
return { game, stopped: { index, intent, code: 'NO_ACTOR' } };
|
||||||
|
}
|
||||||
|
const result = applyIntent(game.state, actor, intent);
|
||||||
|
if (!result.ok) {
|
||||||
|
return { game, stopped: { index, intent, code: result.code } };
|
||||||
|
}
|
||||||
|
game.history.push(intent);
|
||||||
|
record(game, result.events, actor);
|
||||||
|
drain(game);
|
||||||
|
}
|
||||||
|
return { game, stopped: null };
|
||||||
|
}
|
||||||
|
|||||||
+55
-21
@@ -11,18 +11,30 @@ body{margin:0;background:var(--bg);color:var(--fg);
|
|||||||
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||||
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
|
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
|
||||||
main{width:100%;max-width:760px}
|
main{width:100%;max-width:760px}
|
||||||
|
.hero{display:flex;gap:24px;align-items:flex-start}
|
||||||
|
.hero-image{width:230px;flex:none;border-radius:9px;overflow:hidden;border:1px solid var(--line);cursor:zoom-in}
|
||||||
|
.hero-image img{display:block;width:100%;height:auto}
|
||||||
|
.hero-content{flex:1;min-width:0}
|
||||||
|
@media(max-width:640px){.hero{flex-direction:column}.hero-image{width:100%;max-width:280px}}
|
||||||
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
|
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
|
||||||
.tag{color:var(--dim);margin:0;font-size:14px}
|
.tag{color:var(--dim);margin:0;font-size:14px}
|
||||||
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch}
|
.blurb{color:#c6ccd6;margin:16px 0 0;max-width:66ch}
|
||||||
.rule{height:1px;background:var(--line);margin:22px 0}
|
.rule{height:1px;background:var(--line);margin:22px 0}
|
||||||
.doors{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
.doors{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-top:26px}
|
||||||
@media(max-width:640px){.doors{grid-template-columns:1fr}}
|
@media(max-width:640px){.doors{grid-template-columns:1fr}}
|
||||||
a.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
|
.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
|
||||||
border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s}
|
border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s}
|
||||||
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
|
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
|
||||||
a.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
||||||
a.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
||||||
a.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
|
.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}
|
||||||
|
.lightbox img{display:block;max-width:90vw;max-height:90vh;border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,.5)}
|
||||||
/* the rule between title and blurb is drawn as rail, not decorated */
|
/* the rule between title and blurb is drawn as rail, not decorated */
|
||||||
svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0}
|
svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0}
|
||||||
.rail{stroke:#4a5361;stroke-width:1.6}
|
.rail{stroke:#4a5361;stroke-width:1.6}
|
||||||
@@ -32,24 +44,41 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Station Master</h1>
|
<div class="hero">
|
||||||
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
<div class="hero-image" id="hero-image" tabindex="0" role="button" aria-label="View full-size box art">
|
||||||
|
<img src="./images/station-master-splash.jpg"
|
||||||
|
alt="Station Master — TimeTable & Train Order Railroading, 1840–1950. Box art: two trains meet at a junction beside a train order office."
|
||||||
|
width="560" height="700">
|
||||||
|
</div>
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1>Station Master</h1>
|
||||||
|
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
||||||
|
|
||||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
||||||
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
|
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
|
||||||
<g id="ties"></g>
|
<g id="ties"></g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<p class="blurb">
|
<p class="blurb">
|
||||||
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
||||||
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
||||||
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
||||||
and get every train away again — a train with nowhere to stand is a collision, and a
|
and get every train away again — a train with nowhere to stand is a collision, and a
|
||||||
collision costs more than the freight was worth.
|
collision costs more than the freight was worth.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="doors">
|
<div class="doors">
|
||||||
|
<a class="door" id="door-multiplayer" href="./play.html?lobby">
|
||||||
|
<h2>Play multiplayer</h2>
|
||||||
|
<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 →</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a class="door" href="./play.html">
|
<a class="door" href="./play.html">
|
||||||
<h2>Play solitaire</h2>
|
<h2>Play solitaire</h2>
|
||||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||||
@@ -71,10 +100,15 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>build <span id="build">__BUILD__</span></span>
|
<span>build <span id="build">__BUILD__</span></span>
|
||||||
<span>runs entirely in your browser — no server code required</span>
|
<span>solitaire runs entirely in your browser — no server code required</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox" hidden>
|
||||||
|
<img src="./images/station-master-splash.jpg"
|
||||||
|
alt="Station Master — TimeTable & Train Order Railroading, 1840–1950. Box art: two trains meet at a junction beside a train order office.">
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="./web/splash.js"></script>
|
<script type="module" src="./web/splash.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
import { STARTING_HAND_LABELS, houseRules } from '../engine/content.ts';
|
||||||
|
import type { StartingHand } from '../engine/content.ts';
|
||||||
|
import { seatLabel } from '../sim/view.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 ${seatLabel(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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosenMode = (): 'competitive' | 'coop' =>
|
||||||
|
($('lb-choice-section').querySelector<HTMLInputElement>('input[name="lb-mode"]:checked')?.value ??
|
||||||
|
'competitive') as 'competitive' | 'coop';
|
||||||
|
const chosenPlayers = (): number => Number($<HTMLSelectElement>('lb-players').value) || 4;
|
||||||
|
const num = (id: string): number => Number($<HTMLInputElement>(id).value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode and table size only move the DEFAULTS, exactly as the solitaire dialog's own presets do —
|
||||||
|
* every field stays editable afterwards, so "Co-op, 3 players" is a starting point rather than a
|
||||||
|
* fixed ruleset. Re-running this on a change is also what keeps the combined-Revenue floor
|
||||||
|
* honest: it is derived from the player count, and the count is a control on this form.
|
||||||
|
*/
|
||||||
|
function applyDefaults(): void {
|
||||||
|
const c = defaultMultiplayerConfig(chosenMode(), chosenPlayers());
|
||||||
|
const rules = houseRules(c);
|
||||||
|
$<HTMLInputElement>('lb-days').value = String(c.days);
|
||||||
|
$<HTMLInputElement>('lb-minrev').value = String(c.minCombinedRevenue);
|
||||||
|
$<HTMLInputElement>('lb-colday').value = String(c.maxCollisionsPerDay);
|
||||||
|
$<HTMLInputElement>('lb-coltotal').value = String(c.maxCollisionsTotal);
|
||||||
|
$<HTMLInputElement>('lb-passenger').value = String(rules.revenue.passengerPerCoach);
|
||||||
|
$<HTMLInputElement>('lb-freight').value = String(rules.revenue.freightPerLoad);
|
||||||
|
$<HTMLInputElement>('lb-transit').value = String(rules.revenue.trainPerTransit);
|
||||||
|
// Co-op has no valid target for the opponent-directed cards, so the toggle is forced off rather
|
||||||
|
// than merely defaulted off — the same rule `buildDeck` applies.
|
||||||
|
const pvp = $<HTMLInputElement>('lb-pvp');
|
||||||
|
pvp.checked = c.pvpCardsAllowed;
|
||||||
|
pvp.disabled = chosenMode() !== 'competitive';
|
||||||
|
}
|
||||||
|
for (const input of Array.from(
|
||||||
|
$('lb-choice-section').querySelectorAll<HTMLInputElement>('input[name="lb-mode"]'),
|
||||||
|
)) {
|
||||||
|
input.onchange = applyDefaults;
|
||||||
|
}
|
||||||
|
$<HTMLSelectElement>('lb-players').onchange = applyDefaults;
|
||||||
|
applyDefaults();
|
||||||
|
|
||||||
|
$<HTMLButtonElement>('lb-create').onclick = () => {
|
||||||
|
const displayName = $<HTMLInputElement>('lb-name').value.trim();
|
||||||
|
const mode = chosenMode();
|
||||||
|
if (displayName === '') {
|
||||||
|
setError('lb-create-err', 'enter a display name first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const players = chosenPlayers();
|
||||||
|
const hand = $('lb-settings').querySelector<HTMLInputElement>('input[name="lb-hand"]:checked')?.value;
|
||||||
|
// Blank or unparseable both mean "surprise me", which is what leaving the box alone asks for.
|
||||||
|
const asked = $<HTMLInputElement>('lb-seed').value.trim();
|
||||||
|
const seed = asked === '' || !Number.isFinite(Number(asked)) ? null : Math.trunc(Number(asked));
|
||||||
|
|
||||||
|
const config: GameConfig = {
|
||||||
|
...defaultMultiplayerConfig(mode, players),
|
||||||
|
days: Math.max(1, Math.round(num('lb-days')) || 5),
|
||||||
|
minCombinedRevenue: Math.max(0, Math.round(num('lb-minrev')) || 0),
|
||||||
|
maxCollisionsPerDay: Math.max(0, Math.round(num('lb-colday')) || 0),
|
||||||
|
maxCollisionsTotal: Math.max(0, Math.round(num('lb-coltotal')) || 0),
|
||||||
|
pvpCardsAllowed: $<HTMLInputElement>('lb-pvp').checked && mode === 'competitive',
|
||||||
|
optionalRules: {
|
||||||
|
reducedVisibility: $<HTMLInputElement>('lb-visibility').checked,
|
||||||
|
employeeRotation: $<HTMLInputElement>('lb-rotation').checked,
|
||||||
|
emergencyToolbox: $<HTMLInputElement>('lb-toolbox').checked,
|
||||||
|
},
|
||||||
|
houseRules: {
|
||||||
|
...(STARTING_HAND_LABELS.some((o) => o.value === hand) ? { startingHand: hand as StartingHand } : {}),
|
||||||
|
revenue: {
|
||||||
|
passengerPerCoach: num('lb-passenger'),
|
||||||
|
freightPerLoad: num('lb-freight'),
|
||||||
|
trainPerTransit: num('lb-transit'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
void postJson('/api/lobby/create', { secret: secret(), config, displayName, players, seed }).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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
+431
-41
@@ -8,25 +8,103 @@
|
|||||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||||
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame } from '../sim/view.ts';
|
||||||
|
import { seatLabel } from '../sim/view.ts';
|
||||||
import type { Menu, Save } from './game.ts';
|
import type { Menu, Save } from './game.ts';
|
||||||
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||||
import { playCue } from './sound.ts';
|
import { playCue } from './sound.ts';
|
||||||
import { MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.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 { HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts';
|
||||||
import type { LocalSession } from './session.ts';
|
import type { NewGameOptions } from './game.ts';
|
||||||
import { createLocalSession } from './session.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 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.
|
* 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
|
||||||
* Typed as `LocalSession` because this page is the solitaire client and uses undo, local saves and
|
* in it, and in a multiplayer game two players may reasonably want these set differently. Grows as
|
||||||
* new-game — all of which are local-only. The parts that draw and submit go through the plain
|
* more of the page's display state earns a preference; `districtMode`/`soundOn`/`zoom` are the
|
||||||
* `Session` surface, which is what a remote client will provide unchanged.
|
* 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. */
|
/** Which card or track piece is picked, waiting for a location. */
|
||||||
let selected: string | null = null;
|
let selected: string | null = null;
|
||||||
/**
|
/**
|
||||||
@@ -49,18 +127,20 @@ let pendingAt: string | null = null;
|
|||||||
*
|
*
|
||||||
* 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until
|
* '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
|
* 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
|
* 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.
|
* rather than the finished thing. One click in the title bar turns it on, and that click is also
|
||||||
* One click in the title bar turns it on, and that click is also the gesture browsers require
|
* the gesture browsers require before any audio may start.
|
||||||
* 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.
|
* The phase the page last drew, so a change of phase can be announced.
|
||||||
*
|
*
|
||||||
@@ -99,6 +179,23 @@ const $ = (id: string): HTMLElement => {
|
|||||||
const esc = (s: string): string =>
|
const esc = (s: string): string =>
|
||||||
s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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.
|
* A DRAWING OF THE PIECE A PLACEMENT WOULD LAY.
|
||||||
*
|
*
|
||||||
@@ -153,19 +250,27 @@ 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
|
* 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
|
* 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
|
* different economy again — and now a game with `minrev=15` is a different game from one with
|
||||||
* true — and the New Game dialog navigates by URL, so this is also how its answers reach `start()`.
|
* `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
|
* 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`).
|
* 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;
|
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 rules: HouseRuleOverrides = {};
|
||||||
const hand = params.get('hand');
|
const hand = params.get('hand');
|
||||||
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
||||||
@@ -178,29 +283,134 @@ function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
|
|||||||
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw);
|
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw);
|
||||||
}
|
}
|
||||||
if (Object.keys(revenue).length > 0) rules.revenue = revenue;
|
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();
|
const params = new URLSearchParams();
|
||||||
if (seed !== '') params.set('seed', seed);
|
if (seed !== '') params.set('seed', seed);
|
||||||
params.set('hand', rules.startingHand);
|
params.set('hand', rules.startingHand);
|
||||||
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
|
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}`;
|
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 {
|
function start(): void {
|
||||||
const params = new URLSearchParams(location.search);
|
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.
|
// 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);
|
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
|
// 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.
|
// `configFor`. That is why the restore happens after the session is built rather than feeding it.
|
||||||
const saved = load();
|
const saved = load();
|
||||||
if (saved && requested === null) session.restore(saved);
|
if (saved && requested === null) local.restore(saved);
|
||||||
|
|
||||||
applyCapabilities();
|
applyCapabilities();
|
||||||
// Every render goes through the session, so the page redraws whenever the game says it changed —
|
// Every render goes through the session, so the page redraws whenever the game says it changed —
|
||||||
@@ -226,6 +436,58 @@ function applyCapabilities(): void {
|
|||||||
hide('undo', c.undo);
|
hide('undo', c.undo);
|
||||||
hide('savefile', c.saveLocal);
|
hide('savefile', c.saveLocal);
|
||||||
hide('newgame', c.newGame);
|
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;
|
||||||
|
/**
|
||||||
|
* ONLY WHILE THE GAME IS STILL OPENING.
|
||||||
|
*
|
||||||
|
* This answers "who is where, and why" — which is a question you have once, at the start, when
|
||||||
|
* the chain has just been rolled and the names are new. By Day 1 Stage 2 the map itself has been
|
||||||
|
* answering it for a while, and a permanent line restating it is a permanent line to read past.
|
||||||
|
*/
|
||||||
|
const opening = f.day === 1 && f.stage === 1;
|
||||||
|
if (f.players.length < 2 || !opening) {
|
||||||
|
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 ${seatLabel(p.seat)}`);
|
||||||
|
$('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
@@ -251,6 +513,7 @@ function render(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderTurnChart(f);
|
renderTurnChart(f);
|
||||||
|
renderPresence(f);
|
||||||
$('revenue').textContent = String(f.revenue);
|
$('revenue').textContent = String(f.revenue);
|
||||||
/**
|
/**
|
||||||
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
||||||
@@ -263,11 +526,19 @@ function render(): void {
|
|||||||
const obj = $('objective');
|
const obj = $('objective');
|
||||||
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
|
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
|
||||||
obj.className = 'pace';
|
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 ${seatLabel(session.seat())}`;
|
||||||
renderHouseRules(f.houseRules);
|
renderHouseRules(f.houseRules);
|
||||||
|
|
||||||
// -- division
|
// -- 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
|
// -- board. Both renderers are shared with the replay so the two can never draw different
|
||||||
// pictures of the same position.
|
// pictures of the same position.
|
||||||
@@ -302,6 +573,7 @@ function render(): void {
|
|||||||
return [{ row: cell.row, col: cell.col, label }];
|
return [{ row: cell.row, col: cell.col, label }];
|
||||||
});
|
});
|
||||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
|
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
|
// 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.
|
// you switching?" picker writes, so the board and the action panel drive one value either way.
|
||||||
@@ -593,14 +865,18 @@ function render(): void {
|
|||||||
*/
|
*/
|
||||||
function renderUndo(): void {
|
function renderUndo(): void {
|
||||||
const btn = document.getElementById('undo') as HTMLButtonElement | null;
|
const btn = document.getElementById('undo') as HTMLButtonElement | null;
|
||||||
if (!btn || !session.capabilities.undo) return;
|
if (!btn || !isLocal(session)) return;
|
||||||
const n = session.steps();
|
// 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.disabled = n === 0;
|
||||||
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
|
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
|
||||||
btn.onclick = () => {
|
btn.onclick = () => {
|
||||||
// The session drops the rebuilt game's cues and draws — replaying the history re-records them,
|
// 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.
|
// and none of it is news to a player who just stepped back.
|
||||||
if (!session.undo()) return;
|
if (!local.undo()) return;
|
||||||
selected = null;
|
selected = null;
|
||||||
mode = null;
|
mode = null;
|
||||||
pendingAt = null;
|
pendingAt = null;
|
||||||
@@ -657,6 +933,7 @@ function renderDistrict(f: Frame): void {
|
|||||||
btn.onclick = () => {
|
btn.onclick = () => {
|
||||||
// auto -> pin it to the opposite of what auto is doing -> back to auto.
|
// auto -> pin it to the opposite of what auto is doing -> back to auto.
|
||||||
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
||||||
|
saveSettings({ districtMode });
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -819,8 +1096,8 @@ function renderActions(
|
|||||||
.map(
|
.map(
|
||||||
(m) =>
|
(m) =>
|
||||||
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
|
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
|
||||||
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.row}, ${m.from.col}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
|
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.col}, ${m.from.row}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
|
||||||
`${esc(m.label)} <span class="dim">(${m.from.row}, ${m.from.col})</span></button>`,
|
`${esc(m.label)} <span class="dim">(${m.from.col}, ${m.from.row})</span></button>`,
|
||||||
)
|
)
|
||||||
.join('') +
|
.join('') +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
@@ -832,9 +1109,19 @@ function renderActions(
|
|||||||
* Everything about a card in hand now lives on the card, and everything about making up a train
|
* Everything about a card in hand now lives on the card, and everything about making up a train
|
||||||
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
|
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
|
||||||
* drawing, switching moves, the Freight Agent, and finishing.
|
* drawing, switching moves, the Freight Agent, and finishing.
|
||||||
|
*
|
||||||
|
* EXCLUDE BY TITLE, NOT BY PREFIX. This used to be `!/^Making up /.test(g.title)`, on the
|
||||||
|
* assumption only the yard-chip panel's own group is ever titled that way. It is not: "choose
|
||||||
|
* where Extra X22 starts" is titled by `trainCardTitle`, which also begins "Making up Extra
|
||||||
|
* X22…", so the regex swallowed it too — and with no tray yet being filled, `menu.makeUp` is
|
||||||
|
* null, so nothing rendered it anywhere else. REPORTED as the game hanging with the Freight
|
||||||
|
* House mid-cycle: an Extra came due, the action panel had a legal decision and zero buttons,
|
||||||
|
* and nothing short of restarting looked like it would ever move again. Matching the exact
|
||||||
|
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
|
||||||
|
* however it is titled, on screen where a player can act on it.
|
||||||
*/
|
*/
|
||||||
html += menu.direct
|
html += menu.direct
|
||||||
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
|
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
|
||||||
.map(
|
.map(
|
||||||
(g) =>
|
(g) =>
|
||||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
||||||
@@ -970,6 +1257,10 @@ function renderActions(
|
|||||||
* where a rendered page would have been megabytes.
|
* where a rendered page would have been megabytes.
|
||||||
*/
|
*/
|
||||||
function downloadSave(): void {
|
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 data = JSON.stringify(session.save(), null, 1);
|
||||||
const blob = new Blob([data], { type: 'application/json' });
|
const blob = new Blob([data], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -981,7 +1272,7 @@ function downloadSave(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function save(): void {
|
function save(): void {
|
||||||
if (!session.capabilities.saveLocal) return;
|
if (!isLocal(session)) return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
|
localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1029,36 +1320,97 @@ if (saveBtn) saveBtn.onclick = downloadSave;
|
|||||||
* Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would
|
* 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.
|
* 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 newBtn = document.getElementById('newgame');
|
||||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
||||||
if (newBtn && dlg) {
|
if (newBtn && dlg) {
|
||||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
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
|
* 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
|
* 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 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
|
* 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
|
* compare against the first is the common case, and re-entering settings each time is how a
|
||||||
* comparison silently stops comparing.
|
* 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 = () => {
|
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 day = f.day;
|
||||||
const started = f.status === 'active' && (day > 1 || f.stage > 1);
|
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 = '';
|
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"]')) {
|
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-hand"]')) {
|
||||||
input.checked = input.value === current.startingHand;
|
input.checked = input.value === current.startingHand;
|
||||||
}
|
}
|
||||||
field<HTMLInputElement>('ng-passenger').value = String(current.revenue.passengerPerCoach);
|
field<HTMLInputElement>('ng-passenger').value = String(current.revenue.passengerPerCoach);
|
||||||
field<HTMLInputElement>('ng-freight').value = String(current.revenue.freightPerLoad);
|
field<HTMLInputElement>('ng-freight').value = String(current.revenue.freightPerLoad);
|
||||||
field<HTMLInputElement>('ng-transit').value = String(current.revenue.trainPerTransit);
|
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();
|
dlg.showModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1068,6 +1420,8 @@ if (newBtn && dlg) {
|
|||||||
*
|
*
|
||||||
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
|
* 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.
|
* 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', () => {
|
dlg.addEventListener('close', () => {
|
||||||
if (dlg.returnValue !== 'deal') return;
|
if (dlg.returnValue !== 'deal') return;
|
||||||
@@ -1087,9 +1441,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();
|
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
|
// 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,
|
// 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.
|
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
|
||||||
@@ -1098,6 +1458,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');
|
const soundBtn = document.getElementById('sound');
|
||||||
if (soundBtn) {
|
if (soundBtn) {
|
||||||
const paint = (): void => {
|
const paint = (): void => {
|
||||||
@@ -1105,6 +1494,7 @@ if (soundBtn) {
|
|||||||
};
|
};
|
||||||
soundBtn.onclick = () => {
|
soundBtn.onclick = () => {
|
||||||
soundOn = !soundOn;
|
soundOn = !soundOn;
|
||||||
|
saveSettings({ soundOn });
|
||||||
paint();
|
paint();
|
||||||
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it
|
// 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.
|
// doubles as the user gesture the browser needs before any audio may start.
|
||||||
|
|||||||
+187
-1
@@ -33,6 +33,16 @@ header button:hover{border-color:#4d6fa8}
|
|||||||
stops inviting the press. */
|
stops inviting the press. */
|
||||||
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
|
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
|
||||||
header button:disabled:hover{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}
|
||||||
|
#lb-settings{margin:10px 0;border:1px solid var(--line);border-radius:7px;padding:8px 12px;background:#171c23}
|
||||||
|
#lb-settings summary{cursor:pointer;font-size:13px;color:#9fb6d8}
|
||||||
|
#lb-settings h3{font-size:12px;margin:12px 0 4px;color:#9fb6d8}
|
||||||
|
.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}
|
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
|
||||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||||
.home:hover{color:#5aa9e6}
|
.home:hover{color:#5aa9e6}
|
||||||
@@ -70,6 +80,14 @@ main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14p
|
|||||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||||
padding:10px 12px;margin-bottom:12px}
|
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 strip */
|
||||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
#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}
|
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
|
||||||
@@ -154,6 +172,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;
|
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}
|
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}
|
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);
|
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
|
||||||
color:#f2e6cf;font-weight:600}
|
color:#f2e6cf;font-weight:600}
|
||||||
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
||||||
@@ -207,6 +231,130 @@ ul.blocked li{padding:2px 0}
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
||||||
|
<details id="lb-settings">
|
||||||
|
<summary>Game settings</summary>
|
||||||
|
<p class="ng-note">The same dials the solitaire New Game dialog has. Picking a mode or a table
|
||||||
|
size sets them to that game's defaults; every one stays yours to change. They are fixed when
|
||||||
|
the game is created and cannot be changed once it starts.</p>
|
||||||
|
|
||||||
|
<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="lb-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
|
||||||
|
|
||||||
|
<h3>Starting hand</h3>
|
||||||
|
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
||||||
|
either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||||
|
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeRandom" checked>
|
||||||
|
<span>Three random cards</span></label>
|
||||||
|
<label class="ng-radio"><input type="radio" name="lb-hand" value="sixRandom">
|
||||||
|
<span>Six random cards</span></label>
|
||||||
|
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeTrackThreeOther">
|
||||||
|
<span>Three track and three other</span></label>
|
||||||
|
|
||||||
|
<h3>Revenue</h3>
|
||||||
|
<p class="ng-note">What each piece of work pays, 0 to 5. Zero switches an economy off so the
|
||||||
|
others can be read.</p>
|
||||||
|
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||||
|
<input id="lb-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||||
|
<label class="ng-num"><span>Freight revenue per load</span>
|
||||||
|
<input id="lb-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||||
|
<label class="ng-num"><span>Train revenue per transit</span>
|
||||||
|
<input id="lb-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||||
|
|
||||||
|
<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 default is sized for the table you chose above.</p>
|
||||||
|
<label class="ng-num"><span>Days</span>
|
||||||
|
<input id="lb-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="lb-minrev" type="number" min="0" step="1" value="60"></label>
|
||||||
|
<label class="ng-num"><span>Collisions in one Day that end the game</span>
|
||||||
|
<input id="lb-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="lb-coltotal" type="number" min="0" step="1" value="5"></label>
|
||||||
|
<label class="ng-num"><span>Allow the opponent-directed cards</span>
|
||||||
|
<input id="lb-pvp" type="checkbox"></label>
|
||||||
|
<p class="ng-note" id="lb-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect
|
||||||
|
either way until then.</p>
|
||||||
|
|
||||||
|
<h3>Optional rules</h3>
|
||||||
|
<p class="ng-note">Appendix B. Off by default; each one changes how the game plays.</p>
|
||||||
|
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||||
|
night Stages (1–3 and 11–12)</span>
|
||||||
|
<input id="lb-visibility" type="checkbox"></label>
|
||||||
|
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one chair
|
||||||
|
left and takes over the next station up the line. Your Revenue and the Fedora go with you;
|
||||||
|
the district stays where it is</span>
|
||||||
|
<input id="lb-rotation" type="checkbox"></label>
|
||||||
|
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand of
|
||||||
|
four; play or discard down to three on the first turn</span>
|
||||||
|
<input id="lb-toolbox" type="checkbox"></label>
|
||||||
|
</details>
|
||||||
|
<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">
|
<div class="topbar">
|
||||||
<header>
|
<header>
|
||||||
<b><a href="./index.html" class="home">Station Master</a></b>
|
<b><a href="./index.html" class="home">Station Master</a></b>
|
||||||
@@ -221,9 +369,16 @@ ul.blocked li{padding:2px 0}
|
|||||||
to keep the header on one line; the tooltip spells it out. -->
|
to keep the header on one line; the tooltip spells it out. -->
|
||||||
<span class="dim" id="houserules" title="">—</span>
|
<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>
|
<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="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="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="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>
|
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -238,10 +393,15 @@ ul.blocked li{padding:2px 0}
|
|||||||
what you miss when the automatic phases run between two clicks. -->
|
what you miss when the automatic phases run between two clicks. -->
|
||||||
<div id="phasenote"></div>
|
<div id="phasenote"></div>
|
||||||
<div id="announce"></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>
|
<main>
|
||||||
<div>
|
<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">
|
<section id="district">
|
||||||
<h2>Your Office Area
|
<h2>Your Office Area
|
||||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||||
@@ -305,10 +465,21 @@ ul.blocked li{padding:2px 0}
|
|||||||
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
|
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.
|
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">
|
<dialog id="newgamedlg" aria-labelledby="ng-title">
|
||||||
<form method="dialog" id="newgameform">
|
<form method="dialog" id="newgameform">
|
||||||
<h2 class="big" id="ng-title">New game</h2>
|
<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>
|
<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>
|
<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">
|
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
|
||||||
@@ -332,7 +503,22 @@ ul.blocked li{padding:2px 0}
|
|||||||
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
<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>
|
<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">
|
<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="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
|
||||||
<button value="deal" id="ng-deal" type="submit">Deal</button>
|
<button value="deal" id="ng-deal" type="submit">Deal</button>
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
+146
-9
@@ -18,8 +18,9 @@
|
|||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame } from '../sim/view.ts';
|
||||||
import type { PlayerIndex } from '../engine/state.ts';
|
import type { PlayerIndex } from '../engine/state.ts';
|
||||||
import type { HouseRuleOverrides } from '../engine/content.ts';
|
import { applyDelta } from '../sim/frame-delta.ts';
|
||||||
import type { Game, Menu, Save } from './game.ts';
|
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||||
|
import type { Game, Menu, NewGameOptions, Save } from './game.ts';
|
||||||
import {
|
import {
|
||||||
actionMenu,
|
actionMenu,
|
||||||
configWith,
|
configWith,
|
||||||
@@ -92,6 +93,15 @@ export type Session = {
|
|||||||
takeAnnouncement(): string | null;
|
takeAnnouncement(): string | null;
|
||||||
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
|
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
|
||||||
justDrawn(): string | null;
|
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
|
* `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.
|
* 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
|
* It takes `NewGameOptions` rather than a whole `GameConfig` because DEALING is the only thing on the
|
||||||
* side of this that the page is allowed to decide. A config carries the mode, the victory condition
|
* far side of this that the page is allowed to decide. A config carries the mode and the optional
|
||||||
* and the optional rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build
|
* rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build meant importing
|
||||||
* meant importing the engine's own defaults into the page, which is the boundary `session.test.ts`
|
* the engine's own defaults into the page, which is the boundary `session.test.ts` guards. The seed,
|
||||||
* guards. The seed and the house rules are the two things a player picks when they press New game.
|
* 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 {
|
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
||||||
let game: Game = rules ? newGame(seed, configWith(rules)) : newGame(seed);
|
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
const changed = (): void => {
|
const changed = (): void => {
|
||||||
for (const fn of [...listeners]) fn();
|
for (const fn of [...listeners]) fn();
|
||||||
@@ -162,6 +172,7 @@ export function createLocalSession(seed: number, rules?: HouseRuleOverrides): Lo
|
|||||||
return text;
|
return text;
|
||||||
},
|
},
|
||||||
justDrawn: () => game.justDrawn,
|
justDrawn: () => game.justDrawn,
|
||||||
|
presence: () => [],
|
||||||
|
|
||||||
seed: () => game.seed,
|
seed: () => game.seed,
|
||||||
save: () => toSave(game),
|
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 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+81
-17
@@ -64,6 +64,38 @@ export function playCue(cue: string): void {
|
|||||||
osc.stop(at + dur + 0.02);
|
osc.stop(at + dur + 0.02);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A burst of filtered noise — the breath of steam behind a whistle, one chuff of a moving
|
||||||
|
* locomotive, or the boom of an impact. Shared by `stage` (one long burst) and the chuffs in
|
||||||
|
* `arrive`/`depart` (several short ones), and the wide-open burst under `crash`.
|
||||||
|
*/
|
||||||
|
const noiseBurst = (
|
||||||
|
at: number,
|
||||||
|
dur: number,
|
||||||
|
gain: number,
|
||||||
|
freq: number,
|
||||||
|
q: number,
|
||||||
|
type: BiquadFilterType = 'bandpass',
|
||||||
|
): void => {
|
||||||
|
const frames = Math.max(1, Math.floor(c.sampleRate * dur));
|
||||||
|
const buf = c.createBuffer(1, frames, c.sampleRate);
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
|
||||||
|
const src = c.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
const filt = c.createBiquadFilter();
|
||||||
|
filt.type = type;
|
||||||
|
filt.frequency.value = freq;
|
||||||
|
filt.Q.value = q;
|
||||||
|
const amp = c.createGain();
|
||||||
|
amp.gain.setValueAtTime(0, at);
|
||||||
|
amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.05, dur / 4));
|
||||||
|
amp.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||||
|
src.connect(filt).connect(amp).connect(c.destination);
|
||||||
|
src.start(at);
|
||||||
|
src.stop(at + dur + 0.02);
|
||||||
|
};
|
||||||
|
|
||||||
const t = c.currentTime + 0.02;
|
const t = c.currentTime + 0.02;
|
||||||
|
|
||||||
if (cue === 'stage') {
|
if (cue === 'stage') {
|
||||||
@@ -73,23 +105,55 @@ export function playCue(cue: string): void {
|
|||||||
for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) {
|
for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) {
|
||||||
tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985);
|
tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985);
|
||||||
}
|
}
|
||||||
const frames = Math.floor(c.sampleRate * d);
|
noiseBurst(t, d, 0.05, 1400, 0.8);
|
||||||
const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate);
|
return;
|
||||||
const data = buf.getChannelData(0);
|
}
|
||||||
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
|
|
||||||
const src = c.createBufferSource();
|
if (cue === 'arrive') {
|
||||||
src.buffer = buf;
|
/**
|
||||||
const band = c.createBiquadFilter();
|
* A LOCOMOTIVE SLOWING TO A STOP: chuffs (one per wheel revolution) spaced FARTHER apart as
|
||||||
band.type = 'bandpass';
|
* road speed drops, then a soft two-partial bell tap as it settles — a lighter cousin of the
|
||||||
band.frequency.value = 1400;
|
* `day` bell, not a repeat of it.
|
||||||
band.Q.value = 0.8;
|
*/
|
||||||
const amp = c.createGain();
|
let at = t;
|
||||||
amp.gain.setValueAtTime(0, t);
|
const gaps = [0.13, 0.17, 0.22, 0.28];
|
||||||
amp.gain.linearRampToValueAtTime(0.05, t + 0.05);
|
for (let i = 0; i < gaps.length; i++) {
|
||||||
amp.gain.exponentialRampToValueAtTime(0.0001, t + d);
|
noiseBurst(at, 0.07, 0.11 - i * 0.015, 190, 1.1, 'bandpass');
|
||||||
src.connect(band).connect(amp).connect(c.destination);
|
at += gaps[i]!;
|
||||||
src.start(t);
|
}
|
||||||
src.stop(t + d);
|
for (const fg of [[1046, 0.07], [1568, 0.04]]) {
|
||||||
|
tone(at + 0.12, fg[0]!, 0.3, fg[1]!, 'sine');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cue === 'depart') {
|
||||||
|
/**
|
||||||
|
* THE INVERSE OF `arrive`: chuffs starting slow and pulling closer together as the train
|
||||||
|
* gathers speed, then one falling, fading horn note — a single call, not `completed`'s pair,
|
||||||
|
* since this train is only leaving the Office, not clearing the whole Division.
|
||||||
|
*/
|
||||||
|
let at = t;
|
||||||
|
const gaps = [0.22, 0.15, 0.1, 0.07, 0.05];
|
||||||
|
for (let i = 0; i < gaps.length; i++) {
|
||||||
|
noiseBurst(at, 0.06, 0.06 + i * 0.012, 210, 1.2, 'bandpass');
|
||||||
|
at += gaps[i]!;
|
||||||
|
}
|
||||||
|
tone(at, 349, 0.45, 0.1, 'sawtooth', 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cue === 'crash') {
|
||||||
|
/**
|
||||||
|
* IMPACT, NOT MUSIC: two tones bent sharply downward at a DISSONANT (non-harmonic) interval,
|
||||||
|
* so the ear reads a collision rather than a chord — `couple`'s clank is consonant on purpose,
|
||||||
|
* and this is its opposite. A wide-open noise boom and a percussive sub-bass thump give it
|
||||||
|
* weight under the screech.
|
||||||
|
*/
|
||||||
|
tone(t, 311, 0.35, 0.16, 'square', 70);
|
||||||
|
tone(t, 233, 0.4, 0.13, 'sawtooth', 52);
|
||||||
|
noiseBurst(t, 0.4, 0.16, 900, 0.4, 'lowpass');
|
||||||
|
tone(t, 45, 0.3, 0.18, 'sine');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,65 @@ if (ties) {
|
|||||||
}
|
}
|
||||||
ties.innerHTML = out;
|
ties.innerHTML = out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clicking the box art opens it full size, since the thumbnail is too small to read. */
|
||||||
|
const heroImage = document.getElementById('hero-image');
|
||||||
|
const lightbox = document.getElementById('lightbox');
|
||||||
|
if (heroImage && lightbox) {
|
||||||
|
const open = () => { lightbox.hidden = false; };
|
||||||
|
const close = () => { lightbox.hidden = true; };
|
||||||
|
heroImage.addEventListener('click', open);
|
||||||
|
heroImage.addEventListener('keydown', (e) => {
|
||||||
|
if (e instanceof KeyboardEvent && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lightbox.addEventListener('click', close);
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|||||||
+226
-62
@@ -9,20 +9,22 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { advance, pump } from '../src/engine/advance.ts';
|
import { advance, pump } from '../src/engine/advance.ts';
|
||||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||||
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.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 { legalActions } from '../src/engine/legal.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import { developerBot } from '../src/sim/bot.ts';
|
import { developerBot } from '../src/sim/bot.ts';
|
||||||
import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts';
|
import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts';
|
||||||
import { railFacingOf } from '../src/engine/state.ts';
|
import { coordKey, railFacingOf } from '../src/engine/state.ts';
|
||||||
|
|
||||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 3,
|
||||||
length: 'short',
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -327,6 +329,75 @@ describe('collisions are automatic (Gap 2)', () => {
|
|||||||
assert.ok(!s.trays.has(id), 'the train is removed');
|
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)', () => {
|
it('returns cabooses to the Division Yard and other stock to Classification (Gap 2c)', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
s.officeAreas.get(0)!.adOccupancy = ['blocker'];
|
s.officeAreas.get(0)!.adOccupancy = ['blocker'];
|
||||||
@@ -464,11 +535,11 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('victory conditions (§3, Gap 10e)', () => {
|
describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||||
it('loses a timed Solitaire game that misses the target', () => {
|
it('loses a timed Solitaire game that misses minCombinedRevenue', () => {
|
||||||
// The target doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
|
// minCombinedRevenue doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
|
||||||
const s = game(1);
|
const s = game(1, { minCombinedRevenue: 10 });
|
||||||
s.clock.day = lengthProfile('short').days + 1;
|
s.clock.day = s.config.days + 1;
|
||||||
s.clock.stage = STAGES_PER_DAY;
|
s.clock.stage = STAGES_PER_DAY;
|
||||||
s.clock.phase = 'shiftChange';
|
s.clock.phase = 'shiftChange';
|
||||||
s.players[0]!.revenue = 0;
|
s.players[0]!.revenue = 0;
|
||||||
@@ -478,25 +549,101 @@ describe('victory conditions (§3, Gap 10e)', () => {
|
|||||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wins a timed Solitaire game that clears the target', () => {
|
it('wins a timed Solitaire game that clears minCombinedRevenue', () => {
|
||||||
const s = game(1);
|
const s = game(1, { minCombinedRevenue: 10 });
|
||||||
s.clock.day = lengthProfile('short').days + 1;
|
s.clock.day = s.config.days + 1;
|
||||||
s.clock.stage = STAGES_PER_DAY;
|
s.clock.stage = STAGES_PER_DAY;
|
||||||
s.clock.phase = 'shiftChange';
|
s.clock.phase = 'shiftChange';
|
||||||
s.players[0]!.revenue = lengthProfile('short').target;
|
s.players[0]!.revenue = 10;
|
||||||
advance(s);
|
advance(s);
|
||||||
assert.equal(s.status, 'finished');
|
assert.equal(s.status, 'finished');
|
||||||
assert.equal(s.outcome!.result, 'win');
|
assert.equal(s.outcome!.result, 'win');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ends a first-to-target game the moment the target is reached', () => {
|
it('minCombinedRevenue = 0 disables the floor — any score wins once days run out', () => {
|
||||||
const s = game(1, { victory: 'firstToTarget' });
|
const s = game(1, { minCombinedRevenue: 0 });
|
||||||
s.players[0]!.revenue = lengthProfile('short').target;
|
s.clock.day = s.config.days + 1;
|
||||||
s.clock.stage = STAGES_PER_DAY;
|
s.clock.stage = STAGES_PER_DAY;
|
||||||
s.clock.phase = 'shiftChange';
|
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);
|
advance(s);
|
||||||
assert.equal(s.status, 'finished');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -690,9 +837,16 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
|||||||
* them — it may be the rules are wrong, or it may be my perception."
|
* them — it may be the rules are wrong, or it may be my perception."
|
||||||
*
|
*
|
||||||
* It was perception, but the log was actively feeding it. Every departure read alike, and the
|
* It was perception, but the log was actively feeding it. Every departure read alike, and the
|
||||||
* arrival line for an Expedited train said there was "no turn in which to work it" — which is
|
* arrival line for an Expedited train said there was "no turn in which to work it" — which was
|
||||||
* false and cost the player the Cargo turn they did have. These pin the two claims the log now
|
* false under the OLD (v0.4.8 and earlier) same-stage-departure reading of Q3, and cost the player
|
||||||
* makes, so a phase-order change cannot leave the narration lying about it.
|
* the Cargo turn they did have.
|
||||||
|
*
|
||||||
|
* v0.4.9 CORRECTED Q3 ITSELF, not just the narration: Expedite does not change when a train
|
||||||
|
* leaves — it is released by the ordinary §8.1 rules exactly like any other train, and may be
|
||||||
|
* switched normally while it stands. What it means is that the train must not be PARKED anywhere
|
||||||
|
* but the station; leaving it on Secondary Track when a Mainline Phase begins is a Station Master
|
||||||
|
* fault (`expediteFault`, `EXPEDITE_FAULT_PENALTY`). These pin that behaviour, so a phase-order
|
||||||
|
* change cannot silently bring back the old forced departure.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases
|
* Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases
|
||||||
@@ -701,14 +855,13 @@ 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.
|
* 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 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';
|
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||||
/**
|
/**
|
||||||
* A DIVISION WITH NOTHING ELSE ON IT. Expedite is not absolute: §8.1 can still hold the train,
|
* A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a
|
||||||
* and `shiftChange` says so — "an expedited train that would need a ruling simply stays, and
|
* same-direction meet — with the bot's own traffic running, Train 6 was held four Stages and
|
||||||
* runs normally next Stage". With the bot's own traffic running, Train 6 was held four Stages
|
* collected four Local Operations turns. Correct behaviour, but it is traffic noise for what
|
||||||
* and collected four Local Operations turns, which is correct behaviour and the opposite of what
|
* this test is trying to pin, so clearing the timetable isolates the rule from it.
|
||||||
* this test is trying to pin. Clearing the timetable isolates the rule from the traffic.
|
|
||||||
*/
|
*/
|
||||||
s.timetable = s.timetable.map(() => null);
|
s.timetable = s.timetable.map(() => null);
|
||||||
const id = 'watched';
|
const id = 'watched';
|
||||||
@@ -779,40 +932,55 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
|||||||
assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase');
|
assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives an Expedited train the Cargo phase but never Local Operations', () => {
|
it('gives an Expedited train a Local Operations turn too, exactly like an ordinary one', () => {
|
||||||
// Train 6 The Sparrow — Expedite. The claim the arrival line makes is precisely this pair:
|
// Train 6 The Sparrow — Expedite. v0.4.9 corrected Q3: Expedite no longer forces a same-stage
|
||||||
// Porters and Laborers can reach it, a switching turn never comes, and it goes at the end of
|
// departure, so an Expedited train is released by the ordinary §8.1 rule and stands for a
|
||||||
// the Stage rather than in a Mainline Phase.
|
// Local Operations turn the same as Train 12 above.
|
||||||
const r = phasesWith(6);
|
const r = phasesWith(6);
|
||||||
assert.equal(r.localOps, 0, 'an Expedited train got a Local Operations turn after all');
|
assert.ok(r.localOps >= 1, `an Expedited train got ${r.localOps} Local Operations turns`);
|
||||||
assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase');
|
assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase');
|
||||||
assert.equal(r.leftIn, 'shiftChange', 'an Expedited train should leave in Supervisor Shift');
|
assert.equal(r.leftIn, 'mainline', 'an Expedited train should leave in a Mainline Phase, like any other');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT expedite when §8.1 wants a ruling — it stays and runs normally', () => {
|
it('faults an Expedited train left off the station when a Mainline Phase begins', () => {
|
||||||
/**
|
// What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left
|
||||||
* EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table.
|
// 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.
|
||||||
* `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs
|
const s = game(7, { days: 5 });
|
||||||
* normally next Stage" — and it happens often: driven by the developer bot this was reached by
|
const area = areaOf(s, 0);
|
||||||
* ordinary traffic, and Train 6 collected FOUR Local Operations turns instead of none. Built by
|
const id = 'expedited';
|
||||||
* hand here rather than fished out of a bot game, because whether the bot happens to produce a
|
const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 };
|
||||||
* meet depends on the deck, and the deck moves.
|
s.trays.set(id, {
|
||||||
*
|
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||||
* So the log and the card must not promise that an Expedited train can never be switched.
|
direction: 'east', facing: 'e',
|
||||||
*/
|
position: { at: 'grid', seat: 0, coord: secondary }, movesUsed: 0,
|
||||||
const s = game(7, { length: 'standard' });
|
});
|
||||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
|
||||||
|
|
||||||
// Train 6 (Expedite) standing at the Office, having just arrived: this is the state
|
s.clock.phase = 'mainline';
|
||||||
// `arriveAtOffice` leaves behind when it sets `departsThisStage`.
|
const before = s.players[0]!.revenue;
|
||||||
|
const r = advance(s);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
r.events.some((e) => e.type === 'expediteFault' && e.trainNumber === 6),
|
||||||
|
'no fault was raised for an Expedited train left off the station',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
s.players[0]!.revenue,
|
||||||
|
before - EXPEDITE_FAULT_PENALTY,
|
||||||
|
'the fault did not cost Revenue',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, { days: 5 });
|
||||||
const area = areaOf(s, 0);
|
const area = areaOf(s, 0);
|
||||||
const id = 'expedited';
|
const id = 'expedited';
|
||||||
s.trays.set(id, {
|
s.trays.set(id, {
|
||||||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||||
direction: 'east', facing: 'e',
|
direction: 'east', facing: 'e',
|
||||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||||
departsThisStage: true,
|
|
||||||
});
|
});
|
||||||
area.adOccupancy.push(id);
|
area.adOccupancy.push(id);
|
||||||
|
|
||||||
@@ -829,26 +997,22 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
|||||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||||
}
|
}
|
||||||
|
|
||||||
s.clock.phase = 'shiftChange';
|
s.clock.phase = 'mainline';
|
||||||
|
const before = s.players[0]!.revenue;
|
||||||
const r = advance(s);
|
const r = advance(s);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
|
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
|
||||||
'the Expedited departure was not put to the Superintendent',
|
'the departure was not put to the Superintendent',
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
s.trays.get(id)?.position.at,
|
|
||||||
'grid',
|
|
||||||
'the Expedited train left despite needing a ruling nobody could give',
|
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
r.events.some((e) => e.type === 'trainHeld' && /EXPEDITES/.test(e.reason)),
|
!r.events.some((e) => e.type === 'expediteFault'),
|
||||||
'the log does not say why the Expedited train stayed',
|
'a train properly parked at the station was faulted anyway',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
s.trays.get(id)?.departsThisStage,
|
s.players[0]!.revenue,
|
||||||
false,
|
before,
|
||||||
'it should run as an ordinary train from now on, not retry the expedited departure',
|
'Revenue changed for a train legitimately held at the station',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+60
-3
@@ -17,11 +17,13 @@ import { cardDescription, snapshot } from '../src/sim/view.ts';
|
|||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -685,6 +687,61 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
|||||||
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED');
|
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('unloads the car the player actually picked, not always the westmost one', () => {
|
||||||
|
// REPORTED FROM PLAY: three loaded cars stood at a Freight House; asking to unload the EAST
|
||||||
|
// (index 2) car unloaded the WEST one instead, every time, regardless of which car was picked.
|
||||||
|
// The reducer for `unloadBegan` used to re-derive the target with
|
||||||
|
// `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in
|
||||||
|
// track order no matter which `carIndex` the intent actually named.
|
||||||
|
const s = game();
|
||||||
|
const coord = at(-1, 0);
|
||||||
|
addCard(s, coord, {
|
||||||
|
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: [
|
||||||
|
{ type: 'boxcar', loaded: true },
|
||||||
|
{ type: 'hopper', loaded: true },
|
||||||
|
{ type: 'tank', loaded: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
laborers: 3,
|
||||||
|
porters: 0,
|
||||||
|
usedThisStage: { laborers: 0, porters: 0 },
|
||||||
|
},
|
||||||
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
|
});
|
||||||
|
s.clock.phase = 'loadUnload';
|
||||||
|
// An empty replacement of every type standing on the track, so `check` never refuses for a
|
||||||
|
// reason unrelated to what this test is about.
|
||||||
|
s.yards.divisionYard.push(
|
||||||
|
{ type: 'boxcar', loaded: false },
|
||||||
|
{ type: 'hopper', loaded: false },
|
||||||
|
{ type: 'tank', loaded: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const r = applyIntent(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 2 });
|
||||||
|
assert.ok(r.ok, 'unloading the east (index 2) car should be legal');
|
||||||
|
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||||
|
assert.equal(f.industryTrack.cars[0]!.type, 'boxcar', 'the west car must be untouched');
|
||||||
|
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the west car must still be loaded');
|
||||||
|
assert.equal(f.industryTrack.cars[1]!.type, 'hopper', 'the middle car must be untouched');
|
||||||
|
assert.equal(f.industryTrack.cars[1]!.loaded, true, 'the middle car must still be loaded');
|
||||||
|
assert.equal(f.industryTrack.cars[2]!.type, 'tank', 'the east car — the one picked — must still be there');
|
||||||
|
assert.equal(f.industryTrack.cars[2]!.loaded, false, 'the east car is the one that should be emptied');
|
||||||
|
});
|
||||||
|
|
||||||
it('takes four Laborer actions in total for one point', () => {
|
it('takes four Laborer actions in total for one point', () => {
|
||||||
// The asymmetry the whole economy is calibrated against (card-reference.md §2).
|
// The asymmetry the whole economy is calibrated against (card-reference.md §2).
|
||||||
const s = game();
|
const s = game();
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
|||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
+62
-22
@@ -18,9 +18,12 @@ import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts';
|
|||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: 0,
|
||||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
};
|
};
|
||||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||||
@@ -468,9 +471,10 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
|||||||
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop
|
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop
|
||||||
* would break in silence. These pin it.
|
* would break in silence. These pin it.
|
||||||
*
|
*
|
||||||
* It is also what gives the printed **Expedite** rule something to do — an Expedited train departs
|
* v0.4.9 CORRECTED Q3 so **Expedite is not an exception to this any more**: an Expedited train
|
||||||
* at the END of the Stage it arrived, precisely because the general rule forbids it departing at
|
* stops for the Stage exactly like any other and may be switched in the Local Operations turn that
|
||||||
* all. It stands through Load/Unload first, which is what makes its coaches workable.
|
* follows. What Expedite restricts is where the train may be left standing afterwards, not when it
|
||||||
|
* departs — see `advance.test.ts`'s `expediteFault` coverage for that half.
|
||||||
*/
|
*/
|
||||||
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
|
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
|
||||||
const id = 'arriving';
|
const id = 'arriving';
|
||||||
@@ -503,12 +507,14 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unless its card prints Expedite — but it still stands through Load/Unload first', () => {
|
it('an Expedite train follows the same rule now — no exception any more', () => {
|
||||||
/**
|
/**
|
||||||
* Q3, as revised in v0.4.2. An Expedite train leaves the Stage it arrives, but at the END of that
|
* Q3, as CORRECTED in v0.4.9. Before this, an Expedite train left the Stage it arrived — at the
|
||||||
* Stage rather than inside the Mainline Phase. It used to be gone before Load/Unload ran, which
|
* end of it rather than inside the Mainline Phase (v0.4.2's fix for coaches otherwise never
|
||||||
* made the coaches on every coach-carrying express (1/2, 5/6, 19) permanently unworkable —
|
* reaching Load/Unload) — which made it the one exception to "stops for the Stage". That forced
|
||||||
* measured, Train 2 arrived 32 times in 60 games and stood for Load/Unload in none of them.
|
* departure is gone: Expedite no longer changes WHEN a train leaves, only where it may be left
|
||||||
|
* standing afterwards (`expediteFault`, pinned in `advance.test.ts`). So it stops for the Stage
|
||||||
|
* exactly like Train 9 above.
|
||||||
*/
|
*/
|
||||||
const s = game();
|
const s = game();
|
||||||
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
|
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
|
||||||
@@ -518,19 +524,9 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
s.trays.get(id)!.position.at, 'grid',
|
s.trays.get(id)!.position.at, 'grid',
|
||||||
'an Expedite train left inside the Mainline Phase, before Load/Unload could reach it',
|
'an Expedite train departed again in the same Stage it arrived from a mainline card',
|
||||||
);
|
);
|
||||||
assert.equal(s.trays.get(id)!.departsThisStage, true, 'it is not flagged to leave at the end of the Stage');
|
|
||||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track');
|
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track');
|
||||||
|
|
||||||
// Run the end of the Stage directly. Load/Unload waits on the player, and the thing under test
|
|
||||||
// is the departure pass that follows it — not that phase's own input handling.
|
|
||||||
s.clock.phase = 'shiftChange';
|
|
||||||
advance(s);
|
|
||||||
assert.notEqual(
|
|
||||||
s.trays.get(id)?.position.at, 'grid',
|
|
||||||
'an Expedite train should be gone by the end of the Stage it arrived',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -666,6 +662,50 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
|||||||
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
|
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('holds against an uncleared facing train even if a same-direction one is checked first', () => {
|
||||||
|
// Reproduces a playtest report: the Superintendent was asked to rule on a same-direction train
|
||||||
|
// instead of being automatically held against an opposite-direction one also occupying the
|
||||||
|
// card. Root cause — `evaluateClearance` returned on whichever occupant it examined first, in
|
||||||
|
// `node.transits` insertion order, rather than checking every occupant. Only visible once a
|
||||||
|
// card can hold two trains at once, which the Telegraph/Telephone/Radio exception is exactly
|
||||||
|
// what creates: a facing train dispatched past another sits on the same card as it.
|
||||||
|
const s = game();
|
||||||
|
const ml = s.division.nodes[1];
|
||||||
|
assert.equal(ml?.kind, 'mainline');
|
||||||
|
if (ml?.kind === 'mainline') {
|
||||||
|
ml.card = 'plains';
|
||||||
|
// SAME direction, entered the transit list FIRST — what the old code asked about.
|
||||||
|
s.trays.set('sameDir', {
|
||||||
|
id: 'sameDir', trainNumber: 3, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
|
});
|
||||||
|
ml.transits.push({ tray: 'sameDir', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||||
|
// OPPOSITE direction, entered SECOND, no dispatch device on the board — an absolute bar.
|
||||||
|
s.trays.set('facing', {
|
||||||
|
id: 'facing', trainNumber: 5, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
|
});
|
||||||
|
ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||||
|
}
|
||||||
|
s.trays.set('mine', {
|
||||||
|
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||||
|
});
|
||||||
|
s.clock.phase = 'mainline';
|
||||||
|
s.movedThisPhase = new Set();
|
||||||
|
|
||||||
|
const r = advance(s);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
s.trays.get('mine')!.position.at, 'divisionPoint',
|
||||||
|
'the train entered despite an uncleared opposite-direction occupant on the card',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!r.events.some((e) => e.type === 'clearanceRequested'),
|
||||||
|
'the Superintendent was asked to rule on the same-direction occupant instead of an automatic hold',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('lets a junior train win the meet with a Telegraph', () => {
|
it('lets a junior train win the meet with a Telegraph', () => {
|
||||||
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
|
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
|
||||||
const s = game();
|
const s = game();
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ const KNOWN_UNREDUCED = [
|
|||||||
'carPassed',
|
'carPassed',
|
||||||
'clearanceRequested',
|
'clearanceRequested',
|
||||||
'dispatchBonusUsed',
|
'dispatchBonusUsed',
|
||||||
|
'expediteFault',
|
||||||
'phaseBegan',
|
'phaseBegan',
|
||||||
|
// Employee Rotation moves `seating` in the phase driver and then describes what it did, which is
|
||||||
|
// the pattern every entry on this list follows.
|
||||||
|
'seatsRotated',
|
||||||
'stageBegan',
|
'stageBegan',
|
||||||
'trainArrived',
|
'trainArrived',
|
||||||
'trainCompleted',
|
'trainCompleted',
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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,
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,11 +29,13 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ import { snapshot } from '../src/sim/view.ts';
|
|||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: 0,
|
||||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
};
|
};
|
||||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||||
@@ -733,8 +736,8 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
|||||||
id: 'reg',
|
id: 'reg',
|
||||||
seed: 4,
|
seed: 4,
|
||||||
config: {
|
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 },
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
},
|
},
|
||||||
playerNames: ['Solitaire'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
@@ -796,8 +799,8 @@ describe('Q13 — a train that catches the one ahead runs into it', () => {
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: 'rear', seed: 3,
|
id: 'rear', seed: 3,
|
||||||
config: {
|
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 },
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
},
|
},
|
||||||
playerNames: ['bot'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
|
|||||||
+279
-7
@@ -11,22 +11,33 @@ import { describe, it } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { advance, pump } from '../src/engine/advance.ts';
|
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 { STAGES_PER_DAY, STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.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 { developerBot, playGame } from '../src/sim/bot.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
import { impediments } from '../src/sim/narrate.ts';
|
import { divisionSvg } from '../src/sim/board-svg.ts';
|
||||||
|
import { impediments, narrate } from '../src/sim/narrate.ts';
|
||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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 = {
|
const competitive: GameConfig = {
|
||||||
mode: 'competitive',
|
mode: 'competitive',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: 0,
|
||||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
const game = (players: number, seed = 4242): GameState =>
|
const game = (players: number, seed = 4242): GameState =>
|
||||||
@@ -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.
|
* which is a list of squares that do not exist on the board they are looking at.
|
||||||
*/
|
*/
|
||||||
const s = game(3);
|
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[0]!.revenue = 1;
|
||||||
s.players[1]!.revenue = 9;
|
s.players[1]!.revenue = 9;
|
||||||
s.players[2]!.revenue = 17;
|
s.players[2]!.revenue = 17;
|
||||||
@@ -600,3 +614,261 @@ describe('scoring lands on the right seat', () => {
|
|||||||
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Employee Rotation (Appendix B)', () => {
|
||||||
|
/**
|
||||||
|
* Straight to the Day boundary, which is the only moment a rotation happens — the same shortcut
|
||||||
|
* `advance.test.ts` uses to roll the clock over without playing twelve Stages of real turns.
|
||||||
|
*/
|
||||||
|
const atDayEnd = (on: boolean): GameState => {
|
||||||
|
const s = createGame({
|
||||||
|
id: 'rot',
|
||||||
|
seed: 4242,
|
||||||
|
config: { ...competitive, optionalRules: { ...competitive.optionalRules, employeeRotation: on } },
|
||||||
|
playerNames: ['Alice', 'Bob', 'Carol'],
|
||||||
|
});
|
||||||
|
s.clock.stage = STAGES_PER_DAY;
|
||||||
|
s.clock.phase = 'shiftChange';
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('is off unless asked for — the clock alone must not move anybody', () => {
|
||||||
|
const s = atDayEnd(false);
|
||||||
|
const before = [...s.seating];
|
||||||
|
advance(s);
|
||||||
|
assert.equal(s.clock.day, 2, 'the clock did not roll over');
|
||||||
|
assert.deepEqual(s.seating, before, 'seats moved with the rule switched off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves every player one chair left at the Day boundary', () => {
|
||||||
|
const s = atDayEnd(true);
|
||||||
|
const before = [...s.seating];
|
||||||
|
advance(s);
|
||||||
|
assert.equal(s.clock.day, 2);
|
||||||
|
// "One chair to the left" is seat + 1, the direction `playerLeftOf` already turns the table.
|
||||||
|
const expected = before.map((_, seat, all) => all[(seat - 1 + all.length) % all.length]!);
|
||||||
|
assert.deepEqual(s.seating, expected);
|
||||||
|
// Everyone moved, and nobody was lost or duplicated on the way round.
|
||||||
|
assert.deepEqual([...s.seating].sort(), [...before].sort());
|
||||||
|
assert.notDeepEqual(s.seating, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes your points and the Fedora with you, and leaves the district behind', () => {
|
||||||
|
const s = atDayEnd(true);
|
||||||
|
const traveller = 1 as PlayerIndex;
|
||||||
|
s.players[traveller]!.revenue = 17;
|
||||||
|
const seatBefore = seatOf(s, traveller);
|
||||||
|
/**
|
||||||
|
* The Fedora is compared against the SAME game with the rule off, not against its own value
|
||||||
|
* before the advance — Stage 12 is a shift change (§5), so it passes here anyway for reasons
|
||||||
|
* that have nothing to do with rotation. What matters is that moving the chairs does not move
|
||||||
|
* it: it names a player, and players are exactly what the rotation does not renumber.
|
||||||
|
*/
|
||||||
|
const control = atDayEnd(false);
|
||||||
|
advance(control);
|
||||||
|
// Identity, not a field: anything mutable is liable to be reset at a Day boundary anyway (the
|
||||||
|
// once-a-Day dispatch reset clears `dispatchUsedToday` right there), and the claim under test
|
||||||
|
// is about which OBJECT is attached to which chair.
|
||||||
|
const districtLeftBehind = areaAtSeat(s, seatBefore);
|
||||||
|
|
||||||
|
advance(s);
|
||||||
|
|
||||||
|
assert.notEqual(seatOf(s, traveller), seatBefore, 'the traveller did not move');
|
||||||
|
assert.equal(s.players[traveller]!.revenue, 17, 'Revenue is keyed by player and must travel');
|
||||||
|
assert.equal(s.clock.superintendent, control.clock.superintendent, 'rotating the chairs moved the Fedora');
|
||||||
|
// The Office stayed exactly where it was, so whoever sits there now inherits it as they find
|
||||||
|
// it. That is the rule rather than a side effect of it — you take over the next station up the
|
||||||
|
// line, mess and all.
|
||||||
|
assert.equal(areaAtSeat(s, seatBefore), districtLeftBehind, 'the district moved with the player');
|
||||||
|
assert.notEqual(areaOf(s, traveller), districtLeftBehind, 'the traveller kept their old district');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says who is now sitting where, by name', () => {
|
||||||
|
const s = atDayEnd(true);
|
||||||
|
const { events } = advance(s);
|
||||||
|
const rotated = events.find((e) => e.type === 'seatsRotated');
|
||||||
|
assert.ok(rotated, 'no seatsRotated event was emitted');
|
||||||
|
const line = narrate(rotated, { playerName: (p) => s.players[p]!.name }).text;
|
||||||
|
for (const name of ['Alice', 'Bob', 'Carol']) assert.match(line, new RegExp(name));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* §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,
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-4
@@ -9,6 +9,7 @@ import { describe, it } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { pump } from '../src/engine/advance.ts';
|
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 type { GameEvent } from '../src/engine/events.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import type { GameConfig } from '../src/engine/state.ts';
|
import type { GameConfig } from '../src/engine/state.ts';
|
||||||
@@ -17,13 +18,17 @@ import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.
|
|||||||
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
||||||
import { summarize } from '../src/sim/stats.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 = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: collectiveRevenueFloor(1, 5),
|
||||||
|
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||||
|
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -58,7 +63,7 @@ const SAMPLES: GameEvent[] = [
|
|||||||
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
|
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
|
||||||
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||||
{ type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
{ type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||||
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper', carIndex: 0 },
|
||||||
{ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' },
|
{ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' },
|
||||||
{ type: 'phaseEnded', player: 0, phase: 'localOps' },
|
{ type: 'phaseEnded', player: 0, phase: 'localOps' },
|
||||||
];
|
];
|
||||||
@@ -446,6 +451,13 @@ describe('the replay behaves like the game it is replaying', () => {
|
|||||||
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
|
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
|
||||||
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
|
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
|
||||||
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
|
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
|
||||||
|
assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound');
|
||||||
|
assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound');
|
||||||
|
// Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not
|
||||||
|
// guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current
|
||||||
|
// stride and seed count. If this starts failing after either changes, widen the pool rather than
|
||||||
|
// deleting the assertion — §10 is the one event a player most needs to hear.
|
||||||
|
assert.ok(kinds.has('crash'), 'a collision never made a sound');
|
||||||
|
|
||||||
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
|
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
|
||||||
// accounting the live game is held to. The switching cues are counted separately: they follow
|
// accounting the live game is held to. The switching cues are counted separately: they follow
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
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,
|
||||||
|
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);
|
||||||
|
assert.equal(result.found, true);
|
||||||
|
if (!result.found) return;
|
||||||
|
assert.deepEqual(result.saved, saved);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('reports the version that wrote the file without judging it', () =>
|
||||||
|
withTempDir(async (dir) => {
|
||||||
|
// Reading a save no longer refuses on the version. The stamp is the PACKAGE version, which
|
||||||
|
// moves for reasons unrelated to the rules, and gating on it destroyed every game in progress
|
||||||
|
// across four releases — one of which only changed how the board is drawn. Whether a save
|
||||||
|
// still replays is decided by replaying it (`tryResumeSession`); the version is kept because
|
||||||
|
// it is worth naming in a failure, and nothing else.
|
||||||
|
await writeGame(dir, saved, '1.2.3');
|
||||||
|
const result = await loadGame(dir);
|
||||||
|
assert.equal(result.found, true);
|
||||||
|
if (!result.found) return;
|
||||||
|
assert.equal(result.storedVersion, '1.2.3');
|
||||||
|
assert.deepEqual(result.saved, saved);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('reports not-found rather than throwing when nothing has been saved yet', () =>
|
||||||
|
withTempDir(async (dir) => {
|
||||||
|
const result = await loadGame(dir);
|
||||||
|
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);
|
||||||
|
assert.equal(result.found, true);
|
||||||
|
if (!result.found) 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);
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* 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 { GameSession, Push } from '../../src/server/session.ts';
|
||||||
|
import { createSession, resumeSession, tryResumeSession } from '../../src/server/session.ts';
|
||||||
|
|
||||||
|
const config: GameConfig = {
|
||||||
|
mode: 'competitive',
|
||||||
|
days: 5,
|
||||||
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: {
|
||||||
|
reducedVisibility: 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a save survives a release that did not change the rules', () => {
|
||||||
|
/** Plays a couple of real moves so the history is worth replaying. */
|
||||||
|
const played = (): ReturnType<GameSession['exportSave']> => {
|
||||||
|
const s = createSession(42, config, ['Alice', 'Bob']);
|
||||||
|
const actor = (s.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex;
|
||||||
|
s.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
|
||||||
|
return s.exportSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('resumes whatever version stamped it, so long as the moves still replay', () => {
|
||||||
|
// This is the whole point. The engine version used to gate this, and it is the PACKAGE version
|
||||||
|
// — it moves for a CSS fix. Four releases in a row destroyed every game in progress, one of
|
||||||
|
// them for a change that only altered how the board is drawn.
|
||||||
|
const saved = played();
|
||||||
|
const r = tryResumeSession(saved);
|
||||||
|
assert.equal(r.ok, true, 'a replayable save was refused');
|
||||||
|
if (!r.ok) return;
|
||||||
|
assert.deepEqual(r.session.exportSave().history, saved.history);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a save whose moves no longer replay, and says which move and why', () => {
|
||||||
|
// A rules change is simulated by corrupting one intent — the engine cannot apply it, which is
|
||||||
|
// exactly the shape a genuinely incompatible save has.
|
||||||
|
const saved = played();
|
||||||
|
const broken = {
|
||||||
|
...saved,
|
||||||
|
history: [...saved.history, { type: 'localOps.choose', option: 'not-a-real-option' } as never],
|
||||||
|
};
|
||||||
|
const r = tryResumeSession(broken);
|
||||||
|
assert.equal(r.ok, false, 'a save the rules reject was accepted');
|
||||||
|
if (r.ok) return;
|
||||||
|
assert.equal(r.failure.of, broken.history.length);
|
||||||
|
assert.equal(r.failure.stoppedAt, broken.history.length - 1, 'wrong move blamed');
|
||||||
|
assert.equal(r.failure.intent, 'localOps.choose');
|
||||||
|
assert.ok(r.failure.code.length > 0, 'no rejection code to act on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never silently truncates — the old loop stopped at a bad move and said nothing', () => {
|
||||||
|
// The silence was survivable only because the version check meant a doomed replay was never
|
||||||
|
// attempted. Now that the replay IS the check, a partial one must be impossible to mistake for
|
||||||
|
// a whole one.
|
||||||
|
const saved = played();
|
||||||
|
const broken = { ...saved, history: [{ type: 'draw.end' } as never, ...saved.history] };
|
||||||
|
const r = tryResumeSession(broken);
|
||||||
|
assert.equal(r.ok, false, 'a truncated replay was returned as a healthy session');
|
||||||
|
});
|
||||||
|
});
|
||||||
+28
-1
@@ -14,11 +14,12 @@
|
|||||||
|
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.ts';
|
import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.ts';
|
||||||
import { createLocalSession } from '../src/web/session.ts';
|
import { createLocalSession } from '../src/web/session.ts';
|
||||||
|
import { seatLabel } from '../src/sim/view.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drive a session by always taking the first offered action.
|
* Drive a session by always taking the first offered action.
|
||||||
@@ -218,6 +219,32 @@ describe('the page stays on the near side of the boundary', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('seats are counted from 1 wherever a person reads them', () => {
|
||||||
|
it('seatLabel shifts the zero-based index the whole engine uses', () => {
|
||||||
|
assert.deepEqual([0, 1, 2, 3].map(seatLabel), [1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no user-facing "Seat N" bypasses it', () => {
|
||||||
|
// The internal convention is zero-based and must stay that way — it indexes `seating`, the
|
||||||
|
// seats array and every route. The DISPLAYED number is the one a player would say out loud, so
|
||||||
|
// the two have to be converted at exactly one place; anything interpolating a raw seat into a
|
||||||
|
// "Seat …" string has quietly reintroduced "Seat 0".
|
||||||
|
const roots = ['src/web', 'src/sim', 'src/server'];
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const dir of roots) {
|
||||||
|
const base = join(import.meta.dirname, '..', dir);
|
||||||
|
for (const name of readdirSync(base, { recursive: true, encoding: 'utf8' })) {
|
||||||
|
if (!name.endsWith('.ts')) continue;
|
||||||
|
const text = readFileSync(join(base, name), 'utf8');
|
||||||
|
for (const m of text.matchAll(/`[^`]*Seat \$\{([^}]*)\}/g)) {
|
||||||
|
if (!m[1]!.includes('seatLabel')) offenders.push(`${dir}/${name}: ${m[0]!.slice(0, 60)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(offenders, [], 'a seat is shown to a player without going through seatLabel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('capabilities say what only a local session can do', () => {
|
describe('capabilities say what only a local session can do', () => {
|
||||||
it('offers undo, a local save and a new deal', () => {
|
it('offers undo, a local save and a new deal', () => {
|
||||||
// The page hides these rather than calling them and failing. A server can offer none of them: it
|
// The page hides these rather than calling them and failing. A server can offer none of them: it
|
||||||
|
|||||||
+13
-7
@@ -34,11 +34,13 @@ import { subdivisions } from '../src/engine/state.ts';
|
|||||||
|
|
||||||
const solitaireConfig: GameConfig = {
|
const solitaireConfig: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -299,12 +301,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(
|
assert.deepEqual(
|
||||||
LENGTH_PROFILES.map((p) => [p.target, p.days]),
|
LENGTH_PROFILES.map((p) => [p.length, p.days]),
|
||||||
[[10, 3], [20, 5], [45, 10]],
|
[['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', () => {
|
it('scales fixed supplies with player count', () => {
|
||||||
|
|||||||
+24
-22
@@ -22,11 +22,13 @@ import { anomalies, strategyBuckets } from '../src/sim/stats.ts';
|
|||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 3,
|
||||||
length: 'short',
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
optionalRules: {
|
optionalRules: {
|
||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
sisterTrains: false,
|
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
},
|
},
|
||||||
@@ -155,7 +157,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.
|
// more cars in total (22 vs 19 over forty games). Widened so it fails for the reason it names.
|
||||||
let spotted = 0;
|
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]) {
|
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);
|
playGame(s, developerBot, pump);
|
||||||
for (const card of s.officeAreas.get(0)!.grid.values()) {
|
for (const card of s.officeAreas.get(0)!.grid.values()) {
|
||||||
if (card.facility?.kind === 'freight') spotted += card.facility.industryTrack.cars.length;
|
if (card.facility?.kind === 'freight') spotted += card.facility.industryTrack.cars.length;
|
||||||
@@ -172,7 +174,7 @@ describe('the revenue chain works end to end (regression)', () => {
|
|||||||
// the old 52-card placeholder it was 10 in 52.
|
// the old 52-card placeholder it was 10 in 52.
|
||||||
let placed = 0;
|
let placed = 0;
|
||||||
for (const seed of [11, 23, 47, 91, 137, 211]) {
|
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);
|
playGame(s, developerBot, pump);
|
||||||
for (const card of s.officeAreas.get(0)!.grid.values()) {
|
for (const card of s.officeAreas.get(0)!.grid.values()) {
|
||||||
if (card.geometry.kind !== 'facility') continue;
|
if (card.geometry.kind !== 'facility') continue;
|
||||||
@@ -206,7 +208,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.
|
// "downward only" is gone, and with it the rule that rejected every square above row 0.
|
||||||
const rows = new Set<number>();
|
const rows = new Set<number>();
|
||||||
for (const seed of [3, 9, 27, 81]) {
|
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);
|
playGame(s, developerBot, pump);
|
||||||
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
|
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
|
||||||
}
|
}
|
||||||
@@ -272,7 +274,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,
|
// 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.
|
// a hand, or on the board — grid cells, enhancements laid on them, and Mainline modifiers.
|
||||||
for (const seed of [3, 17, 91]) {
|
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;
|
const total = s.cards.size;
|
||||||
playGame(s, developerBot, pump);
|
playGame(s, developerBot, pump);
|
||||||
|
|
||||||
@@ -294,7 +296,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
|
// 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
|
// 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.
|
// 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;
|
const before = s.cards.size;
|
||||||
playGame(s, developerBot, pump);
|
playGame(s, developerBot, pump);
|
||||||
const loose =
|
const loose =
|
||||||
@@ -345,8 +347,8 @@ describe('end-of-game statistics', () => {
|
|||||||
* BOT no longer gets a crew next to an industry: it builds shallow districts and places 2.2
|
* BOT no longer gets a crew next to an industry: it builds shallow districts and places 2.2
|
||||||
* facilities a game, down from 3.8 when industries could sit on the main.
|
* facilities a game, down from 3.8 when industries could sit on the main.
|
||||||
*
|
*
|
||||||
* That is the bot's district-building weakness, which is the top item in TODO.md, not a rule
|
* That is the bot's district-building weakness, tracked in TODO.md under Bot Performance, not a
|
||||||
* that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
|
* rule that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
|
||||||
* and so removing this line is what proves the bot has been fixed.
|
* and so removing this line is what proves the bot has been fixed.
|
||||||
*/
|
*/
|
||||||
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch'];
|
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch'];
|
||||||
@@ -541,7 +543,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `sd-${i}`,
|
id: `sd-${i}`,
|
||||||
seed: 1000 + i * 7919,
|
seed: 1000 + i * 7919,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, days: 5 },
|
||||||
playerNames: ['bot'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
playGame(s, developerBot, pump);
|
playGame(s, developerBot, pump);
|
||||||
@@ -639,7 +641,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `cs-${i}`,
|
id: `cs-${i}`,
|
||||||
seed: 1000 + i * 7919,
|
seed: 1000 + i * 7919,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, days: 5 },
|
||||||
playerNames: ['bot'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
const r = playGame(s, developerBot, pump);
|
const r = playGame(s, developerBot, pump);
|
||||||
@@ -723,7 +725,7 @@ describe('the bot does not throw away its own freight (regression)', () => {
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `uj-${i}`,
|
id: `uj-${i}`,
|
||||||
seed: 1000 + i * 7919,
|
seed: 1000 + i * 7919,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, days: 5 },
|
||||||
playerNames: ['bot'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
const r = playGame(s, developerBot, pump);
|
const r = playGame(s, developerBot, pump);
|
||||||
@@ -752,7 +754,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
|
// 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
|
// 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).
|
// 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)!;
|
const area = s.officeAreas.get(0)!;
|
||||||
area.grid.set('-1,0', {
|
area.grid.set('-1,0', {
|
||||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
@@ -797,7 +799,7 @@ describe('Enhancements can reach the board at all (regression)', () => {
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `st-${i}`,
|
id: `st-${i}`,
|
||||||
seed: 1000 + i * 7919,
|
seed: 1000 + i * 7919,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, days: 5 },
|
||||||
playerNames: ['bot'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
playGame(s, developerBot, pump);
|
playGame(s, developerBot, pump);
|
||||||
@@ -834,7 +836,7 @@ describe('Enhancements can reach the board at all (regression)', () => {
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `il-${i}`,
|
id: `il-${i}`,
|
||||||
seed: 1000 + i * 7919,
|
seed: 1000 + i * 7919,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, days: 5 },
|
||||||
playerNames: ['bot'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
playGame(s, developerBot, pump);
|
playGame(s, developerBot, pump);
|
||||||
@@ -872,7 +874,7 @@ describe('rolling stock returns to service (regression)', () => {
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: 'refill',
|
id: 'refill',
|
||||||
seed: 4,
|
seed: 4,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, days: 5 },
|
||||||
playerNames: ['bot'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -911,7 +913,7 @@ describe('measurement discipline', () => {
|
|||||||
// the observer hook cannot drift from the log, so probes have one trustworthy way in and no
|
// 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.
|
// reason to hand-roll the drive loop again.
|
||||||
const seen: string[] = [];
|
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));
|
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');
|
assert.ok(out.events.length > 0, 'a finished game must produce events');
|
||||||
@@ -923,7 +925,7 @@ describe('measurement discipline', () => {
|
|||||||
// Asserted across several seeds because a single game may never draw a Depot card.
|
// Asserted across several seeds because a single game may never draw a Depot card.
|
||||||
let upgrades = 0;
|
let upgrades = 0;
|
||||||
for (let seed = 0; seed < 25; seed++) {
|
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);
|
const out = playGame(s, developerBot, pump);
|
||||||
upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length;
|
upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length;
|
||||||
}
|
}
|
||||||
@@ -943,7 +945,7 @@ describe('the bot does not lay track that cannot work (regression)', () => {
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `lay-${seed}`,
|
id: `lay-${seed}`,
|
||||||
seed,
|
seed,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, days: 5 },
|
||||||
playerNames: ['Solitaire'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
const laid: Lay[] = [];
|
const laid: Lay[] = [];
|
||||||
@@ -996,7 +998,7 @@ describe('the bot does not lay track that cannot work (regression)', () => {
|
|||||||
for (const seed of SEEDS) {
|
for (const seed of SEEDS) {
|
||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `dp-${seed}`, seed,
|
id: `dp-${seed}`, seed,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, days: 5 },
|
||||||
playerNames: ['Solitaire'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
const spy = {
|
const spy = {
|
||||||
@@ -1085,7 +1087,7 @@ describe('the freight figures count both halves (regression)', () => {
|
|||||||
const seed = 1000 + i * 7919;
|
const seed = 1000 + i * 7919;
|
||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `fu-${seed}`, seed,
|
id: `fu-${seed}`, seed,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, days: 5 },
|
||||||
playerNames: ['bot'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
const r = playGame(s, developerBot, pump);
|
const r = playGame(s, developerBot, pump);
|
||||||
|
|||||||
+12
-2
@@ -128,8 +128,8 @@ function gameWith(area: OfficeArea): GameState {
|
|||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: 'g', seed: 5,
|
id: 'g', seed: 5,
|
||||||
config: {
|
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 },
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
},
|
},
|
||||||
playerNames: ['p'],
|
playerNames: ['p'],
|
||||||
});
|
});
|
||||||
@@ -563,6 +563,16 @@ describe('placement and drop-off', () => {
|
|||||||
assert.ok(canDropCarsAt(area, at(0, 1)), 'ordinary Operational Rail');
|
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', () => {
|
it('forbids dropping cars on a locked industry track', () => {
|
||||||
const area = areaFrom(
|
const area = areaFrom(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ import { trainRules } from '../src/sim/view.ts';
|
|||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: 0,
|
||||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
};
|
};
|
||||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||||
@@ -46,9 +49,9 @@ function switching(
|
|||||||
): string {
|
): string {
|
||||||
const area = areaOf(s, 0);
|
const area = areaOf(s, 0);
|
||||||
const office = area.officeCoord;
|
const office = area.officeCoord;
|
||||||
// The crew stands one square west of the Office and works westward. It may NOT stand on the Office
|
// The crew stands one square west of the Office and works westward, away from the Office square's
|
||||||
// itself: §A.4 forbids leaving Rolling Stock on the Office track, so every drop there is refused
|
// own special-cased drop rule (§A.4 forbids everything but a coach there, v0.5.0) — tests that want
|
||||||
// whatever the train's own card says.
|
// that exception exercise it directly instead.
|
||||||
const here = { row: office.row, col: office.col - 1 };
|
const here = { row: office.row, col: office.col - 1 };
|
||||||
area.grid.set(coordKey(here), straight());
|
area.grid.set(coordKey(here), straight());
|
||||||
area.grid.set(coordKey({ row: office.row, col: office.col - 2 }), straight(westCars));
|
area.grid.set(coordKey({ row: office.row, col: office.col - 2 }), straight(westCars));
|
||||||
@@ -76,13 +79,23 @@ const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('§7 — no switching', () => {
|
describe('§7 — no switching', () => {
|
||||||
it('refuses every switching act to a train whose card forbids it', () => {
|
it('still allows a plain move — "no switching" means no adding or dropping cars', () => {
|
||||||
// Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains.
|
// Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains.
|
||||||
// Moving, setting out and sorting are all switching, so all three are refused.
|
// A train held at the Office may still need to clear onto Secondary Track ahead of other
|
||||||
|
// traffic, so a move that couples nothing is legal.
|
||||||
const s = game();
|
const s = game();
|
||||||
switching(s, 1, false, [boxcar()]);
|
switching(s, 1, false, [boxcar()]);
|
||||||
|
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), 'NO_SWITCHING');
|
it('refuses a move that would couple a car, and refuses dropping and sorting outright', () => {
|
||||||
|
const s = game();
|
||||||
|
switching(s, 1, false, [boxcar()], [boxcar()]);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
|
||||||
|
'PICKUP_NOT_ALLOWED',
|
||||||
|
);
|
||||||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING');
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING');
|
||||||
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING');
|
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING');
|
||||||
});
|
});
|
||||||
@@ -192,6 +205,41 @@ describe('§7 — the Local keeps its coach (trains 7/8)', () => {
|
|||||||
switching(s, 9, false, [boxcar(), coach()]);
|
switching(s, 9, false, [boxcar(), coach()]);
|
||||||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
|
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', () => {
|
describe('§7 — trains Porters may not work', () => {
|
||||||
|
|||||||
+554
-59
@@ -42,7 +42,21 @@ import {
|
|||||||
} from '../src/web/game.ts';
|
} from '../src/web/game.ts';
|
||||||
|
|
||||||
const root = join(import.meta.dirname, '..');
|
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');
|
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. */
|
/** Play a whole game by always taking the first offered action. */
|
||||||
function playThrough(seed: number, maxTurns = 20_000) {
|
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]) }));
|
.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[] => [
|
* A 45° leg is a curved `<path>` now (v0.5.0), not straight `<line>`s — see `curvedRail` in
|
||||||
...new Set(
|
* `board-svg.ts`. Each returned array is the sampled polyline for one of the leg's two rails, in
|
||||||
draw(links).map((r) => {
|
* order from the through edge to the diagonal edge.
|
||||||
const a = (Math.atan2(r.y2 - r.y1, r.x2 - r.x1) * 180) / Math.PI;
|
*/
|
||||||
return Math.round((((a % 180) + 180) % 180) * 10) / 10;
|
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', () => {
|
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
|
// 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']]) {
|
for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) {
|
||||||
const found = angles(links);
|
const [rail] = drawCurve(links);
|
||||||
assert.ok(found.includes(0), `${links} has no level run along the centre line`);
|
assert.ok(rail && rail.length > 2, `${links} drew no curved leg`);
|
||||||
assert.ok(
|
const start = angleOf(rail[0]!, rail[1]!);
|
||||||
found.includes(45) || found.includes(135),
|
const end = angleOf(rail[rail.length - 2]!, rail[rail.length - 1]!);
|
||||||
`${links} draws its leg at ${found.join('/')}° — the card prints 45°`,
|
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.equal(found.length, 2, `${links} draws ${found.join('/')}° — a leg is a run and one 45° angle`);
|
assert.ok(offDiagonal <= 6, `${links} meets the diagonal edge at ${end}°, not 45°/135°`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws the two diagonals as two different diagonals', () => {
|
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,
|
// 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
|
// so a stacked pair on the same diagonal reads as one continuous curve and a mismatched pair
|
||||||
// as the V it is. Drawn alike, the picture would claim a join the engine refuses.
|
// 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');
|
const family = (links: string[]): 'rising' | 'falling' => {
|
||||||
assert.deepEqual(angles(['nw']), angles(['se']), 'nw and se must lie on the same diagonal');
|
const [rail] = drawCurve(links);
|
||||||
assert.notDeepEqual(angles(['ne']), angles(['nw']), 'the two diagonals must be distinguishable');
|
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', () => {
|
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 W = 166;
|
||||||
const H = 96;
|
const rails = drawCurve(['sw']);
|
||||||
// Only the sloping segments, and only the end of each that lands on the south edge.
|
assert.equal(rails.length, 2, 'the leg is two rails');
|
||||||
const atEdge = draw(['sw'])
|
const atEdge = rails.map((r) => r[r.length - 1]!.x);
|
||||||
.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 mid = (atEdge[0]! + atEdge[1]!) / 2;
|
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})`);
|
assert.ok(Math.abs(mid - W / 2) < 0.5, `the leg crosses the south edge at x=${mid}, not the middle (${W / 2})`);
|
||||||
});
|
});
|
||||||
@@ -841,7 +880,7 @@ describe('board highlighting', () => {
|
|||||||
}
|
}
|
||||||
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
|
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
|
||||||
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
|
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
|
||||||
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
|
assert.ok(sp.label.includes(`(${sp.coord.col}, ${sp.coord.row})`), 'label and coord disagree');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1171,10 +1210,12 @@ describe('the page explains itself', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('states the objective and whether you are keeping up', () => {
|
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));
|
const f = view(newGame(430));
|
||||||
assert.equal(f.objective.target, 20);
|
assert.equal(f.objective.target, 15);
|
||||||
assert.equal(f.objective.days, 5);
|
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/);
|
assert.match(f.objective.note, /Days? left/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1325,11 +1366,17 @@ describe('replays are saves', () => {
|
|||||||
describe('the static build', () => {
|
describe('the static build', () => {
|
||||||
|
|
||||||
it('builds, and needs nothing but a static host', () => {
|
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(distTest, 'index.html')), 'no index.html');
|
||||||
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
|
assert.ok(existsSync(join(distTest, 'web/main.js')), 'no entry script');
|
||||||
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
|
assert.ok(existsSync(join(distTest, 'engine/apply.js')), 'the engine did not emit');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits no import that a browser cannot resolve', () => {
|
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', () => {
|
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
|
// 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:
|
// dropped an element the old script still asked for, so the page threw `missing element:
|
||||||
@@ -1787,7 +2001,19 @@ describe('the static build', () => {
|
|||||||
// click-through happens to visit.
|
// click-through happens to visit.
|
||||||
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
|
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
|
||||||
const out = new Set<number>();
|
const out = new Set<number>();
|
||||||
for (const g of menu.direct) for (const a of g.actions) out.add(a.index);
|
/**
|
||||||
|
* THE SAME EXCLUSION `renderActions()` (main.ts) APPLIES, not the raw menu.
|
||||||
|
*
|
||||||
|
* `menu.direct` alone is what the ENGINE offers; `renderActions()` then drops any group whose
|
||||||
|
* title matches the one `menu.makeUp` already covers, so a group counted here but filtered
|
||||||
|
* there is exactly the gap that stranded a pending Extra with `menu.makeUp` null and its own
|
||||||
|
* "Making up Extra X22…" title caught by the OLD broader regex this once was. Mirroring the
|
||||||
|
* real filter is what makes this test the one that would have caught that.
|
||||||
|
*/
|
||||||
|
const onScreen = menu.direct.filter(
|
||||||
|
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
|
||||||
|
);
|
||||||
|
for (const g of onScreen) for (const a of g.actions) out.add(a.index);
|
||||||
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
|
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
|
||||||
for (const h of menu.hand) {
|
for (const h of menu.hand) {
|
||||||
if (h.playNow !== null) out.add(h.playNow);
|
if (h.playNow !== null) out.add(h.playNow);
|
||||||
@@ -1817,6 +2043,37 @@ describe('the static build', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers "where does this Extra start" on screen, not just in the menu', () => {
|
||||||
|
// REPORTED: the game "hung" mid-Freight-House-work with nothing to click. `newTrain.startExtra`
|
||||||
|
// was legal and present in `menu.direct` — the engine was never stuck — but its group is titled
|
||||||
|
// by `trainCardTitle`, which begins "Making up Extra X22…" exactly like the yard-chip panel's
|
||||||
|
// OWN group. `renderActions()` used to drop every group titled that way, on the assumption only
|
||||||
|
// the yard-chip panel used the prefix; with no tray yet being filled (`menu.makeUp` is null,
|
||||||
|
// since the Extra has not chosen where it starts yet), that group was the only thing offered and
|
||||||
|
// it vanished from the page — a legal decision with zero buttons.
|
||||||
|
const game = newGame(430);
|
||||||
|
game.state.pendingExtras.push(22); // Extra X22 "Pee-Dee" — one caboose, per-diem.
|
||||||
|
game.state.clock.phase = 'newTrain';
|
||||||
|
game.state.clock.currentActor = 0;
|
||||||
|
assert.equal(currentActor(game), 0, 'a decision should be waiting');
|
||||||
|
|
||||||
|
const menu = actionMenu(game);
|
||||||
|
assert.equal(menu.makeUp, null, 'no tray is being filled — the Extra has not started yet');
|
||||||
|
const extraGroup = menu.direct.find((g) => g.actions.some((a) => menu.options[a.index]?.type === 'newTrain.startExtra'));
|
||||||
|
assert.ok(extraGroup, 'newTrain.startExtra should appear in some menu.direct group');
|
||||||
|
assert.match(extraGroup!.title, /^Making up /, 'this is exactly the title shape that used to collide');
|
||||||
|
|
||||||
|
// The actual filter `renderActions()` applies (main.ts) — reproduced here for the reason given
|
||||||
|
// on `reachable()` above: there is no DOM harness in this suite to call the real renderer.
|
||||||
|
const onScreen = menu.direct.filter(
|
||||||
|
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
onScreen.includes(extraGroup!),
|
||||||
|
'the Extra\'s "choose where it starts" group was filtered out along with the yard-chip panel',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders every part of the menu it is given', () => {
|
it('renders every part of the menu it is given', () => {
|
||||||
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
|
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
|
||||||
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
|
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
|
||||||
@@ -2054,6 +2311,27 @@ describe('the static build', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws the crew chip on a train standing away from the Office', () => {
|
||||||
|
// REPORTED: dropping a loaded boxcar off the back of Train 3 at the Freight House made "the
|
||||||
|
// train vanish off the face of the earth". The game state was never wrong — `selectedTrain` was
|
||||||
|
// only ever assigned inside the `cell.adTracks !== null` branch, so a train standing on any card
|
||||||
|
// OTHER than the Office (which is every card it stands on while actually being switched) got no
|
||||||
|
// badge at all: no label, no engine arrow, no cars. This is the Freight House from that report —
|
||||||
|
// an ordinary facility card, `adTracks: null`, one train parked on it mid-switch.
|
||||||
|
const cell = {
|
||||||
|
row: 1, col: 0, kind: 'facility', label: 'Freight House', running: false,
|
||||||
|
enhancements: [], enhancementsWhat: [], adTracks: null,
|
||||||
|
trains: [{
|
||||||
|
trayId: 'tray3', label: 'T3', cars: ['empty boxcar'], engineAt: 0, facing: 'w' as const,
|
||||||
|
what: 'Train 3 "Express"',
|
||||||
|
}],
|
||||||
|
cars: [], standingWest: 0, facility: null, links: ['ew'], what: '',
|
||||||
|
};
|
||||||
|
const svg = officeSvg([cell as never], 0);
|
||||||
|
assert.match(svg, /class="bs-crew"/, 'the crew badge is not drawn off the Office square');
|
||||||
|
assert.match(svg, />T3</, 'the train\'s own label is missing from the board');
|
||||||
|
});
|
||||||
|
|
||||||
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
|
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
|
||||||
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
|
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
|
||||||
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
|
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
|
||||||
@@ -2237,6 +2515,149 @@ describe('the static build', () => {
|
|||||||
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
|
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', () => {
|
it('serves a page that loads the game as a module', () => {
|
||||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||||
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
|
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
|
||||||
@@ -2254,9 +2675,12 @@ describe('the Division map shows the whole route', () => {
|
|||||||
seed: 7,
|
seed: 7,
|
||||||
config: {
|
config: {
|
||||||
mode: players === 1 ? 'solitaire' : 'competitive',
|
mode: players === 1 ? 'solitaire' : 'competitive',
|
||||||
victory: 'highestAfterDays',
|
days: 5,
|
||||||
length: 'standard',
|
minCombinedRevenue: 0,
|
||||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
},
|
},
|
||||||
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
|
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
|
||||||
});
|
});
|
||||||
@@ -2270,8 +2694,8 @@ describe('the Division map shows the whole route', () => {
|
|||||||
id: 'div-run',
|
id: 'div-run',
|
||||||
seed: 7,
|
seed: 7,
|
||||||
config: {
|
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 },
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
},
|
},
|
||||||
playerNames: ['Solitaire'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
@@ -2323,8 +2747,8 @@ describe('the Division map shows the whole route', () => {
|
|||||||
const s = createEngineGame({
|
const s = createEngineGame({
|
||||||
id: 'div-chips', seed: 7,
|
id: 'div-chips', seed: 7,
|
||||||
config: {
|
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 },
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
},
|
},
|
||||||
playerNames: ['Solitaire'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
@@ -2652,8 +3076,8 @@ describe('the tray is an engine plus its Rolling Stock', () => {
|
|||||||
const s = createEngineGame({
|
const s = createEngineGame({
|
||||||
id: 'eng', seed: 1038389,
|
id: 'eng', seed: 1038389,
|
||||||
config: {
|
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 },
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
},
|
},
|
||||||
playerNames: ['Solitaire'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
@@ -2702,8 +3126,8 @@ describe('the tray is an engine plus its Rolling Stock', () => {
|
|||||||
const s = createEngineGame({
|
const s = createEngineGame({
|
||||||
id: 'yards', seed: 1038389,
|
id: 'yards', seed: 1038389,
|
||||||
config: {
|
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 },
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
},
|
},
|
||||||
playerNames: ['Solitaire'],
|
playerNames: ['Solitaire'],
|
||||||
});
|
});
|
||||||
@@ -2754,12 +3178,21 @@ describe('the New Game dialog', () => {
|
|||||||
value,
|
value,
|
||||||
checked: false,
|
checked: false,
|
||||||
}));
|
}));
|
||||||
|
// 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 === 'solitaire',
|
||||||
|
onchange: null as (() => void) | null,
|
||||||
|
}));
|
||||||
let html = '';
|
let html = '';
|
||||||
const node: Record<string, unknown> = {
|
const node: Record<string, unknown> = {
|
||||||
id, value: '', textContent: '', title: '', returnValue: '', open: false,
|
id, value: '', textContent: '', title: '', returnValue: '', open: false,
|
||||||
style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||||
|
checked: false, disabled: false,
|
||||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||||
radios,
|
radios,
|
||||||
|
modeRadios,
|
||||||
addEventListener: (type: string, fn: () => void) =>
|
addEventListener: (type: string, fn: () => void) =>
|
||||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||||
showModal: () => void ((node as { open: boolean }).open = true),
|
showModal: () => void ((node as { open: boolean }).open = true),
|
||||||
@@ -2769,9 +3202,14 @@ describe('the New Game dialog', () => {
|
|||||||
},
|
},
|
||||||
// Only the radio-group selectors the dialog actually uses; anything else is not this
|
// 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.
|
// 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 : []),
|
querySelectorAll: (sel: string) =>
|
||||||
|
sel === 'input[name="ng-hand"]' ? radios : sel === 'input[name="ng-mode"]' ? modeRadios : [],
|
||||||
querySelector: (sel: string) =>
|
querySelector: (sel: string) =>
|
||||||
sel === 'input[name="ng-hand"]:checked' ? (radios.find((r) => r.checked) ?? null) : null,
|
sel === 'input[name="ng-hand"]:checked'
|
||||||
|
? (radios.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) });
|
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||||
return node;
|
return node;
|
||||||
@@ -2824,6 +3262,53 @@ describe('the New Game dialog', () => {
|
|||||||
assert.deepEqual(checked.map((r) => r.value), ['sixRandom'], 'the opening hand in play was not preselected');
|
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 () => {
|
it('puts the seed and all three settings into the URL when it deals', async () => {
|
||||||
const { els, nav } = await load('?seed=430');
|
const { els, nav } = await load('?seed=430');
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
(els.get('newgame')!['onclick'] as () => void)();
|
||||||
@@ -2837,7 +3322,10 @@ describe('the New Game dialog', () => {
|
|||||||
dlg['returnValue'] = 'deal';
|
dlg['returnValue'] = 'deal';
|
||||||
(dlg['close'] as () => void)();
|
(dlg['close'] as () => void)();
|
||||||
|
|
||||||
assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&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 () => {
|
it('deals nothing on cancel, and nothing on Esc', async () => {
|
||||||
@@ -2856,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 () => {
|
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
|
// 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.
|
// search string — and assigning `location.search` the value it already holds does nothing. Spells
|
||||||
const url = '?hand=threeRandom&passenger=1&freight=1&transit=0';
|
// 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);
|
const { els, nav } = await load(url);
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
(els.get('newgame')!['onclick'] as () => void)();
|
||||||
const dlg = els.get('newgamedlg')!;
|
const dlg = els.get('newgamedlg')!;
|
||||||
@@ -2886,7 +3376,11 @@ describe('the New Game dialog', () => {
|
|||||||
dlg['returnValue'] = 'deal';
|
dlg['returnValue'] = 'deal';
|
||||||
(dlg['close'] as () => void)();
|
(dlg['close'] as () => void)();
|
||||||
|
|
||||||
assert.equal(nav.search, '?hand=threeRandom&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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3113,18 +3607,19 @@ describe('two crews switching are told apart', () => {
|
|||||||
assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from);
|
assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves out a train the rules will not let switch at all', () => {
|
it('still offers a no-switching train as a crew to switch — it may be moved, just not worked', () => {
|
||||||
// `movesFor` is pure track geometry; six cards print "no switching" and `check` enforces it.
|
// v0.4.9 — six cards print "no switching", which means may not couple, set out or sort, not
|
||||||
// Offering the Circus Train in a "which train are you switching?" row offers a train whose
|
// "may never be touched": a train held at the Office may still need to clear onto Secondary
|
||||||
// every move is refused.
|
// Track ahead of other traffic. `check` refuses the coupling itself, not the move, so this row
|
||||||
|
// is not filtered any differently for a no-switching train than for any other.
|
||||||
const game = twoCrews();
|
const game = twoCrews();
|
||||||
const s = game.state;
|
const s = game.state;
|
||||||
const circus = [...s.trays.values()][0]!;
|
const circus = [...s.trays.values()][0]!;
|
||||||
circus.trainNumber = 18;
|
circus.trainNumber = 18;
|
||||||
circus.trainIsExtra = true; // X18 Circus Train — noSwitching
|
circus.trainIsExtra = true; // X18 Circus Train — noSwitching
|
||||||
const f = view(game);
|
const f = view(game);
|
||||||
assert.equal(f.moves.length, 1, 'a no-switching train was offered as a crew to switch');
|
assert.equal(f.moves.length, 2, 'a no-switching train was left out of the crews offered to switch');
|
||||||
assert.equal(f.moves[0]!.label, 'Train 12');
|
assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 12', 'Train X18']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user