Compare commits

...
6 Commits
Author SHA1 Message Date
Jesse.Markowitz 40f07b0710 v0.6.0 — saves survive a release, Employee Rotation is real, and the lobby
asks what game you want

Three queued items. The last 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 legal under old rules may not be legal
under 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. 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 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 IMPLEMENTED, SISTER TRAINS IS DELETED.

Two of the four optional-rule flags were read by nothing at all. Employee
Rotation 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, and the Office, district, grid and any trains standing in it stay
with the chair. Inheriting 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 built: Q9 records that the Second
Section card supersedes it, and that card exists, 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 name, a mode and a table size; every other dial
was 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. The seed is honoured, so a game can
be reproduced or compared.

Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked
both ways — disabling the rotation and turning the table the wrong way each
fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected
illegal move was refused by name, and a create with every dial set to a
non-default value came back out of game.json with all of them intact,
including seed 777.

Two of my own assertions were wrong on the way and the tests caught them:
the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared
against its own earlier value, and dispatchUsedToday is cleared at every
Day boundary so it cannot mark a district.
2026-08-21 21:45:34 -04:00
Jesse 51710498f5 v0.5.6 — seats counted from 1, a name you can read, and a line that stops repeating
Three things off the first proper look at a live table.

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 at a table calls their chair "seat 0". There
were four of these rather than 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 now, and a test fails the
build if any "Seat ${...}" interpolates a raw seat again, since the
conversion has to happen in exactly one place or the two conventions drift
apart. Verified by mutation — putting the raw seat back fails the suite.

seatLabel lives in sim/view.ts, not web/game.ts. Putting it in game.ts was
the first attempt and test/session.test.ts caught it: the page may not
import values from that module, because they are the local engine by
another name and importing one reopens the Phase 1 boundary. The test was
right and the placement was wrong.

.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, so the one name you most need to read was the one you could
not. The bump is gone; amber against #e6e9ee was always doing the work, and
blue "(you)" and amber "their move" stay clearly distinct without it.

The west-to-east chain under the Division map now shows only during Day 1
Stage 1. It 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 Stage 2 the map has been answering it for a while and the line is
something to read past.

675 tests pass (673 + 2).
2026-08-21 20:53:17 -04:00
Jesse bfd2708ecc v0.5.5 — a remembered session for a game that no longer exists
Reported after updating to v0.5.4: clicking Multiplayer went straight into
a game with no lobby and no controls, and the board was blank.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The probe's own test was vacuous on the first attempt — both its "closes"
cases reached close() through the .catch arm, so deleting the body-naming
check outright still passed. Caught by mutating splash.ts and re-running;
the test now covers all three closing routes and fails without the check.
2026-08-21 11:00:06 -04:00
45 changed files with 1948 additions and 163 deletions
+302
View File
@@ -19,6 +19,308 @@ 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 ## 0.5.1 — 2026-08-21
Multiplayer Phase 4 — lobby, sessions, reconnection (`docs/architecture/multiplayer.md` §12 steps Multiplayer Phase 4 — lobby, sessions, reconnection (`docs/architecture/multiplayer.md` §12 steps
+135 -3
View File
@@ -21,6 +21,15 @@ Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplaye
below. below.
3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below. 3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below.
Queued 2026-08-21, from playing the StartOS build:
4. ~~**The lobby must offer every game parameter the solitaire New Game dialog does**~~ — done in
v0.6.0.
5. ~~**Decide what the four `optionalRules` are**~~ — done in v0.6.0: `sisterTrains` deleted,
`employeeRotation` implemented, the other two were already live.
6. ~~**Stop every release destroying every game in progress**~~ — done in v0.6.0, by replaying the
save rather than comparing version strings.
Nothing else queued at the moment. Nothing else queued at the moment.
--- ---
@@ -395,9 +404,10 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
at a real table; the reasoning worth keeping is that **deny** is the safe default, since a at a real table; the reasoning worth keeping is that **deny** is the safe default, since a
held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor. held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in - **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes v0.4.1**, and **displayed in v0.5.4**. It orders the whole chain, west to east by ascending
it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain roll; `openingRolls` is on the `Frame` now and the play page prints the chain under the
rather than only the result (`lobby-and-sessions.md` §4). Division map — *West to East: Alice (1) → Bot 2 (5) → Bot 1 (11)* — so the rolls that formed
it are visible rather than only their result (`lobby-and-sessions.md` §4).
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create - **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
pointed at the open internet for long. Note that one-game-at-a-time per person is expected pointed at the open internet for long. Note that one-game-at-a-time per person is expected
@@ -479,6 +489,128 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now, and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now,
2026-08-20. 2026-08-20.
- [x] **~~The lobby's seat controls could not express "nobody in this chair"~~ — done in v0.5.3.**
Raised by Jesse 2026-08-21. The seats array grew as people joined, so the four rows on screen
were partly fictional: a 2-player game simply started with a 2-long array, and a host who
added a bot to a later chair padded the array with a `null` that silently disabled Start
behind a one-line note. **The host now picks the table size (2-4) when creating the game**
and the array is built at that length once, so a gap cannot be expressed rather than merely
being rejected. That also removed the need to compact seats at `Lobby.Start` — which would
have shifted the `player` index every `PlayerSession` records at join time and that
`/api/stream` and `/api/intent` route by, quietly handing a player somebody else's railroad.
Tested in `test/server/lobby.test.ts` ("seat index is player index, with no compaction to
shift it", "never grows the table, whoever asks", "refuses a chair that is not at the
table").
- [x] **~~EVERY RELEASE DESTROYS EVERY GAME IN PROGRESS~~ — fixed in v0.6.0, by option 3.**
Raised 2026-08-21 after v0.5.2, v0.5.3 and v0.5.4 each killed the games on the StartOS box in
turn — v0.5.4's changes were *rendering only*, and it still refused two saved games.
**Why it happens, and why the design is right as far as it goes.** A save is a seed plus a
list of intents (D5), so loading one means replaying those intents through the current engine.
A move that was legal under the old rules may be rejected under the new ones, and a
half-replayed game is worse than no game — so `loadGame` refuses on any `engineVersion`
mismatch and `index.ts` logs it and carries on (D7). Nothing is deleted; rolling the version
back makes the games loadable again. That is all correct. The problem is only that the test is
**exact equality against the package version**, which moves for reasons that have nothing to
do with the rules.
**Why it is getting worse rather than better.** It was harmless while Jesse was the only
player. It stops being acceptable the moment other people are seated: their game is destroyed
because somebody shipped a CSS fix. It also interacts badly with the stranded-session bug
fixed in v0.5.5 — the refusal is precisely what stranded a browser on a blank page.
Three ways out, cheapest first:
1. **A separate rules version, bumped by hand.** `RULES_VERSION` in `content.ts`, stamped into
the save instead of `package.json`'s version, and raised only when a change can alter
whether an intent is legal. v0.5.4 would not have touched it and both games would have
survived. Cheapest and the least clever, but it is a judgement call on every release, and
getting it wrong silently corrupts a game rather than refusing it — the failure is worse
than the one it replaces.
2. **A declared compatibility floor.** The save records the version that wrote it; the engine
declares the oldest save it will accept. Loading checks `saved >= floor` rather than
`saved === current`. Same judgement call as (1), but expressed as a range, which makes
"this release breaks saves" an explicit act rather than the default.
3. **Verify rather than assume — replay and see.** Load the save, replay it, and refuse only
if an intent actually rejects. This is the honest test and needs no judgement at all: it
answers the real question ("does this game still replay?") instead of a proxy for it. It
costs a full replay per game on boot, which is ~100 ms per finished game (measured
2026-08-21) and only unfinished games are loaded — so at any realistic table count it is
free. The work is in reporting a partial failure well: the game is intact up to the
rejected intent, and a player would probably rather resume there than lose it entirely.
**(3) was done.** `loadGame` no longer looks at the version; `tryResumeSession` replays the
save and reports the first intent the engine refuses, and `index.ts` resumes or refuses on
that. A save stamped with a version the server has never run now resumes, provided its moves
replay — verified against a file hand-stamped `0.4.9-ancient`. A save that genuinely does not
replay is refused as before, but the log now names the move: *"move 3 of 8
(localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN"*.
One thing deliberately NOT done: resuming a partially-replayable game at the last good move.
The note above suggested a player would rather have that than nothing, and on reflection it
is worse — the game would silently rewind to a position nobody played to, and the browsers
holding a later Frame would have no idea. Refusing keeps the file intact, so putting the
previous version back still recovers the game. Revisit only with a way to tell the table what
happened.
- [x] **~~THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING~~ — resolved
in v0.6.0.** `sisterTrains` is deleted: Q9 records that the Second Section card supersedes it,
and that card is built. `employeeRotation` is implemented — the rotation is four lines in
`advance.ts` because the seat/player split (D9) exists precisely for it, so Revenue, hands and
the Fedora travel with the player and the district stays with the chair. All three survivors
are now settable from the lobby. Original reasoning kept below.
**Original note:** Split out
at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby
item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`,
`sisterTrains`, `employeeRotation` and `emergencyToolbox`. Neither the solitaire New Game
dialog nor the lobby exposes any of them, and every construction site in the codebase
hardcodes all four to `false` (`web/game.ts`, `sim/harness.ts`, `sim/replay.ts`,
`sim/compare.ts`), so no game has ever been played with one on.
**Check what is real before building a form for it.** Only two are wired:
| rule | status |
| --- | --- |
| `reducedVisibility` | **live** — read at `advance.ts:53`, gates on `NIGHT_STAGES` |
| `emergencyToolbox` | **live** — read at `setup.ts:374`, seeds each player's Red Flags |
| `sisterTrains` | **nothing reads it.** Declared, defaulted, never consulted — and §9a Q9 records that the Second Section card *supersedes* the Sister Trains optional rule, so this flag is most likely dead rather than unbuilt. Decide whether to implement or delete it |
| `employeeRotation` | **nothing reads it.** Declared, defaulted, never consulted. Note the seat/player split (Phase 0, D9) was built specifically so this rule *could* exist — the groundwork is there, the rule is not |
So a dialog listing all four would offer two working toggles beside two that silently do
nothing — the exact failure `checkPlay`'s `NOT_IMPLEMENTED` and `enhancementText`'s
live/dormant/unbuilt table exist to prevent. Either implement the two dead ones, delete
them, or label them on screen the way an unbuilt Enhancement already labels itself. Doing
that is what decides whether this is a UI job or a rules job.
- [x] **~~THE LOBBY OFFERS NO GAME PARAMETERS AT ALL~~ — done in v0.6.0.** A "Game settings" block
on the create form carries the same dials the solitaire dialog has — seed, starting hand, the
three revenue rates, days, the combined-Revenue floor, both collision caps, the PvP toggle —
plus the three surviving optional rules. Mode and table size set the defaults and every field
stays editable, matching the solitaire dialog's own behaviour. Original note below.
**Original note:** Raised by
Jesse 2026-08-21 after playing the StartOS build. Creating a multiplayer game asks for a
display name and a mode, and nothing else — every other dial comes from
`defaultMultiplayerConfig(mode)` (`web/game.ts`), hardcoded, with no way to change it.
Solitaire's New Game dialog (`play.html`, `#ng-*`) asks for all of it: seed, starting hand
(`ng-hand` — three random / six random / three track + three other), the three revenue rates
(`ng-passenger` / `ng-freight` / `ng-transit`), `days`, `minCombinedRevenue`,
`maxCollisionsPerDay`, `maxCollisionsTotal` and `pvpCardsAllowed`. Multiplayer should ask for
the same set. Note that `GameConfig.optionalRules` (reduced visibility, sister trains,
employee rotation, emergency toolbox) is exposed by NEITHER dialog and is hardcoded false in
both — worth deciding on separately rather than folding in silently.
**~~The bug this hid~~ — fixed in v0.5.3.** `defaultMultiplayerConfig` defaults to
`players = 4` and `lobby.ts` called it without the argument, so `minCombinedRevenue` was
always `collectiveRevenueFloor(4, 5)` = 60 whatever the table's real size — a 2-player game
played against a floor meant for four (60 rather than 3x2x5 = 30), and missing that floor
means *everyone loses*. It fell out of the seat-control change: the host now picks the table
size when creating the game, so the real count reaches `defaultMultiplayerConfig` and the
ordering problem that caused this (config fixed at CREATE, seat count unknown until START)
no longer exists. **The form itself is still missing** — that is what this item is now.
- [ ] **D19's switching-instrumentation still needs writing, once real people are playing.** "13% - [ ] **D19's switching-instrumentation still needs writing, once real people are playing.** "13%
for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts` for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts`
or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land
+22
View File
@@ -245,9 +245,31 @@ special handling: `pump` stops, and the next push simply carries a `Menu` contai
POST /api/lobby/create, /api/lobby/join lobby POST /api/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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.5.1", "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",
+24
View File
@@ -1077,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 {
@@ -1113,6 +1114,29 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
* "the table's score is everyone's Revenue summed" model — winner stays null, the achievement is * "the table's score is everyone's Revenue summed" model — winner stays null, the achievement is
* shared — now against the same configurable floor. * 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 daysElapsed = s.clock.day - 1; const daysElapsed = s.clock.day - 1;
if (daysElapsed < s.config.days) return false; if (daysElapsed < s.config.days) return false;
+2
View File
@@ -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
-1
View File
@@ -583,7 +583,6 @@ export type GameConfig = {
pvpCardsAllowed: boolean; pvpCardsAllowed: boolean;
optionalRules: { optionalRules: {
reducedVisibility: boolean; reducedVisibility: boolean;
sisterTrains: boolean;
employeeRotation: boolean; employeeRotation: boolean;
emergencyToolbox: boolean; emergencyToolbox: boolean;
}; };
+177 -4
View File
@@ -27,8 +27,11 @@ import type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts'; import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { import {
appendTiming, appendTiming,
deleteGame,
deleteLobby, deleteLobby,
gameDir, gameDir,
readIndex,
removeIndexEntry,
upsertIndexEntry, upsertIndexEntry,
writeGame, writeGame,
writeLobby, writeLobby,
@@ -39,6 +42,7 @@ import type { GameSession, Push } from './session.ts';
import { import {
createLobby, createLobby,
freshGameCode, freshGameCode,
playerCountAllowed,
joinLobby, joinLobby,
reassignHost, reassignHost,
setBotSeat, setBotSeat,
@@ -58,6 +62,14 @@ export type ServerOptions = {
dataDir: string; dataDir: string;
/** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */ /** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */
engineVersion: string; 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. */ /** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */
initialGames: Map<string, GameSession>; initialGames: Map<string, GameSession>;
initialLobbies: Map<string, Lobby>; initialLobbies: Map<string, Lobby>;
@@ -197,20 +209,154 @@ export function startServer(opts: ServerOptions): void {
void (async () => { void (async () => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); 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) -------------------------- // -- Lobby: creating and joining (the door — join-secret gated) --------------------------
if (url.pathname === '/api/lobby/create' && req.method === 'POST') { if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string }; const body = (await readJson(req)) as {
secret?: string;
config?: GameConfig;
displayName?: string;
players?: number;
seed?: number | null;
};
if (body.secret !== opts.joinSecret) { if (body.secret !== opts.joinSecret) {
sendJson(res, 403, { error: 'bad or missing secret' }); sendJson(res, 403, { error: 'bad or missing secret' });
return; return;
} }
if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') { if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
sendJson(res, 400, { error: 'expected { secret, config, displayName }' }); 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; return;
} }
const gameCode = freshGameCode((code) => gameCodes.has(code)); const gameCode = freshGameCode((code) => gameCodes.has(code));
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode); 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 persistLobby(lobby);
await persistSession(session); await persistSession(session);
sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player }); sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
@@ -285,7 +431,13 @@ export function startServer(opts: ServerOptions): void {
sendJson(res, 409, { error: result.code }); sendJson(res, 409, { error: result.code });
return; return;
} }
const session = createSession(Math.floor(Math.random() * 1e9), lobby.config, result.playerNames, result.botSeats); // 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); games.set(lobby.gameId, session);
lobbies.delete(lobby.gameId); lobbies.delete(lobby.gameId);
// Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game // Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game
@@ -337,6 +489,27 @@ export function startServer(opts: ServerOptions): void {
// -- The running game (token-authenticated) ------------------------------------------------ // -- 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') { if (url.pathname === '/api/stream' && req.method === 'GET') {
const token = url.searchParams.get('token') ?? ''; const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token); const ps = sessions.get(token);
+38 -13
View File
@@ -13,13 +13,20 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { startServer } from './http.ts'; import { startServer } from './http.ts';
import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.ts'; import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.ts';
import { resumeSession } from './session.ts'; import { tryResumeSession } from './session.ts';
import type { GameSession } from './session.ts'; import type { GameSession } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts'; import type { Lobby, PlayerSession } from './lobby.ts';
const port = Number(process.env['PORT'] ?? 8081); const port = Number(process.env['PORT'] ?? 8081);
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0'; const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
const joinSecret = process.env['JOIN_SECRET']; 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 distDir = resolve(process.env['DIST_DIR'] ?? 'dist');
const dataDir = resolve(process.env['DATA_DIR'] ?? 'data'); const dataDir = resolve(process.env['DATA_DIR'] ?? 'data');
@@ -38,6 +45,11 @@ const initialLobbies = new Map<string, Lobby>();
const initialSessions = new Map<string, PlayerSession>(); const initialSessions = new Map<string, PlayerSession>();
const index = await readIndex(dataDir); 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) { for (const entry of index) {
const sessions = await readSessions(dataDir, entry.gameId); const sessions = await readSessions(dataDir, entry.gameId);
for (const s of sessions) initialSessions.set(s.token, s); for (const s of sessions) initialSessions.set(s.token, s);
@@ -48,18 +60,27 @@ for (const entry of index) {
continue; continue;
} }
const loaded = await loadGame(gameDir(dataDir, entry.gameId), engineVersion); const loaded = await loadGame(gameDir(dataDir, entry.gameId));
if (loaded.found && loaded.ok) { if (loaded.found) {
initialGames.set(entry.gameId, resumeSession(loaded.saved)); const resumed = tryResumeSession(loaded.saved);
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`); if (resumed.ok) {
} else if (loaded.found && !loaded.ok) { initialGames.set(entry.gameId, resumed.session);
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
// under. The file is left untouched: rolling the running version back would let it load again. } else {
console.error( /**
`Refusing to resume ${entry.gameId} (${entry.gameCode}): saved under engine version ` + * The save does not replay under these rules, which is the only thing that has ever actually
`${loaded.storedVersion}, this server is running ${engineVersion}. Left untouched, and ` + * mattered — and now the only thing asked. Says which move it choked on, because "some
`will not appear as an active game until the version matches again.`, * 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 // `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). // forward, and their files stay on disk for post-game replay (`lobby-and-sessions.md` §6).
@@ -69,6 +90,7 @@ startServer({
port, port,
bindAddress, bindAddress,
joinSecret, joinSecret,
adminSecret,
distDir, distDir,
dataDir, dataDir,
engineVersion, engineVersion,
@@ -80,3 +102,6 @@ console.log(
`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` + `Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` +
`${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`, `${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.');
}
+51 -11
View File
@@ -41,6 +41,12 @@ export type Lobby = {
* replacement is unambiguous (`lobby-and-sessions.md` §2: "earliest-joined remaining player"). */ * replacement is unambiguous (`lobby-and-sessions.md` §2: "earliest-joined remaining player"). */
joinOrder: string[]; joinOrder: string[];
createdAt: number; 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 CreateResult = { lobby: Lobby; session: PlayerSession };
@@ -81,18 +87,44 @@ export function playerCountAllowed(mode: GameConfig['mode'], count: number): boo
} }
/** The creating player is the host and takes seat 0 (`lobby-and-sessions.md` §2). */ /** The creating player is the host and takes seat 0 (`lobby-and-sessions.md` §2). */
export function createLobby(config: GameConfig, hostDisplayName: string, gameCode: string): CreateResult { /**
* 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 gameId = randomUUID();
const token = randomUUID(); const token = randomUUID();
const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName }; 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 = { const lobby: Lobby = {
gameId, gameId,
gameCode, gameCode,
hostToken: token, hostToken: token,
config, config,
seats: [{ kind: 'human', token, displayName: hostDisplayName }], seats,
joinOrder: [token], joinOrder: [token],
createdAt: Date.now(), createdAt: Date.now(),
seed,
}; };
return { lobby, session }; return { lobby, session };
} }
@@ -103,10 +135,10 @@ export function createLobby(config: GameConfig, hostDisplayName: string, gameCod
* play in, but the running count is checked against `playerCountAllowed` at every join too, so a * 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). */ * lobby can never grow the seats array past what could legally start). */
export function joinLobby(lobby: Lobby, displayName: string): JoinResult { export function joinLobby(lobby: Lobby, displayName: string): JoinResult {
const cap = lobby.config.mode === 'solitaire' ? 1 : 4; // The table was sized at creation, so joining takes an empty chair or none at all — there is no
const empty = lobby.seats.findIndex((s) => s === null); // longer an "append another seat" path for a late arrival to grow the game through.
const seatIndex = empty >= 0 ? empty : lobby.seats.length; const seatIndex = lobby.seats.findIndex((s) => s === null);
if (seatIndex >= cap) return { ok: false, code: 'LOBBY_FULL' }; if (seatIndex < 0) return { ok: false, code: 'LOBBY_FULL' };
const token = randomUUID(); const token = randomUUID();
const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName }; const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName };
@@ -124,7 +156,9 @@ export function joinLobby(lobby: Lobby, displayName: string): JoinResult {
* human seat; the host removes a person by them leaving, not by overwriting their seat. */ * 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 { export function setBotSeat(lobby: Lobby, seat: PlayerIndex, filled: boolean): Lobby {
const seats = [...lobby.seats]; const seats = [...lobby.seats];
while (seats.length <= seat) seats.push(null); // 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 (filled) {
if (seats[seat] !== null) return lobby; if (seats[seat] !== null) return lobby;
seats[seat] = { kind: 'bot' }; seats[seat] = { kind: 'bot' };
@@ -157,11 +191,17 @@ export function reassignHost(lobby: Lobby, departingToken: string): Lobby {
*/ */
export function startLobby(lobby: Lobby, callerToken: string): StartResult { export function startLobby(lobby: Lobby, callerToken: string): StartResult {
if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' }; if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' };
const filled = lobby.seats.filter((s) => s !== null); // Every chair at the table must be taken. The size itself was validated at creation and cannot
if (filled.length !== lobby.seats.length || !playerCountAllowed(lobby.config.mode, filled.length)) { // 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' }; return { ok: false, code: 'BAD_PLAYER_COUNT' };
} }
const playerNames = filled.map((s) => (s!.kind === 'human' ? s.displayName : 'Bot')); // Seat index IS player index — no compaction, because there is nothing to compact past.
const botSeats = filled.flatMap((s, i) => (s!.kind === 'bot' ? [i as PlayerIndex] : [])); 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 }; return { ok: true, playerNames, botSeats };
} }
+37 -10
View File
@@ -11,7 +11,7 @@
* the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet. * the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet.
*/ */
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import type { SavedGame, TurnTiming } from './session.ts'; import type { SavedGame, TurnTiming } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts'; import type { Lobby, PlayerSession } from './lobby.ts';
@@ -38,11 +38,23 @@ export async function writeGame(dataDir: string, saved: SavedGame, engineVersion
export type LoadResult = export type LoadResult =
| { found: false } | { found: false }
| { found: true; ok: true; saved: SavedGame } /** The version that wrote the file, for diagnostics — it is no longer what decides. */
/** §12 step 15 — refused explicitly, never silently replayed under the wrong rules. */ | { found: true; saved: SavedGame; storedVersion: string };
| { found: true; ok: false; storedVersion: string; currentVersion: string };
export async function loadGame(dataDir: string, currentVersion: string): Promise<LoadResult> { /**
* 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; let text: string;
try { try {
text = await readFile(join(dataDir, GAME_FILE), 'utf8'); text = await readFile(join(dataDir, GAME_FILE), 'utf8');
@@ -50,11 +62,8 @@ export async function loadGame(dataDir: string, currentVersion: string): Promise
return { found: false }; return { found: false };
} }
const payload = JSON.parse(text) as PersistedGame; const payload = JSON.parse(text) as PersistedGame;
if (payload.engineVersion !== currentVersion) { const { engineVersion, ...saved } = payload;
return { found: true, ok: false, storedVersion: payload.engineVersion, currentVersion }; return { found: true, saved, storedVersion: engineVersion };
}
const { engineVersion: _engineVersion, ...saved } = payload;
return { found: true, ok: true, saved };
} }
/** Appended once per closed turn span (`GameSession.intent`'s `timing` result) — read-modify-write at /** Appended once per closed turn span (`GameSession.intent`'s `timing` result) — read-modify-write at
@@ -111,6 +120,24 @@ export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry):
await writeIndex(dataDir, entries); 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> { export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> {
const dir = gameDir(dataDir, lobby.gameId); const dir = gameDir(dataDir, lobby.gameId);
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
+97 -4
View File
@@ -26,7 +26,7 @@ import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, subm
import type { Game, Menu } from '../web/game.ts'; import type { Game, Menu } from '../web/game.ts';
import { deltaFrame } from '../sim/frame-delta.ts'; import { deltaFrame } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts'; import type { FrameDelta } from '../sim/frame-delta.ts';
import { snapshot } from '../sim/view.ts'; import { snapshot, seatLabel } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts'; import type { Frame } from '../sim/view.ts';
import { developerBot } from '../sim/bot.ts'; import { developerBot } from '../sim/bot.ts';
@@ -82,6 +82,33 @@ export type SavedGame = {
* guessing from a display name rather than reading a fact. * guessing from a display name rather than reading a fact.
*/ */
botSeats: PlayerIndex[]; 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 = export type IntentResult =
@@ -96,6 +123,12 @@ export type GameSession = {
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult; intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult;
/** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */ /** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */
exportSave(): SavedGame; 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 }; type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
@@ -105,7 +138,9 @@ function buildSession(
playerNames: string[], playerNames: string[],
createdAt: number, createdAt: number,
botSeats: Set<PlayerIndex>, botSeats: Set<PlayerIndex>,
lastMoveAtInit: number,
): GameSession { ): GameSession {
let lastMoveAt = lastMoveAtInit;
const lastSeq = new Map<PlayerIndex, number>(); const lastSeq = new Map<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>(); const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>(); const sentLines = new Map<PlayerIndex, number>();
@@ -229,6 +264,7 @@ function buildSession(
if (!applied) return { accepted: false, code: 'REJECTED' }; if (!applied) return { accepted: false, code: 'REJECTED' };
lastSeq.set(seat, seq); lastSeq.set(seat, seq);
lastMoveAt = Date.now();
const timing = settleTiming(); const timing = settleTiming();
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism // 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 // diffs against whatever was last sent, so it captures the bots' moves along with the human's
@@ -246,6 +282,23 @@ function buildSession(
status: game.state.status === 'finished' ? 'finished' : 'active', status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt, createdAt,
botSeats: [...botSeats], 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)}` },
}; };
}, },
}; };
@@ -257,7 +310,8 @@ export function createSession(
playerNames: string[], playerNames: string[],
botSeats: PlayerIndex[] = [], botSeats: PlayerIndex[] = [],
): GameSession { ): GameSession {
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now(), new Set(botSeats)); const now = Date.now();
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, now, new Set(botSeats), now);
} }
/** /**
@@ -265,7 +319,46 @@ export function createSession(
* check happens before this is ever called; by the time `saved.history` reaches here it is already * 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. * 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 { export function resumeSession(saved: SavedGame): GameSession {
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history); const r = tryResumeSession(saved);
return buildSession(game, saved.playerNames, saved.createdAt, new Set(saved.botSeats)); 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,
);
} }
+67 -5
View File
@@ -28,7 +28,23 @@ export type BoardTrain = { label: string; consist: string[] };
* The Division as a dispatcher would see it: one continuous line per running track, sections * 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>`;
@@ -1090,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}
-1
View File
@@ -74,7 +74,6 @@ const SOLO = (length: GameLength, mode: GameMode): GameConfig => {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
-1
View File
@@ -76,7 +76,6 @@ function configFor(mode: GameMode, length: GameLength, players: number): GameCon
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
+14
View File
@@ -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.
+4 -2
View File
@@ -68,7 +68,6 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
@@ -513,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);
+39
View File
@@ -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;
@@ -370,6 +386,23 @@ 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;
/** /**
@@ -1241,6 +1274,12 @@ 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: overHandLimit:
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT), (s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
+22 -7
View File
@@ -76,7 +76,6 @@ export const SOLO_CONFIG: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
@@ -104,7 +103,6 @@ export function defaultMultiplayerConfig(mode: 'competitive' | 'coop', players =
pvpCardsAllowed: mode === 'competitive', pvpCardsAllowed: mode === 'competitive',
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
@@ -1185,21 +1183,38 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
* out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a * out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a
* touch-in-passing. * 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( export function fromMultiplayerSave(
seed: number, seed: number,
config: GameConfig, config: GameConfig,
playerNames: string[], playerNames: string[],
history: Intent[], history: Intent[],
): Game { ): { game: Game; stopped: ReplayStop | null } {
const game = newMultiplayerGame(seed, config, playerNames); const game = newMultiplayerGame(seed, config, playerNames);
for (const intent of history) { for (const [index, intent] of history.entries()) {
const actor = currentActor(game); const actor = currentActor(game);
if (actor === null) break; if (actor === null) {
return { game, stopped: { index, intent, code: 'NO_ACTOR' } };
}
const result = applyIntent(game.state, actor, intent); const result = applyIntent(game.state, actor, intent);
if (!result.ok) break; if (!result.ok) {
return { game, stopped: { index, intent, code: result.code } };
}
game.history.push(intent); game.history.push(intent);
record(game, result.events, actor); record(game, result.events, actor);
drain(game); drain(game);
} }
return game; return { game, stopped: null };
} }
+8 -6
View File
@@ -29,6 +29,8 @@ a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5} .door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6} .door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
.door.disabled .go{color:var(--dim)} .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; .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} align-items:center;justify-content:center;padding:32px;z-index:10;cursor:zoom-out}
.lightbox[hidden]{display:none} .lightbox[hidden]{display:none}
@@ -69,13 +71,13 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</div> </div>
<div class="doors"> <div class="doors">
<div class="door disabled"> <a class="door" id="door-multiplayer" href="./play.html?lobby">
<h2>Play multiplayer</h2> <h2>Play multiplayer</h2>
<p>Play with your friends across the internet. You each run your own office area within the <p id="door-multiplayer-blurb">Play with your friends across the internet. You each run your
entire division. You can play a normal game, or choose co-op or cutthroat. This requires own office area within the entire division. You can play a normal game, or choose co-op or
a Station Master server to be running.</p> cutthroat.</p>
<span class="go">Coming soon</span> <span class="go" id="door-multiplayer-go">Set up a game &rarr;</span>
</div> </a>
<a class="door" href="./play.html"> <a class="door" href="./play.html">
<h2>Play solitaire</h2> <h2>Play solitaire</h2>
+95 -14
View File
@@ -13,6 +13,9 @@
import type { GameConfig, PlayerIndex } from '../engine/state.ts'; import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { defaultMultiplayerConfig } from './game.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 }; export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex };
@@ -66,18 +69,17 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
} }
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void { function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
$('lb-gamecode').textContent = `— code ${lobby.gameCode}`; $('lb-gamecode').textContent = lobby.gameCode;
const isHost = lobby.hostToken === token; const isHost = lobby.hostToken === token;
const cap = lobby.config.mode === 'solitaire' ? 1 : 4;
let html = ''; let html = '';
for (let seat = 0; seat < cap; seat++) { for (let seat = 0; seat < lobby.seats.length; seat++) {
const occupant = lobby.seats[seat] ?? null; const occupant = lobby.seats[seat] ?? null;
const isYou = occupant?.kind === 'human' && occupant.token === token; const isYou = occupant?.kind === 'human' && occupant.token === token;
const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken; const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken;
const who = const who =
occupant === null occupant === null
? '<span class="dim">— empty —</span>' ? '<span class="dim">— waiting —</span>'
: occupant.kind === 'bot' : occupant.kind === 'bot'
? 'Bot' ? 'Bot'
: `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`; : `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`;
@@ -86,10 +88,28 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
if (occupant === null) action = `<button class="lb-bot-add" data-seat="${seat}">+ bot</button>`; 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>`; else if (occupant.kind === 'bot') action = `<button class="lb-bot-remove" data-seat="${seat}">remove bot</button>`;
} }
html += `<div class="lb-seat"><span class="dim">Seat ${seat}</span><span class="who">${who}</span>${action}</div>`; html += `<div class="lb-seat"><span class="dim">Seat ${seatLabel(seat)}</span><span class="who">${who}</span>${action}</div>`;
} }
$('lb-seats').innerHTML = html; $('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'))) { 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 }); btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true });
} }
@@ -97,16 +117,14 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: false }); btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: false });
} }
const filled = lobby.seats.filter((s) => s !== null).length; const waiting = lobby.seats.filter((s) => s === null).length;
const noGaps = filled === lobby.seats.length;
const legalCount = lobby.config.mode === 'solitaire' ? filled === 1 : filled >= 2 && filled <= 4;
const startBtn = $<HTMLButtonElement>('lb-start'); const startBtn = $<HTMLButtonElement>('lb-start');
startBtn.hidden = !isHost; startBtn.hidden = !isHost;
startBtn.disabled = !(noGaps && legalCount); startBtn.disabled = waiting > 0;
$('lb-start-note').textContent = isHost $('lb-start-note').textContent = isHost
? noGaps && legalCount ? waiting === 0
? '' ? ''
: 'Needs 2–4 seated players (human or bot), no empty seats in between.' : `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.'; : 'Waiting for the host to start the game.';
startBtn.onclick = () => { startBtn.onclick = () => {
void postJson('/api/lobby/start', { token }).then(({ status, body }) => { void postJson('/api/lobby/start', { token }).then(({ status, body }) => {
@@ -130,15 +148,78 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
}; };
} }
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 = () => { $<HTMLButtonElement>('lb-create').onclick = () => {
const displayName = $<HTMLInputElement>('lb-name').value.trim(); const displayName = $<HTMLInputElement>('lb-name').value.trim();
const mode = ($('lb-choice-section').querySelector<HTMLInputElement>('input[name="lb-mode"]:checked')?.value ?? const mode = chosenMode();
'competitive') as 'competitive' | 'coop';
if (displayName === '') { if (displayName === '') {
setError('lb-create-err', 'enter a display name first'); setError('lb-create-err', 'enter a display name first');
return; return;
} }
void postJson('/api/lobby/create', { secret: secret(), config: defaultMultiplayerConfig(mode), displayName }).then( 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 }) => { ({ status, body }) => {
if (status !== 200) { if (status !== 200) {
setError('lb-create-err', String(body['error'] ?? 'could not create the game')); setError('lb-create-err', String(body['error'] ?? 'could not create the game'));
+86 -4
View File
@@ -8,6 +8,7 @@
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';
@@ -333,7 +334,11 @@ function showScreen(which: 'lobby' | 'gameui'): void {
function beginRemote(ready: LobbyReady): void { function beginRemote(ready: LobbyReady): void {
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready)); localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
showScreen('gameui'); showScreen('gameui');
session = createRemoteSession(ready.token, ready.seat); // 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(); applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first // 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 // real Frame only exists once the SSE connection's first push arrives, so the first render waits
@@ -342,6 +347,31 @@ function beginRemote(ready: LobbyReady): void {
session.subscribe(render); 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 * 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 * `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL
@@ -352,12 +382,24 @@ function beginRemote(ready: LobbyReady): void {
function start(): void { function start(): void {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
// 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(); const remembered = loadRemote();
if (remembered) { if (remembered) {
beginRemote(remembered); beginRemote(remembered);
return; 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'); showScreen('gameui');
const requested = params.get('seed'); 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.
@@ -399,6 +441,41 @@ function applyCapabilities(): void {
hide('multiplayer', c.newGame); 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 * `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 a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
@@ -409,7 +486,7 @@ function renderPresence(f: Frame): void {
const away = session const away = session
.presence() .presence()
.filter((p) => !p.connected) .filter((p) => !p.connected)
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${p.seat}`); .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`; $('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`;
} }
@@ -451,11 +528,16 @@ function render(): void {
obj.className = 'pace'; obj.className = 'pace';
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll, // 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. // `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${session.seat()}`; $('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')); 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
+97 -3
View File
@@ -35,6 +35,13 @@ 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{display:inline-flex;align-items:center;gap:4px}
.zoom button{padding:3px 9px;line-height:1} .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} .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}
@@ -165,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)}
@@ -241,6 +254,74 @@ ul.blocked li{padding:2px 0}
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label> <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"> <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> <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&ndash;3 and 11&ndash;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> <button id="lb-create">Create game</button>
<p class="dim" id="lb-create-err" role="alert"></p> <p class="dim" id="lb-create-err" role="alert"></p>
@@ -253,8 +334,20 @@ ul.blocked li{padding:2px 0}
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. --> <!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
<section id="lb-seating-section" hidden> <section id="lb-seating-section" hidden>
<h2>Seating <span class="dim" id="lb-gamecode"></span></h2> <h2>Seating</h2>
<p class="ng-note">West to East, in the order everyone joined — this order decides the Superintendent rotation and which Office is adjacent to which. The host may fill an empty seat with a bot, or start once every seat is either a player or a bot.</p> <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> <div id="lb-seats"></div>
<button id="lb-start" disabled>Start game</button> <button id="lb-start" disabled>Start game</button>
<p class="dim" id="lb-start-note"></p> <p class="dim" id="lb-start-note"></p>
@@ -307,7 +400,8 @@ ul.blocked li{padding:2px 0}
<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>
+34 -1
View File
@@ -226,7 +226,16 @@ type Push = {
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities` * 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). * are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
*/ */
export function createRemoteSession(token: string, seat: PlayerIndex): Session { 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 frame: Frame | null = null;
let menu: Menu | null = null; let menu: Menu | null = null;
let lines: { text: string; tone: string }[] = []; let lines: { text: string; tone: string }[] = [];
@@ -239,6 +248,30 @@ export function createRemoteSession(token: string, seat: PlayerIndex): Session {
const qs = `token=${encodeURIComponent(token)}`; const qs = `token=${encodeURIComponent(token)}`;
const source = new EventSource(`/api/stream?${qs}`); 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>) => { source.onmessage = (ev: MessageEvent<string>) => {
const push = JSON.parse(ev.data) as Push; 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 // A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
+43
View File
@@ -26,3 +26,46 @@ if (heroImage && lightbox) {
if (e instanceof KeyboardEvent && e.key === 'Escape') close(); 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());
}
-1
View File
@@ -25,7 +25,6 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
-1
View File
@@ -24,7 +24,6 @@ const config: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
-1
View File
@@ -25,7 +25,6 @@ const config: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
+1 -1
View File
@@ -23,7 +23,7 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: 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 });
+3
View File
@@ -48,6 +48,9 @@ const KNOWN_UNREDUCED = [
'dispatchBonusUsed', 'dispatchBonusUsed',
'expediteFault', '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',
-1
View File
@@ -17,7 +17,6 @@ const config: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
-1
View File
@@ -36,7 +36,6 @@ const config: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
+3 -3
View File
@@ -37,7 +37,7 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: 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 });
@@ -737,7 +737,7 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
seed: 4, seed: 4,
config: { config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, 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'],
}); });
@@ -800,7 +800,7 @@ describe('Q13 — a train that catches the one ahead runs into it', () => {
id: 'rear', seed: 3, id: 'rear', seed: 3,
config: { config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, 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'],
}); });
+163 -3
View File
@@ -12,13 +12,14 @@ import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts'; import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent, 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, playerLeftOf, 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 { actionMenu } from '../src/web/game.ts';
@@ -36,7 +37,7 @@ const competitive: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}; };
const game = (players: number, seed = 4242): GameState => const game = (players: number, seed = 4242): GameState =>
@@ -712,3 +713,162 @@ describe('actionMenu is seat-safe (Phase 2 prep)', () => {
} }
}); });
}); });
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));
});
});
-1
View File
@@ -28,7 +28,6 @@ const config: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
-1
View File
@@ -29,7 +29,6 @@ const config: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
+78 -32
View File
@@ -25,7 +25,6 @@ const competitive: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
@@ -33,17 +32,20 @@ const competitive: GameConfig = {
const solitaire: GameConfig = { ...competitive, mode: 'solitaire' }; const solitaire: GameConfig = { ...competitive, mode: 'solitaire' };
describe('creating and joining', () => { describe('creating and joining', () => {
it('the creator is the host, takes seat 0, and is first in join order', () => { it('seats the host at 0 and lays out the whole table at once', () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001'); const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 3);
assert.equal(session.player, 0); assert.equal(session.player, 0);
assert.equal(lobby.hostToken, session.token); assert.equal(lobby.hostToken, session.token);
assert.equal(lobby.seats.length, 1); // 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[0], { kind: 'human', token: session.token, displayName: 'Alice' });
assert.deepEqual(lobby.seats.slice(1), [null, null]);
assert.deepEqual(lobby.joinOrder, [session.token]); assert.deepEqual(lobby.joinOrder, [session.token]);
}); });
it('fills the next empty seat, in order', () => { it('fills the next empty seat, in order', () => {
const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001'); const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
const j2 = joinLobby(l1, 'Bob'); const j2 = joinLobby(l1, 'Bob');
assert.ok(j2.ok); assert.ok(j2.ok);
if (!j2.ok) return; if (!j2.ok) return;
@@ -55,8 +57,8 @@ describe('creating and joining', () => {
assert.deepEqual(j3.lobby.joinOrder, [s1.token, j2.session.token, j3.session.token]); assert.deepEqual(j3.lobby.joinOrder, [s1.token, j2.session.token, j3.session.token]);
}); });
it('refuses a 5th join to a competitive lobby (cap 4)', () => { it('refuses a join once every chair is taken', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0001').lobby; let lobby = createLobby(competitive, 'Alice', 'RAIL-0001', 4).lobby;
for (const name of ['Bob', 'Carol', 'Dave']) { for (const name of ['Bob', 'Carol', 'Dave']) {
const r = joinLobby(lobby, name); const r = joinLobby(lobby, name);
assert.ok(r.ok); assert.ok(r.ok);
@@ -66,8 +68,8 @@ describe('creating and joining', () => {
assert.deepEqual(fifth, { ok: false, code: 'LOBBY_FULL' }); assert.deepEqual(fifth, { ok: false, code: 'LOBBY_FULL' });
}); });
it('refuses a 2nd join to a solitaire lobby (cap 1)', () => { it('refuses a 2nd join to a one-chair table', () => {
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002'); const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002', 1);
const second = joinLobby(lobby, 'Bob'); const second = joinLobby(lobby, 'Bob');
assert.deepEqual(second, { ok: false, code: 'LOBBY_FULL' }); assert.deepEqual(second, { ok: false, code: 'LOBBY_FULL' });
}); });
@@ -75,20 +77,32 @@ describe('creating and joining', () => {
it('rejoins into a seat an earlier player vacated, not past the end', () => { 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) // 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. // is exactly as joinable as one nobody ever filled.
let lobby = createLobby(competitive, 'Alice', 'RAIL-0003').lobby; let lobby = createLobby(competitive, 'Alice', 'RAIL-0003', 3).lobby;
lobby = setBotSeat(lobby, 1, true); lobby = setBotSeat(lobby, 1, true);
lobby = setBotSeat(lobby, 1, false); lobby = setBotSeat(lobby, 1, false);
const r = joinLobby(lobby, 'Bob'); const r = joinLobby(lobby, 'Bob');
assert.ok(r.ok); assert.ok(r.ok);
if (!r.ok) return; if (!r.ok) return;
assert.equal(r.session.player, 1, 'should take the reopened seat 1, not append at seat 1 anyway by coincidence — check seat 2 stays empty'); assert.equal(r.session.player, 1, 'should take the reopened chair 1');
assert.equal(r.lobby.seats.length, 2); 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', () => { describe('bot seats', () => {
it('fills only an empty seat, and clears only a bot seat', () => { it('fills only an empty seat, and clears only a bot seat', () => {
const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004'); const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004', 2);
const withBot = setBotSeat(l0, 1, true); const withBot = setBotSeat(l0, 1, true);
assert.deepEqual(withBot.seats[1], { kind: 'bot' }); assert.deepEqual(withBot.seats[1], { kind: 'bot' });
@@ -103,11 +117,21 @@ describe('bot seats', () => {
const cleared = setBotSeat(withBot, 1, false); const cleared = setBotSeat(withBot, 1, false);
assert.equal(cleared.seats[1], null); 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', () => { describe('host transfer', () => {
it('passes to the earliest-joined remaining human seat when the host departs', () => { it('passes to the earliest-joined remaining human seat when the host departs', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0005').lobby; let lobby = createLobby(competitive, 'Alice', 'RAIL-0005', 2).lobby;
const hostToken = lobby.hostToken; const hostToken = lobby.hostToken;
const j2 = joinLobby(lobby, 'Bob'); const j2 = joinLobby(lobby, 'Bob');
assert.ok(j2.ok); assert.ok(j2.ok);
@@ -121,13 +145,13 @@ describe('host transfer', () => {
}); });
it('does nothing when the departing token is not the host', () => { it('does nothing when the departing token is not the host', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0006'); const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0006', 2);
const after = reassignHost(lobby, 'not-a-real-token'); const after = reassignHost(lobby, 'not-a-real-token');
assert.equal(after, lobby); assert.equal(after, lobby);
}); });
it('leaves hostToken alone when no other human seat exists', () => { it('leaves hostToken alone when no other human seat exists', () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0007'); const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0007', 2);
const after = reassignHost(lobby, session.token); const after = reassignHost(lobby, session.token);
assert.equal(after.hostToken, session.token); assert.equal(after.hostToken, session.token);
}); });
@@ -135,40 +159,62 @@ describe('host transfer', () => {
describe('starting', () => { describe('starting', () => {
it('refuses a non-host caller', () => { it('refuses a non-host caller', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0008'); const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0008', 2);
joinLobby(lobby, 'Bob'); joinLobby(lobby, 'Bob');
assert.deepEqual(startLobby(lobby, 'someone-elses-token'), { ok: false, code: 'NOT_HOST' }); assert.deepEqual(startLobby(lobby, 'someone-elses-token'), { ok: false, code: 'NOT_HOST' });
}); });
it('refuses to start with a gap in the seats', () => { it('refuses to start while a chair is still empty', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0009').lobby; const lobby = createLobby(competitive, 'Alice', 'RAIL-0009', 3).lobby;
const j2 = joinLobby(lobby, 'Bob'); const j2 = joinLobby(lobby, 'Bob');
assert.ok(j2.ok); assert.ok(j2.ok);
if (!j2.ok) return; if (!j2.ok) return;
const j3 = joinLobby(j2.lobby, 'Carol'); assert.deepEqual(startLobby(j2.lobby, j2.lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
assert.ok(j3.ok); });
if (!j3.ok) return;
lobby = { ...j3.lobby, seats: [j3.lobby.seats[0]!, null, j3.lobby.seats[2]!] }; 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' }); assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
}); });
it('refuses a solo human in a competitive lobby (needs 2-4)', () => { it('starts a full 2-player lobby, naming humans by their display name and numbering the bot', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010'); let lobby = createLobby(competitive, 'Alice', 'RAIL-0011', 2).lobby;
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
});
it('starts a full 2-player lobby, naming bots "Bot" and humans by their display name', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0011').lobby;
lobby = setBotSeat(lobby, 1, true); lobby = setBotSeat(lobby, 1, true);
const r = startLobby(lobby, lobby.hostToken); const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot'], botSeats: [1] }); 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', () => { it('starts a solitaire lobby of exactly 1', () => {
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012'); const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012', 1);
const r = startLobby(lobby, lobby.hostToken); const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice'], botSeats: [] }); 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', () => { describe('playerCountAllowed', () => {
+11 -13
View File
@@ -17,7 +17,6 @@ const config: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
@@ -46,29 +45,30 @@ describe('game persistence (Phase 3)', () => {
it('writes and reads back exactly what was written', () => it('writes and reads back exactly what was written', () =>
withTempDir(async (dir) => { withTempDir(async (dir) => {
await writeGame(dir, saved, '1.2.3'); await writeGame(dir, saved, '1.2.3');
const result = await loadGame(dir, '1.2.3'); const result = await loadGame(dir);
assert.equal(result.found, true); assert.equal(result.found, true);
if (!result.found) return; if (!result.found) return;
assert.equal(result.ok, true);
if (!result.ok) return;
assert.deepEqual(result.saved, saved); assert.deepEqual(result.saved, saved);
})); }));
it('refuses a version mismatch explicitly, naming both versions', () => it('reports the version that wrote the file without judging it', () =>
withTempDir(async (dir) => { 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'); await writeGame(dir, saved, '1.2.3');
const result = await loadGame(dir, '9.9.9'); const result = await loadGame(dir);
assert.equal(result.found, true); assert.equal(result.found, true);
if (!result.found) return; if (!result.found) return;
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.storedVersion, '1.2.3'); assert.equal(result.storedVersion, '1.2.3');
assert.equal(result.currentVersion, '9.9.9'); assert.deepEqual(result.saved, saved);
})); }));
it('reports not-found rather than throwing when nothing has been saved yet', () => it('reports not-found rather than throwing when nothing has been saved yet', () =>
withTempDir(async (dir) => { withTempDir(async (dir) => {
const result = await loadGame(dir, '1.2.3'); const result = await loadGame(dir);
assert.deepEqual(result, { found: false }); assert.deepEqual(result, { found: false });
})); }));
@@ -77,11 +77,9 @@ describe('game persistence (Phase 3)', () => {
await writeGame(dir, saved, '1.2.3'); await writeGame(dir, saved, '1.2.3');
const grown: SavedGame = { ...saved, history: [...saved.history, { type: 'draw.end' }] }; const grown: SavedGame = { ...saved, history: [...saved.history, { type: 'draw.end' }] };
await writeGame(dir, grown, '1.2.3'); await writeGame(dir, grown, '1.2.3');
const result = await loadGame(dir, '1.2.3'); const result = await loadGame(dir);
assert.equal(result.found, true); assert.equal(result.found, true);
if (!result.found) return; if (!result.found) return;
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.saved.history.length, 2); assert.equal(result.saved.history.length, 2);
})); }));
+116 -3
View File
@@ -6,8 +6,8 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts'; import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts';
import type { Push } from '../../src/server/session.ts'; import type { GameSession, Push } from '../../src/server/session.ts';
import { createSession, resumeSession } from '../../src/server/session.ts'; import { createSession, resumeSession, tryResumeSession } from '../../src/server/session.ts';
const config: GameConfig = { const config: GameConfig = {
mode: 'competitive', mode: 'competitive',
@@ -18,7 +18,6 @@ const config: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
@@ -234,3 +233,117 @@ describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => {
assert.equal(session.exportSave().status, 'active'); 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
View File
@@ -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
-1
View File
@@ -41,7 +41,6 @@ const solitaireConfig: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
-1
View File
@@ -29,7 +29,6 @@ const config: GameConfig = {
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
sisterTrains: false,
employeeRotation: false, employeeRotation: false,
emergencyToolbox: false, emergencyToolbox: false,
}, },
+1 -1
View File
@@ -129,7 +129,7 @@ function gameWith(area: OfficeArea): GameState {
id: 'g', seed: 5, id: 'g', seed: 5,
config: { config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, 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'],
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: 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 });
+148 -5
View File
@@ -2515,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=[^"]*)?">/);
@@ -2537,7 +2680,7 @@ describe('the Division map shows the whole route', () => {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}, },
playerNames: ['A', 'B', 'C', 'D'].slice(0, players), playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
}); });
@@ -2552,7 +2695,7 @@ describe('the Division map shows the whole route', () => {
seed: 7, seed: 7,
config: { config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, 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'],
}); });
@@ -2605,7 +2748,7 @@ describe('the Division map shows the whole route', () => {
id: 'div-chips', seed: 7, id: 'div-chips', seed: 7,
config: { config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, 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'],
}); });
@@ -2934,7 +3077,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
id: 'eng', seed: 1038389, id: 'eng', seed: 1038389,
config: { config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, 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'],
}); });
@@ -2984,7 +3127,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
id: 'yards', seed: 1038389, id: 'yards', seed: 1038389,
config: { config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, 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'],
}); });