Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40f07b0710 | ||
|
|
51710498f5 | ||
|
|
bfd2708ecc | ||
|
|
689de2ff0f | ||
|
|
2fbfe11977 |
+260
@@ -19,6 +19,266 @@ 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`,
|
||||
|
||||
@@ -21,6 +21,15 @@ Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplaye
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -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
|
||||
held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
|
||||
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
|
||||
v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes
|
||||
it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain
|
||||
rather than only the result (`lobby-and-sessions.md` §4).
|
||||
v0.4.1**, and **displayed in v0.5.4**. It orders the whole chain, west to east by ascending
|
||||
roll; `openingRolls` is on the `Frame` now and the play page prints the chain under the
|
||||
Division map — *West to East: Alice (1) → Bot 2 (5) → Bot 1 (11)* — so the rolls that formed
|
||||
it are visible rather than only their result (`lobby-and-sessions.md` §4).
|
||||
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
|
||||
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
|
||||
pointed at the open internet for long. Note that one-game-at-a-time per person is expected
|
||||
@@ -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,
|
||||
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%
|
||||
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
|
||||
|
||||
@@ -245,7 +245,10 @@ special handling: `pump` stops, and the next push simply carries a `Menu` contai
|
||||
POST /api/lobby/create, /api/lobby/join lobby
|
||||
POST /api/intent { gameId, seq, intent }
|
||||
GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume
|
||||
GET /api/health { ok, service, engineVersion } — unauthenticated; see below
|
||||
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
|
||||
```
|
||||
|
||||
@@ -253,7 +256,19 @@ GET / the client
|
||||
`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.
|
||||
server is answering and nothing else, no game and no seat. Its `games` field — `{ active, lobby }`
|
||||
— is what the StartOS package's health check reports as "3 games in progress".
|
||||
|
||||
**The three administrative routes are gated by `ADMIN_SECRET`, which is deliberately not the join
|
||||
secret.** Every player holds the join secret, so gating a delete with it would let anyone at the
|
||||
table destroy anyone else's game; this one belongs to whoever runs the server. It arrives in an
|
||||
`x-admin-secret` header rather than the query string, so it stays out of logs and referrers. When
|
||||
the variable is unset the routes answer 404 exactly as any unknown path does, so a server that was
|
||||
never given an administrator does not advertise that it has one.
|
||||
|
||||
**A delete returns the deleted game's save.** The intents are the game (D5), so what comes back is
|
||||
the whole thing and not a summary of it — the record survives even though the game does not, and
|
||||
nothing is destroyed without being handed to whoever destroyed it first.
|
||||
|
||||
Chosen over WebSocket because this game is **idle most of the time** — turn-based with human
|
||||
think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
@@ -1077,6 +1077,7 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
s.clock.stage = 1;
|
||||
s.collisionsToday = 0;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||
rotateSeats(s, events);
|
||||
const finished = checkVictory(s, events);
|
||||
if (finished) return { events, needsInput: false };
|
||||
} 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
|
||||
* 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 {
|
||||
const daysElapsed = s.clock.day - 1;
|
||||
if (daysElapsed < s.config.days) return false;
|
||||
|
||||
@@ -26,6 +26,8 @@ import type { CardId, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId }
|
||||
export type GameEvent =
|
||||
// -- clock
|
||||
| { 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: 'actorChanged'; player: PlayerIndex | null }
|
||||
// -- local operations
|
||||
|
||||
@@ -583,7 +583,6 @@ export type GameConfig = {
|
||||
pvpCardsAllowed: boolean;
|
||||
optionalRules: {
|
||||
reducedVisibility: boolean;
|
||||
sisterTrains: boolean;
|
||||
employeeRotation: boolean;
|
||||
emergencyToolbox: boolean;
|
||||
};
|
||||
|
||||
+159
-5
@@ -27,8 +27,11 @@ import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import {
|
||||
appendTiming,
|
||||
deleteGame,
|
||||
deleteLobby,
|
||||
gameDir,
|
||||
readIndex,
|
||||
removeIndexEntry,
|
||||
upsertIndexEntry,
|
||||
writeGame,
|
||||
writeLobby,
|
||||
@@ -39,6 +42,7 @@ import type { GameSession, Push } from './session.ts';
|
||||
import {
|
||||
createLobby,
|
||||
freshGameCode,
|
||||
playerCountAllowed,
|
||||
joinLobby,
|
||||
reassignHost,
|
||||
setBotSeat,
|
||||
@@ -58,6 +62,14 @@ export type ServerOptions = {
|
||||
dataDir: string;
|
||||
/** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */
|
||||
engineVersion: string;
|
||||
/**
|
||||
* Gates the administrative routes — listing, exporting and deleting games — and is DELIBERATELY
|
||||
* not the join secret. Every player holds that one, so gating a delete with it would let anyone
|
||||
* at the table destroy anyone else's game. This is held by whoever runs the server and nobody
|
||||
* else. When it is unset the admin routes do not exist at all (404, the same answer as any other
|
||||
* unknown path), so a server that was never given one cannot be administered by guessing.
|
||||
*/
|
||||
adminSecret?: string | undefined;
|
||||
/** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */
|
||||
initialGames: Map<string, GameSession>;
|
||||
initialLobbies: Map<string, Lobby>;
|
||||
@@ -212,24 +224,139 @@ export function startServer(opts: ServerOptions): void {
|
||||
* 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') {
|
||||
sendJson(res, 200, { ok: true, service: 'station-master', engineVersion: opts.engineVersion });
|
||||
// `summary()` rather than `exportSave()`: this is polled on a timer, and the save copies
|
||||
// every intent of every game to answer a question about none of them.
|
||||
let active = 0;
|
||||
for (const g of games.values()) if (g.summary().status === 'active') active++;
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
service: 'station-master',
|
||||
engineVersion: opts.engineVersion,
|
||||
games: { active, lobby: lobbies.size },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Administration: listing, exporting and deleting games ------------------------------
|
||||
|
||||
if (url.pathname === '/api/games' || url.pathname.startsWith('/api/games/')) {
|
||||
// Unset means the routes are not here — indistinguishable from any other unknown path, so
|
||||
// nothing advertises an administrative surface to someone probing for one.
|
||||
if (!opts.adminSecret) {
|
||||
await serveStatic(opts.distDir, url.pathname, res);
|
||||
return;
|
||||
}
|
||||
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
||||
sendJson(res, 403, { error: 'bad or missing admin secret' });
|
||||
return;
|
||||
}
|
||||
|
||||
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
|
||||
|
||||
if (url.pathname === '/api/games' && req.method === 'GET') {
|
||||
const running = [...games.entries()].map(([gameId, g]) => ({
|
||||
gameId,
|
||||
gameCode: codes.get(gameId) ?? null,
|
||||
state: 'running' as const,
|
||||
...g.summary(),
|
||||
}));
|
||||
// A lobby has no game to summarize yet — it is reported as what it is, so an
|
||||
// administrator sees a table that never started rather than nothing at all.
|
||||
const waiting = [...lobbies.values()].map((l) => ({
|
||||
gameId: l.gameId,
|
||||
gameCode: l.gameCode,
|
||||
state: 'lobby' as const,
|
||||
playerCount: l.seats.length,
|
||||
playerNames: l.seats.map((seat) =>
|
||||
seat === null ? '(empty)' : seat.kind === 'bot' ? 'Bot' : seat.displayName,
|
||||
),
|
||||
createdAt: l.createdAt,
|
||||
}));
|
||||
sendJson(res, 200, { games: [...running, ...waiting] });
|
||||
return;
|
||||
}
|
||||
|
||||
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname);
|
||||
const gameId = match?.[1];
|
||||
if (!gameId) {
|
||||
sendJson(res, 404, { error: 'no such route' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (match?.[2] && req.method === 'GET') {
|
||||
const session = games.get(gameId);
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { gameCode: codes.get(gameId) ?? null, save: session.exportSave() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const session = games.get(gameId);
|
||||
const lobby = lobbies.get(gameId);
|
||||
if (!session && !lobby) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
// The save goes back with the deletion, so a game can never be destroyed without its
|
||||
// record being handed to whoever destroyed it — the intents ARE the game (D5), so this
|
||||
// is the whole thing, replayable later, not a summary of it.
|
||||
const save = session?.exportSave() ?? null;
|
||||
|
||||
// Everyone watching is told the game is gone before its files are, rather than being
|
||||
// left on a stream that will never push again.
|
||||
for (const [, watcher] of gameConnections.get(gameId) ?? []) watcher.end();
|
||||
gameConnections.delete(gameId);
|
||||
for (const [, watcher] of lobbyConnections.get(gameId) ?? []) watcher.end();
|
||||
lobbyConnections.delete(gameId);
|
||||
|
||||
games.delete(gameId);
|
||||
lobbies.delete(gameId);
|
||||
gameEventIds.delete(gameId);
|
||||
const code = lobby?.gameCode ?? codes.get(gameId);
|
||||
if (code) gameCodes.delete(code);
|
||||
for (const [token, ps] of [...sessions]) if (ps.gameId === gameId) sessions.delete(token);
|
||||
|
||||
await removeIndexEntry(opts.dataDir, gameId);
|
||||
await deleteGame(opts.dataDir, gameId);
|
||||
sendJson(res, 200, { ok: true, gameCode: code ?? null, save });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 405, { error: 'method not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Lobby: creating and joining (the door — join-secret gated) --------------------------
|
||||
|
||||
if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
|
||||
const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string };
|
||||
const body = (await readJson(req)) as {
|
||||
secret?: string;
|
||||
config?: GameConfig;
|
||||
displayName?: string;
|
||||
players?: number;
|
||||
seed?: number | null;
|
||||
};
|
||||
if (body.secret !== opts.joinSecret) {
|
||||
sendJson(res, 403, { error: 'bad or missing secret' });
|
||||
return;
|
||||
}
|
||||
if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
|
||||
sendJson(res, 400, { error: 'expected { secret, config, displayName }' });
|
||||
sendJson(res, 400, { error: 'expected { secret, config, displayName, players }' });
|
||||
return;
|
||||
}
|
||||
// The table size is the host's to choose and is fixed from here on, so it is validated at
|
||||
// the door rather than at Start — `createLobby` builds the seats array from it.
|
||||
const players = body.players ?? 0;
|
||||
if (!Number.isInteger(players) || !playerCountAllowed(body.config.mode, players)) {
|
||||
sendJson(res, 400, { error: 'BAD_PLAYER_COUNT' });
|
||||
return;
|
||||
}
|
||||
const gameCode = freshGameCode((code) => gameCodes.has(code));
|
||||
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode);
|
||||
const seed = typeof body.seed === 'number' && Number.isFinite(body.seed) ? Math.trunc(body.seed) : null;
|
||||
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players, seed);
|
||||
await persistLobby(lobby);
|
||||
await persistSession(session);
|
||||
sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
|
||||
@@ -304,7 +431,13 @@ export function startServer(opts: ServerOptions): void {
|
||||
sendJson(res, 409, { error: result.code });
|
||||
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);
|
||||
lobbies.delete(lobby.gameId);
|
||||
// Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game
|
||||
@@ -356,6 +489,27 @@ export function startServer(opts: ServerOptions): void {
|
||||
|
||||
// -- The running game (token-authenticated) ------------------------------------------------
|
||||
|
||||
/**
|
||||
* IS THIS TOKEN STILL GOOD FOR ANYTHING?
|
||||
*
|
||||
* A browser remembers its session in `localStorage` and re-enters the game on the next load
|
||||
* without asking, which is what makes reconnection seamless — and what leaves it stranded
|
||||
* when the game is gone. `EventSource` cannot report a status code and retries a 404
|
||||
* silently forever, so the client needs somewhere cheap to ask a yes/no question. Two ways a
|
||||
* game legitimately disappears under a player: an engine-version bump refuses to resume it
|
||||
* (D7), and an administrator ends it (`DELETE /api/games/<id>`).
|
||||
*/
|
||||
if (url.pathname === '/api/session' && req.method === 'GET') {
|
||||
const ps = sessions.get(url.searchParams.get('token') ?? '');
|
||||
const live = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !live) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { gameId: ps.gameId, player: ps.player });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/stream' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
|
||||
+38
-13
@@ -13,13 +13,20 @@ import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer } from './http.ts';
|
||||
import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.ts';
|
||||
import { resumeSession } from './session.ts';
|
||||
import { tryResumeSession } from './session.ts';
|
||||
import type { GameSession } from './session.ts';
|
||||
import type { Lobby, PlayerSession } from './lobby.ts';
|
||||
|
||||
const port = Number(process.env['PORT'] ?? 8081);
|
||||
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
|
||||
const joinSecret = process.env['JOIN_SECRET'];
|
||||
/**
|
||||
* Optional, unlike `JOIN_SECRET`: a server with no administrator is a perfectly good server, and
|
||||
* refusing to boot without one would break every existing deployment and every dev run. Unset
|
||||
* simply means the admin routes are not there (`http.ts`), which is the safe default — the
|
||||
* capability has to be granted, never merely left ungated.
|
||||
*/
|
||||
const adminSecret = process.env['ADMIN_SECRET'];
|
||||
const distDir = resolve(process.env['DIST_DIR'] ?? 'dist');
|
||||
const dataDir = resolve(process.env['DATA_DIR'] ?? 'data');
|
||||
|
||||
@@ -38,6 +45,11 @@ const initialLobbies = new Map<string, Lobby>();
|
||||
const initialSessions = new Map<string, PlayerSession>();
|
||||
|
||||
const index = await readIndex(dataDir);
|
||||
// Said before the loop, not after it: replaying is the reason a restart pauses before the port
|
||||
// opens, and a log that only reports each game once it is done gives no warning of how much is
|
||||
// still to come.
|
||||
const resumable = index.filter((e) => e.status !== 'finished').length;
|
||||
if (resumable > 0) console.log(`Resuming ${resumable} saved game(s)…`);
|
||||
for (const entry of index) {
|
||||
const sessions = await readSessions(dataDir, entry.gameId);
|
||||
for (const s of sessions) initialSessions.set(s.token, s);
|
||||
@@ -48,18 +60,27 @@ for (const entry of index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const loaded = await loadGame(gameDir(dataDir, entry.gameId), engineVersion);
|
||||
if (loaded.found && loaded.ok) {
|
||||
initialGames.set(entry.gameId, resumeSession(loaded.saved));
|
||||
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
||||
} else if (loaded.found && !loaded.ok) {
|
||||
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded
|
||||
// under. The file is left untouched: rolling the running version back would let it load again.
|
||||
console.error(
|
||||
`Refusing to resume ${entry.gameId} (${entry.gameCode}): saved under engine version ` +
|
||||
`${loaded.storedVersion}, this server is running ${engineVersion}. Left untouched, and ` +
|
||||
`will not appear as an active game until the version matches again.`,
|
||||
);
|
||||
const loaded = await loadGame(gameDir(dataDir, entry.gameId));
|
||||
if (loaded.found) {
|
||||
const resumed = tryResumeSession(loaded.saved);
|
||||
if (resumed.ok) {
|
||||
initialGames.set(entry.gameId, resumed.session);
|
||||
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
||||
} else {
|
||||
/**
|
||||
* The save does not replay under these rules, which is the only thing that has ever actually
|
||||
* mattered — and now the only thing asked. Says which move it choked on, because "some
|
||||
* version differs" was never enough to act on: the file is left untouched, so an operator who
|
||||
* wants the game back can put the previous version on and finish it.
|
||||
*/
|
||||
const f = resumed.failure;
|
||||
console.error(
|
||||
`Refusing to resume ${entry.gameId} (${entry.gameCode}): move ${f.stoppedAt + 1} of ${f.of} ` +
|
||||
`(${f.intent}) is rejected by the current rules with ${f.code}. Saved under engine ` +
|
||||
`version ${loaded.storedVersion}, this server is running ${engineVersion}. The file is ` +
|
||||
`left untouched.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// `entry.status === 'finished'` games are not resumed into memory at all — nothing plays them
|
||||
// forward, and their files stay on disk for post-game replay (`lobby-and-sessions.md` §6).
|
||||
@@ -69,6 +90,7 @@ startServer({
|
||||
port,
|
||||
bindAddress,
|
||||
joinSecret,
|
||||
adminSecret,
|
||||
distDir,
|
||||
dataDir,
|
||||
engineVersion,
|
||||
@@ -80,3 +102,6 @@ console.log(
|
||||
`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` +
|
||||
`${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`,
|
||||
);
|
||||
if (!adminSecret) {
|
||||
console.log('ADMIN_SECRET is unset — the /api/games administration routes are disabled.');
|
||||
}
|
||||
|
||||
+51
-11
@@ -41,6 +41,12 @@ export type Lobby = {
|
||||
* replacement is unambiguous (`lobby-and-sessions.md` §2: "earliest-joined remaining player"). */
|
||||
joinOrder: string[];
|
||||
createdAt: number;
|
||||
/**
|
||||
* The seed the host asked for, or null for one picked at `Lobby.Start`. Chosen here rather than
|
||||
* at start because the same seed and the same settings deal the same railroad — which is only
|
||||
* useful if the person setting the game up can name it.
|
||||
*/
|
||||
seed: number | null;
|
||||
};
|
||||
|
||||
export type CreateResult = { lobby: Lobby; session: PlayerSession };
|
||||
@@ -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). */
|
||||
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 token = randomUUID();
|
||||
const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName };
|
||||
const seats: LobbySeat[] = Array.from({ length: players }, (_, i) =>
|
||||
i === 0 ? { kind: 'human', token, displayName: hostDisplayName } : null,
|
||||
);
|
||||
const lobby: Lobby = {
|
||||
gameId,
|
||||
gameCode,
|
||||
hostToken: token,
|
||||
config,
|
||||
seats: [{ kind: 'human', token, displayName: hostDisplayName }],
|
||||
seats,
|
||||
joinOrder: [token],
|
||||
createdAt: Date.now(),
|
||||
seed,
|
||||
};
|
||||
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
|
||||
* lobby can never grow the seats array past what could legally start). */
|
||||
export function joinLobby(lobby: Lobby, displayName: string): JoinResult {
|
||||
const cap = lobby.config.mode === 'solitaire' ? 1 : 4;
|
||||
const empty = lobby.seats.findIndex((s) => s === null);
|
||||
const seatIndex = empty >= 0 ? empty : lobby.seats.length;
|
||||
if (seatIndex >= cap) return { ok: false, code: 'LOBBY_FULL' };
|
||||
// The table was sized at creation, so joining takes an empty chair or none at all — there is no
|
||||
// longer an "append another seat" path for a late arrival to grow the game through.
|
||||
const seatIndex = lobby.seats.findIndex((s) => s === null);
|
||||
if (seatIndex < 0) return { ok: false, code: 'LOBBY_FULL' };
|
||||
|
||||
const token = randomUUID();
|
||||
const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName };
|
||||
@@ -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. */
|
||||
export function setBotSeat(lobby: Lobby, seat: PlayerIndex, filled: boolean): Lobby {
|
||||
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 (seats[seat] !== null) return lobby;
|
||||
seats[seat] = { kind: 'bot' };
|
||||
@@ -157,11 +191,17 @@ export function reassignHost(lobby: Lobby, departingToken: string): Lobby {
|
||||
*/
|
||||
export function startLobby(lobby: Lobby, callerToken: string): StartResult {
|
||||
if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' };
|
||||
const filled = lobby.seats.filter((s) => s !== null);
|
||||
if (filled.length !== lobby.seats.length || !playerCountAllowed(lobby.config.mode, filled.length)) {
|
||||
// Every chair at the table must be taken. The size itself was validated at creation and cannot
|
||||
// have moved since, so this is only ever waiting on the last empty seat to fill.
|
||||
if (lobby.seats.some((s) => s === null) || !playerCountAllowed(lobby.config.mode, lobby.seats.length)) {
|
||||
return { ok: false, code: 'BAD_PLAYER_COUNT' };
|
||||
}
|
||||
const playerNames = filled.map((s) => (s!.kind === 'human' ? s.displayName : 'Bot'));
|
||||
const botSeats = filled.flatMap((s, i) => (s!.kind === 'bot' ? [i as PlayerIndex] : []));
|
||||
// Seat index IS player index — no compaction, because there is nothing to compact past.
|
||||
const taken = lobby.seats as Exclude<LobbySeat, null>[];
|
||||
// Bots are numbered rather than all being called "Bot": two of them at one table are two
|
||||
// different railroads, and a map labelling both the same cannot say which is which.
|
||||
let botNumber = 0;
|
||||
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : `Bot ${++botNumber}`));
|
||||
const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : []));
|
||||
return { ok: true, playerNames, botSeats };
|
||||
}
|
||||
|
||||
+37
-10
@@ -11,7 +11,7 @@
|
||||
* 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 type { SavedGame, TurnTiming } from './session.ts';
|
||||
import type { Lobby, PlayerSession } from './lobby.ts';
|
||||
@@ -38,11 +38,23 @@ export async function writeGame(dataDir: string, saved: SavedGame, engineVersion
|
||||
|
||||
export type LoadResult =
|
||||
| { found: false }
|
||||
| { found: true; ok: true; saved: SavedGame }
|
||||
/** §12 step 15 — refused explicitly, never silently replayed under the wrong rules. */
|
||||
| { found: true; ok: false; storedVersion: string; currentVersion: string };
|
||||
/** The version that wrote the file, for diagnostics — it is no longer what decides. */
|
||||
| { found: true; saved: SavedGame; storedVersion: 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;
|
||||
try {
|
||||
text = await readFile(join(dataDir, GAME_FILE), 'utf8');
|
||||
@@ -50,11 +62,8 @@ export async function loadGame(dataDir: string, currentVersion: string): Promise
|
||||
return { found: false };
|
||||
}
|
||||
const payload = JSON.parse(text) as PersistedGame;
|
||||
if (payload.engineVersion !== currentVersion) {
|
||||
return { found: true, ok: false, storedVersion: payload.engineVersion, currentVersion };
|
||||
}
|
||||
const { engineVersion: _engineVersion, ...saved } = payload;
|
||||
return { found: true, ok: true, saved };
|
||||
const { engineVersion, ...saved } = payload;
|
||||
return { found: true, saved, storedVersion: engineVersion };
|
||||
}
|
||||
|
||||
/** Appended once per closed turn span (`GameSession.intent`'s `timing` result) — read-modify-write at
|
||||
@@ -111,6 +120,24 @@ export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry):
|
||||
await writeIndex(dataDir, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a game from the index. Paired with `deleteGame` — the directory holds the game, the
|
||||
* index says the game exists, and a delete that did one without the other would either resurrect
|
||||
* it on the next boot or leave `index.json` pointing at nothing.
|
||||
*/
|
||||
export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> {
|
||||
const entries = await readIndex(dataDir);
|
||||
await writeIndex(
|
||||
dataDir,
|
||||
entries.filter((e) => e.gameId !== gameId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */
|
||||
export async function deleteGame(dataDir: string, gameId: string): Promise<void> {
|
||||
await rm(gameDir(dataDir, gameId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> {
|
||||
const dir = gameDir(dataDir, lobby.gameId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
+97
-4
@@ -26,7 +26,7 @@ import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, subm
|
||||
import type { Game, Menu } from '../web/game.ts';
|
||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
import { snapshot } from '../sim/view.ts';
|
||||
import { snapshot, seatLabel } from '../sim/view.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { developerBot } from '../sim/bot.ts';
|
||||
|
||||
@@ -82,6 +82,33 @@ export type SavedGame = {
|
||||
* guessing from a display name rather than reading a fact.
|
||||
*/
|
||||
botSeats: PlayerIndex[];
|
||||
/**
|
||||
* Wall-clock of the last accepted intent, so "has this game stalled?" survives a restart.
|
||||
*
|
||||
* Optional because it postdates the format, and defaulted to `createdAt` when absent — a game
|
||||
* whose last move is unrecorded reads as untouched since it began, which is the honest answer
|
||||
* rather than a fabricated one. Kept OUT of `history`, like the turn timings and for the same
|
||||
* reason: a replay must reproduce a game from decisions alone, and wall-clock is not a decision.
|
||||
*/
|
||||
lastMoveAt?: number;
|
||||
};
|
||||
|
||||
/** What an administrator needs to see about a game without replaying it themselves. */
|
||||
export type GameSummary = {
|
||||
playerCount: number;
|
||||
playerNames: string[];
|
||||
botSeats: PlayerIndex[];
|
||||
status: 'active' | 'finished';
|
||||
createdAt: number;
|
||||
lastMoveAt: number;
|
||||
day: number;
|
||||
stage: number;
|
||||
phase: string;
|
||||
/**
|
||||
* Whose move it is, or `null` — which is not an error state: the Mainline Phase runs itself, and
|
||||
* a finished game waits on nobody.
|
||||
*/
|
||||
waitingOn: { seat: PlayerIndex; name: string } | null;
|
||||
};
|
||||
|
||||
export type IntentResult =
|
||||
@@ -96,6 +123,12 @@ export type GameSession = {
|
||||
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult;
|
||||
/** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */
|
||||
exportSave(): SavedGame;
|
||||
/**
|
||||
* A cheap description of where this game has got to. Deliberately does not copy `history` the
|
||||
* way `exportSave` must — the health check polls this on a timer, and an administrator listing
|
||||
* games wants the state of each, not a copy of every intent in all of them.
|
||||
*/
|
||||
summary(): GameSummary;
|
||||
};
|
||||
|
||||
type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
|
||||
@@ -105,7 +138,9 @@ function buildSession(
|
||||
playerNames: string[],
|
||||
createdAt: number,
|
||||
botSeats: Set<PlayerIndex>,
|
||||
lastMoveAtInit: number,
|
||||
): GameSession {
|
||||
let lastMoveAt = lastMoveAtInit;
|
||||
const lastSeq = new Map<PlayerIndex, number>();
|
||||
const lastFrame = new Map<PlayerIndex, Frame>();
|
||||
const sentLines = new Map<PlayerIndex, number>();
|
||||
@@ -229,6 +264,7 @@ function buildSession(
|
||||
if (!applied) return { accepted: false, code: 'REJECTED' };
|
||||
|
||||
lastSeq.set(seat, seq);
|
||||
lastMoveAt = Date.now();
|
||||
const timing = settleTiming();
|
||||
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
|
||||
// diffs against whatever was last sent, so it captures the bots' moves along with the human's
|
||||
@@ -246,6 +282,23 @@ function buildSession(
|
||||
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||
createdAt,
|
||||
botSeats: [...botSeats],
|
||||
lastMoveAt,
|
||||
};
|
||||
},
|
||||
|
||||
summary() {
|
||||
const actor = currentActor(game);
|
||||
return {
|
||||
playerCount: playerNames.length,
|
||||
playerNames: [...playerNames],
|
||||
botSeats: [...botSeats],
|
||||
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||
createdAt,
|
||||
lastMoveAt,
|
||||
day: game.state.clock.day,
|
||||
stage: game.state.clock.stage,
|
||||
phase: game.state.clock.phase,
|
||||
waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${seatLabel(actor)}` },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -257,7 +310,8 @@ export function createSession(
|
||||
playerNames: string[],
|
||||
botSeats: PlayerIndex[] = [],
|
||||
): 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
|
||||
* known to have been recorded under the currently-running rules.
|
||||
*/
|
||||
/**
|
||||
* A resume that could not complete, and exactly where it gave up. `index.ts` turns this into the
|
||||
* refusal it logs, so the operator is told which move the current rules will not accept rather
|
||||
* than only that some version string differs.
|
||||
*/
|
||||
export type ResumeFailure = { stoppedAt: number; of: number; intent: string; code: string };
|
||||
|
||||
export function tryResumeSession(saved: SavedGame): { ok: true; session: GameSession } | { ok: false; failure: ResumeFailure } {
|
||||
const { game, stopped } = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
|
||||
if (stopped) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { stoppedAt: stopped.index, of: saved.history.length, intent: stopped.intent.type, code: stopped.code },
|
||||
};
|
||||
}
|
||||
return { ok: true, session: build(game, saved) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws on a save the current rules will not replay. Kept for callers that have already
|
||||
* established the save is good — the server boots through `tryResumeSession`, which answers
|
||||
* instead of throwing.
|
||||
*/
|
||||
export function resumeSession(saved: SavedGame): GameSession {
|
||||
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
|
||||
return buildSession(game, saved.playerNames, saved.createdAt, new Set(saved.botSeats));
|
||||
const r = tryResumeSession(saved);
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
`save does not replay under the current rules: intent ${r.failure.stoppedAt + 1} of ` +
|
||||
`${r.failure.of} (${r.failure.intent}) was rejected with ${r.failure.code}`,
|
||||
);
|
||||
}
|
||||
return r.session;
|
||||
}
|
||||
|
||||
function build(game: Game, saved: SavedGame): GameSession {
|
||||
return buildSession(
|
||||
game,
|
||||
saved.playerNames,
|
||||
saved.createdAt,
|
||||
new Set(saved.botSeats),
|
||||
saved.lastMoveAt ?? saved.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
+67
-5
@@ -28,7 +28,23 @@ export type BoardTrain = { label: string; consist: string[] };
|
||||
* The Division as a dispatcher would see it: one continuous line per running track, sections
|
||||
* separated by thin seams, capacity legible because the lines can be counted.
|
||||
*/
|
||||
export function divisionSvg(nodes: DivisionView[]): string {
|
||||
/**
|
||||
* Who is at the table, so an Office can be labelled with its owner rather than only its tier.
|
||||
*
|
||||
* Passed in rather than read off the nodes because a `DivisionView` knows its seat and nothing
|
||||
* about people — the roster lives on the `Frame`, keyed by player, and `seat` is what joins them.
|
||||
* Optional so the standalone replay (`replay.ts`, which serialises this function by `toString()`)
|
||||
* keeps working unchanged.
|
||||
*/
|
||||
export type DivisionRoster = {
|
||||
players: { index: number; seat: number; name: string }[];
|
||||
/** The player whose move it is, or null in an automatic phase. A PLAYER index, not a seat. */
|
||||
actor: number | null;
|
||||
/** The player this map is being drawn for. */
|
||||
viewer: number;
|
||||
};
|
||||
|
||||
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
|
||||
/**
|
||||
* THE WHOLE DIVISION, west to east, as one continuous route.
|
||||
*
|
||||
@@ -97,6 +113,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
tip: string;
|
||||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||||
seat: number | null;
|
||||
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||||
owner?: { name: string; isTurn: boolean; isYou: boolean } | null;
|
||||
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||
regions: number;
|
||||
w: number;
|
||||
@@ -116,12 +134,34 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
if (n.kind === 'office') {
|
||||
const cap = n.capacity;
|
||||
const ad = n.trains.flat();
|
||||
/**
|
||||
* THE NAME IS THE HEADLINE, the tier is the detail.
|
||||
*
|
||||
* "Where does Bob sit?" is the question this map could not answer: an Office was labelled
|
||||
* with its tier, which every player's Office also has, so four districts read the same. The
|
||||
* owner's name takes the headline and the tier moves down beside the A/D count, because the
|
||||
* name is what is being looked for and the tier is what is being referred to once found.
|
||||
*/
|
||||
const seatOwner =
|
||||
roster && n.seat !== null ? (roster.players.find((p) => p.seat === n.seat) ?? null) : null;
|
||||
const owner = seatOwner
|
||||
? {
|
||||
name: seatOwner.name,
|
||||
isTurn: roster!.actor === seatOwner.index,
|
||||
isYou: roster!.viewer === seatOwner.index,
|
||||
}
|
||||
: null;
|
||||
|
||||
for (const rc of n.running ?? []) {
|
||||
const isOffice = rc.kind === 'office';
|
||||
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
|
||||
push({
|
||||
kind: 'run',
|
||||
label: rc.label,
|
||||
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
|
||||
label: isOffice && owner ? owner.name : rc.label,
|
||||
owner: isOffice ? owner : null,
|
||||
// With an owner on the headline the tier would otherwise vanish, so it joins the A/D
|
||||
// count on the line below.
|
||||
sub: isOffice ? (owner ? [rc.label, adLabel].filter(Boolean).join(' · ') : adLabel) : '',
|
||||
/**
|
||||
* A train standing at the Office occupies an A/D track, which is where it is — but it is
|
||||
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
|
||||
@@ -131,7 +171,12 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
|
||||
: rc.trains,
|
||||
cap: isOffice ? cap : null,
|
||||
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
||||
tip: owner && isOffice
|
||||
? `${owner.name}'s ${rc.label}` +
|
||||
(owner.isYou ? ' — this is your railroad' : '') +
|
||||
// "their move" is wrong when the reader is the one being waited on.
|
||||
(owner.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '')
|
||||
: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
||||
seat: n.seat ?? null,
|
||||
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
||||
// occupies a card outright rather than a part of one.
|
||||
@@ -279,7 +324,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
const full = c.cap !== null && c.trains.length >= c.cap;
|
||||
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
|
||||
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
|
||||
out += `<text class="bs-name" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label)}</text>`;
|
||||
/**
|
||||
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
|
||||
* than by a legend. Amber is the same "it is happening here" the action panel uses; "(you)"
|
||||
* is spelled out because a colour alone cannot say which of four railroads is the reader's,
|
||||
* and that is the first thing anybody wants to know at a table they just sat down at.
|
||||
*/
|
||||
const mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : '';
|
||||
const suffix = c.owner?.isYou ? ' (you)' : '';
|
||||
out += `<text class="bs-name${mark}" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label + suffix)}</text>`;
|
||||
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
|
||||
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
|
||||
|
||||
@@ -1090,6 +1143,15 @@ export const BOARD_CSS = `
|
||||
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
||||
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||
.bs-name.bs-you{fill:#5aa9e6}
|
||||
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
|
||||
seconds and which railroad is yours never does.
|
||||
|
||||
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.bs-full{fill:#e0a060;font-weight:600}
|
||||
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
||||
|
||||
@@ -74,7 +74,6 @@ const SOLO = (length: GameLength, mode: GameMode): GameConfig => {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -76,7 +76,6 @@ function configFor(mode: GameMode, length: GameLength, players: number): GameCon
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -94,6 +94,11 @@ export type NarrateContext = {
|
||||
* game, phrased in internal identifiers.
|
||||
*/
|
||||
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 {
|
||||
@@ -104,6 +109,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
// -- clock
|
||||
case 'stageBegan':
|
||||
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':
|
||||
// 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.
|
||||
|
||||
+4
-2
@@ -68,7 +68,6 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
@@ -513,7 +512,10 @@ function render() {
|
||||
|
||||
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||
|
||||
$('division').innerHTML = divisionSvg(DIV);
|
||||
// players, actor and viewer are not among the delta'd keys (see compress), so they ride whole on
|
||||
// every frame and the replay names the districts exactly as the live page does. No backticks in
|
||||
// this comment: it is inside the generated-page template literal, which they would terminate.
|
||||
$('division').innerHTML = divisionSvg(DIV, { players: f.players, actor: f.actor, viewer: f.viewer });
|
||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
|
||||
|
||||
|
||||
@@ -275,6 +275,22 @@ export type RunningCardView = {
|
||||
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 = {
|
||||
kind: string;
|
||||
label: string;
|
||||
@@ -370,6 +386,23 @@ export type Frame = {
|
||||
* player order once §4.4's D12 decided who sits where.
|
||||
*/
|
||||
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
|
||||
/**
|
||||
* WHO THIS FRAME WAS BUILT FOR.
|
||||
*
|
||||
* Every private thing on a Frame is already scoped to one player — the hand, the Office Area,
|
||||
* `revenue`, `option`, `movesLeft` — but nothing said which player that was, so a page rendering
|
||||
* it could show a railroad without being able to say whose it is. Harmless in solitaire, where
|
||||
* there is only one; the first thing you want to know at a four-player table.
|
||||
*/
|
||||
viewer: number;
|
||||
/** The viewer's position in the west-to-east chain, which is not their player index (§4.4). */
|
||||
viewerSeat: number;
|
||||
/**
|
||||
* §4.4's opening D12 per player, and the roll that chose the Superintendent — kept so a client
|
||||
* can show the chain being formed rather than only its result (`lobby-and-sessions.md` §4).
|
||||
* Indexed by player, like `s.players`, not by seat.
|
||||
*/
|
||||
openingRolls: { division: number[]; superintendent: number[] };
|
||||
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
|
||||
handCount: number;
|
||||
/**
|
||||
@@ -1241,6 +1274,12 @@ export function snapshot(
|
||||
revenue: p.revenue,
|
||||
hand: (s.decks.hands.get(p.index) ?? []).length,
|
||||
})),
|
||||
viewer,
|
||||
viewerSeat,
|
||||
openingRolls: {
|
||||
division: [...s.openingRolls.division],
|
||||
superintendent: [...s.openingRolls.superintendent],
|
||||
},
|
||||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||||
overHandLimit:
|
||||
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
|
||||
|
||||
+22
-7
@@ -76,7 +76,6 @@ export const SOLO_CONFIG: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
@@ -104,7 +103,6 @@ export function defaultMultiplayerConfig(mode: 'competitive' | 'coop', players =
|
||||
pvpCardsAllowed: mode === 'competitive',
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: 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
|
||||
* touch-in-passing.
|
||||
*/
|
||||
/**
|
||||
* Why the intent a replay stopped at is reported rather than swallowed.
|
||||
*
|
||||
* The loop below has always stopped at the first intent the engine will not accept, and used to do
|
||||
* it in silence — which is the one outcome nobody can afford to guess at, because the result is a
|
||||
* game that looks fine and is short of where it should be. That silence was survivable only
|
||||
* because `loadGame` refused any save whose engine version was not an exact match, so a replay
|
||||
* that could fail was never attempted. Refusing on the version is a proxy question, though, and it
|
||||
* answered "no" for four releases running that changed no rules at all — so the real question gets
|
||||
* asked instead, and its answer has to be legible.
|
||||
*/
|
||||
export type ReplayStop = { index: number; intent: Intent; code: string };
|
||||
|
||||
export function fromMultiplayerSave(
|
||||
seed: number,
|
||||
config: GameConfig,
|
||||
playerNames: string[],
|
||||
history: Intent[],
|
||||
): Game {
|
||||
): { game: Game; stopped: ReplayStop | null } {
|
||||
const game = newMultiplayerGame(seed, config, playerNames);
|
||||
for (const intent of history) {
|
||||
for (const [index, intent] of history.entries()) {
|
||||
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);
|
||||
if (!result.ok) break;
|
||||
if (!result.ok) {
|
||||
return { game, stopped: { index, intent, code: result.code } };
|
||||
}
|
||||
game.history.push(intent);
|
||||
record(game, result.events, actor);
|
||||
drain(game);
|
||||
}
|
||||
return game;
|
||||
return { game, stopped: null };
|
||||
}
|
||||
|
||||
+95
-14
@@ -13,6 +13,9 @@
|
||||
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import { defaultMultiplayerConfig } from './game.ts';
|
||||
import { STARTING_HAND_LABELS, houseRules } from '../engine/content.ts';
|
||||
import type { StartingHand } from '../engine/content.ts';
|
||||
import { seatLabel } from '../sim/view.ts';
|
||||
|
||||
export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex };
|
||||
|
||||
@@ -66,18 +69,17 @@ export function runLobby(onReady: (r: LobbyReady) => void): 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 cap = lobby.config.mode === 'solitaire' ? 1 : 4;
|
||||
|
||||
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 isYou = occupant?.kind === 'human' && occupant.token === token;
|
||||
const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken;
|
||||
const who =
|
||||
occupant === null
|
||||
? '<span class="dim">— empty —</span>'
|
||||
? '<span class="dim">— waiting —</span>'
|
||||
: occupant.kind === 'bot'
|
||||
? 'Bot'
|
||||
: `${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>`;
|
||||
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;
|
||||
|
||||
/**
|
||||
* The code is the whole invitation, so it has to leave this screen by some route other than
|
||||
* being read off it and retyped. `navigator.clipboard` is unavailable on an insecure origin
|
||||
* and can be refused outright, so a failure says the code is there to be selected rather than
|
||||
* silently doing nothing.
|
||||
*/
|
||||
const copyBtn = $<HTMLButtonElement>('lb-copy');
|
||||
copyBtn.onclick = () => {
|
||||
const say = (m: string): void => {
|
||||
$('lb-copied').textContent = m;
|
||||
setTimeout(() => ($('lb-copied').textContent = ''), 4000);
|
||||
};
|
||||
void navigator.clipboard
|
||||
?.writeText(lobby.gameCode)
|
||||
.then(() => say('Copied.'))
|
||||
.catch(() => say('Could not copy — select the code above instead.'));
|
||||
};
|
||||
|
||||
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-add'))) {
|
||||
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
const filled = 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 waiting = lobby.seats.filter((s) => s === null).length;
|
||||
const startBtn = $<HTMLButtonElement>('lb-start');
|
||||
startBtn.hidden = !isHost;
|
||||
startBtn.disabled = !(noGaps && legalCount);
|
||||
startBtn.disabled = waiting > 0;
|
||||
$('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.';
|
||||
startBtn.onclick = () => {
|
||||
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 = () => {
|
||||
const displayName = $<HTMLInputElement>('lb-name').value.trim();
|
||||
const mode = ($('lb-choice-section').querySelector<HTMLInputElement>('input[name="lb-mode"]:checked')?.value ??
|
||||
'competitive') as 'competitive' | 'coop';
|
||||
const mode = chosenMode();
|
||||
if (displayName === '') {
|
||||
setError('lb-create-err', 'enter a display name first');
|
||||
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 }) => {
|
||||
if (status !== 200) {
|
||||
setError('lb-create-err', String(body['error'] ?? 'could not create the game'));
|
||||
|
||||
+77
-4
@@ -8,6 +8,7 @@
|
||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { seatLabel } from '../sim/view.ts';
|
||||
import type { Menu, Save } from './game.ts';
|
||||
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
@@ -333,7 +334,11 @@ function showScreen(which: 'lobby' | 'gameui'): void {
|
||||
function beginRemote(ready: LobbyReady): void {
|
||||
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
|
||||
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();
|
||||
// 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
|
||||
@@ -342,6 +347,31 @@ function beginRemote(ready: LobbyReady): void {
|
||||
session.subscribe(render);
|
||||
}
|
||||
|
||||
/**
|
||||
* The game this browser remembered is gone, so stop waiting for it and go somewhere useful.
|
||||
*
|
||||
* Two things legitimately destroy a game under a seated player, and both are by design: an
|
||||
* engine-version bump refuses to resume it (D7 — a move legal under the old rules may not be under
|
||||
* the new ones), and an administrator ends it. Neither used to be survivable here. The remembered
|
||||
* token sent `start()` straight past the lobby into a game that no longer existed, `EventSource`
|
||||
* retried the 404 in silence, and the player sat on a blank page with no controls and no way back
|
||||
* short of clearing site data.
|
||||
*
|
||||
* Forgetting the token is what makes the next load land in the lobby instead of repeating it.
|
||||
*/
|
||||
function abandonRemote(): void {
|
||||
localStorage.removeItem(REMOTE_KEY);
|
||||
showScreen('lobby');
|
||||
$('presence').textContent = '';
|
||||
runLobby(beginRemote);
|
||||
const note = document.getElementById('lb-create-err');
|
||||
if (note) {
|
||||
note.textContent =
|
||||
'That game is no longer on this server — it was either ended by whoever runs it, or the ' +
|
||||
'service was updated, which does not carry games in progress across. Create or join a new one.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through
|
||||
* `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL
|
||||
@@ -352,6 +382,9 @@ function beginRemote(ready: LobbyReady): void {
|
||||
function start(): void {
|
||||
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();
|
||||
if (remembered) {
|
||||
beginRemote(remembered);
|
||||
@@ -408,6 +441,41 @@ function applyCapabilities(): void {
|
||||
hide('multiplayer', c.newGame);
|
||||
}
|
||||
|
||||
/**
|
||||
* The west-to-east chain in words, with the D12 that decided it (§4.4).
|
||||
*
|
||||
* The map shows where everyone ended up; this says WHY, which is the half `state.openingRolls` was
|
||||
* kept for. It is also the answer to "am I always at the eastern end" — no, the roll decides, and
|
||||
* here is the roll.
|
||||
*/
|
||||
function renderSeatingChain(f: Frame): void {
|
||||
const el = document.getElementById('seating-chain');
|
||||
if (!el) return;
|
||||
/**
|
||||
* ONLY WHILE THE GAME IS STILL OPENING.
|
||||
*
|
||||
* This answers "who is where, and why" — which is a question you have once, at the start, when
|
||||
* the chain has just been rolled and the names are new. By Day 1 Stage 2 the map itself has been
|
||||
* answering it for a while, and a permanent line restating it is a permanent line to read past.
|
||||
*/
|
||||
const opening = f.day === 1 && f.stage === 1;
|
||||
if (f.players.length < 2 || !opening) {
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat);
|
||||
const chain = bySeat
|
||||
.map((p) => {
|
||||
const roll = f.openingRolls.division[p.index];
|
||||
const marks = [p.index === f.viewer ? 'you' : '', p.index === f.actor ? 'now' : '']
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return `${p.name}${roll === undefined ? '' : ` (${roll})`}${marks ? ` [${marks}]` : ''}`;
|
||||
})
|
||||
.join(' → ');
|
||||
el.textContent = `West to East: ${chain}. Order set by the opening D12 — highest roll takes the eastern end.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table
|
||||
* has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
|
||||
@@ -418,7 +486,7 @@ function renderPresence(f: Frame): void {
|
||||
const away = session
|
||||
.presence()
|
||||
.filter((p) => !p.connected)
|
||||
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${p.seat}`);
|
||||
.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`;
|
||||
}
|
||||
|
||||
@@ -460,11 +528,16 @@ function render(): void {
|
||||
obj.className = 'pace';
|
||||
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
|
||||
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
|
||||
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${session.seat()}`;
|
||||
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`;
|
||||
renderHouseRules(f.houseRules);
|
||||
|
||||
// -- division
|
||||
$('division').innerHTML = divisionSvg(f.division);
|
||||
$('division').innerHTML = divisionSvg(f.division, {
|
||||
players: f.players,
|
||||
actor: f.actor,
|
||||
viewer: f.viewer,
|
||||
});
|
||||
renderSeatingChain(f);
|
||||
applyZoom($('division'));
|
||||
|
||||
// -- board. Both renderers are shared with the replay so the two can never draw different
|
||||
|
||||
+97
-3
@@ -35,6 +35,13 @@ header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
|
||||
header button:disabled:hover{border-color:#2c333d}
|
||||
.zoom{display:inline-flex;align-items:center;gap:4px}
|
||||
.zoom button{padding:3px 9px;line-height:1}
|
||||
.lb-invite{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 10px;
|
||||
background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px}
|
||||
.lb-invite-label{font-size:11px;color:var(--dim)}
|
||||
.lb-invite-code{font-size:22px;font-weight:700;letter-spacing:.08em;color:#f2e6cf}
|
||||
#lb-settings{margin:10px 0;border:1px solid var(--line);border-radius:7px;padding:8px 12px;background:#171c23}
|
||||
#lb-settings summary{cursor:pointer;font-size:13px;color:#9fb6d8}
|
||||
#lb-settings h3{font-size:12px;margin:12px 0 4px;color:#9fb6d8}
|
||||
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
|
||||
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
|
||||
.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;
|
||||
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
|
||||
button:hover{background:#39424e;border-color:#4d6fa8}
|
||||
/* GENERIC, and it was not. `header button:disabled` and `#actions button:disabled` were the only
|
||||
disabled styles on the page, so a disabled button anywhere else — #lb-start being the one that
|
||||
mattered — kept its normal face AND still lit up under the cursor from the rule above. It was
|
||||
advertising a click it would refuse. */
|
||||
button:disabled{opacity:.45;cursor:not-allowed}
|
||||
button:disabled:hover{background:#2a3038;border-color:var(--line)}
|
||||
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
|
||||
color:#f2e6cf;font-weight:600}
|
||||
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
||||
@@ -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>
|
||||
<label class="ng-radio"><input type="radio" name="lb-mode" value="coop">
|
||||
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
|
||||
<label class="ng-num"><span>Players at the table</span>
|
||||
<select id="lb-players">
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4" selected>4</option>
|
||||
</select></label>
|
||||
<details id="lb-settings">
|
||||
<summary>Game settings</summary>
|
||||
<p class="ng-note">The same dials the solitaire New Game dialog has. Picking a mode or a table
|
||||
size sets them to that game's defaults; every one stays yours to change. They are fixed when
|
||||
the game is created and cannot be changed once it starts.</p>
|
||||
|
||||
<h3>Seed</h3>
|
||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||
<input id="lb-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
|
||||
|
||||
<h3>Starting hand</h3>
|
||||
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
||||
either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeRandom" checked>
|
||||
<span>Three random cards</span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-hand" value="sixRandom">
|
||||
<span>Six random cards</span></label>
|
||||
<label class="ng-radio"><input type="radio" name="lb-hand" value="threeTrackThreeOther">
|
||||
<span>Three track and three other</span></label>
|
||||
|
||||
<h3>Revenue</h3>
|
||||
<p class="ng-note">What each piece of work pays, 0 to 5. Zero switches an economy off so the
|
||||
others can be read.</p>
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
<input id="lb-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<label class="ng-num"><span>Freight revenue per load</span>
|
||||
<input id="lb-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<label class="ng-num"><span>Train revenue per transit</span>
|
||||
<input id="lb-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
|
||||
<h3>Victory conditions</h3>
|
||||
<p class="ng-note">How long the game runs, and the ways it can end. 0 turns any of these off.
|
||||
The combined-Revenue default is sized for the table you chose above.</p>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="lb-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
<label class="ng-num"><span>Minimum combined Revenue to avoid a loss</span>
|
||||
<input id="lb-minrev" type="number" min="0" step="1" value="60"></label>
|
||||
<label class="ng-num"><span>Collisions in one Day that end the game</span>
|
||||
<input id="lb-colday" type="number" min="0" step="1" value="3"></label>
|
||||
<label class="ng-num"><span>Collisions across the whole game that end it</span>
|
||||
<input id="lb-coltotal" type="number" min="0" step="1" value="5"></label>
|
||||
<label class="ng-num"><span>Allow the opponent-directed cards</span>
|
||||
<input id="lb-pvp" type="checkbox"></label>
|
||||
<p class="ng-note" id="lb-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect
|
||||
either way until then.</p>
|
||||
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Appendix B. Off by default; each one changes how the game plays.</p>
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
<input id="lb-visibility" type="checkbox"></label>
|
||||
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one chair
|
||||
left and takes over the next station up the line. Your Revenue and the Fedora go with you;
|
||||
the district stays where it is</span>
|
||||
<input id="lb-rotation" type="checkbox"></label>
|
||||
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand of
|
||||
four; play or discard down to three on the first turn</span>
|
||||
<input id="lb-toolbox" type="checkbox"></label>
|
||||
</details>
|
||||
<p class="ng-note">Every chair has to be taken before the game can start — by a person or by a
|
||||
bot. Pick the size of the table now; it cannot change once the game is created.</p>
|
||||
<button id="lb-create">Create game</button>
|
||||
<p class="dim" id="lb-create-err" role="alert"></p>
|
||||
|
||||
@@ -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. -->
|
||||
<section id="lb-seating-section" hidden>
|
||||
<h2>Seating <span class="dim" id="lb-gamecode"></span></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>
|
||||
<h2>Seating</h2>
|
||||
<div class="lb-invite">
|
||||
<div>
|
||||
<div class="lb-invite-label">Send this code to your players</div>
|
||||
<div class="lb-invite-code" id="lb-gamecode"></div>
|
||||
</div>
|
||||
<button id="lb-copy" class="ghost">Copy</button>
|
||||
<span class="ng-note" id="lb-copied"></span>
|
||||
</div>
|
||||
<p class="ng-note">They enter it under <b>Join a game</b>, along with the same join secret you used.</p>
|
||||
<p class="ng-note">The host may fill an empty seat with a bot, and starts the game once every
|
||||
seat is either a player or a bot. <b>These chairs are not the running order</b> — who sits
|
||||
where along the Division is decided by a D12 roll when the game starts (§4.4), and the map
|
||||
shows the result.</p>
|
||||
<div id="lb-seats"></div>
|
||||
<button id="lb-start" disabled>Start game</button>
|
||||
<p class="dim" id="lb-start-note"></p>
|
||||
@@ -307,7 +400,8 @@ ul.blocked li{padding:2px 0}
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div>
|
||||
<p class="ng-note" id="seating-chain"></p></section>
|
||||
<section id="district">
|
||||
<h2>Your Office Area
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
|
||||
+34
-1
@@ -226,7 +226,16 @@ type Push = {
|
||||
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
|
||||
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
|
||||
*/
|
||||
export function createRemoteSession(token: string, seat: PlayerIndex): 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 menu: Menu | null = null;
|
||||
let lines: { text: string; tone: string }[] = [];
|
||||
@@ -239,6 +248,30 @@ export function createRemoteSession(token: string, seat: PlayerIndex): Session {
|
||||
|
||||
const qs = `token=${encodeURIComponent(token)}`;
|
||||
const source = new EventSource(`/api/stream?${qs}`);
|
||||
|
||||
/**
|
||||
* A DROPPED CONNECTION AND A DEAD GAME LOOK IDENTICAL HERE, so ask before giving up.
|
||||
*
|
||||
* `EventSource` fires `error` for both a transient blip — which it recovers from by itself, and
|
||||
* which is the expected shape of a game that sits idle for minutes (multiplayer.md §9) — and a
|
||||
* 404 it will nonetheless retry forever. It exposes no status code either way. `/api/session` is
|
||||
* the cheap question that separates them: only a definite 404 closes the stream and reports the
|
||||
* game gone, so a flaky network still self-heals.
|
||||
*/
|
||||
let reportedGone = false;
|
||||
source.onerror = () => {
|
||||
if (reportedGone) return;
|
||||
void fetch(`/api/session?${qs}`)
|
||||
.then((r) => {
|
||||
if (r.status !== 404 || reportedGone) return;
|
||||
reportedGone = true;
|
||||
source.close();
|
||||
onGone?.();
|
||||
})
|
||||
.catch(() => {
|
||||
// The probe itself failed, so this says nothing about the game — leave the retry running.
|
||||
});
|
||||
};
|
||||
source.onmessage = (ev: MessageEvent<string>) => {
|
||||
const push = JSON.parse(ev.data) as Push;
|
||||
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
|
||||
|
||||
@@ -25,7 +25,6 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -25,7 +25,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
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 at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
|
||||
@@ -48,6 +48,9 @@ const KNOWN_UNREDUCED = [
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
'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',
|
||||
'trainArrived',
|
||||
'trainCompleted',
|
||||
|
||||
@@ -17,7 +17,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -36,7 +36,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
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 at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
@@ -737,7 +737,7 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
||||
seed: 4,
|
||||
config: {
|
||||
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'],
|
||||
});
|
||||
@@ -800,7 +800,7 @@ describe('Q13 — a train that catches the one ahead runs into it', () => {
|
||||
id: 'rear', seed: 3,
|
||||
config: {
|
||||
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'],
|
||||
});
|
||||
|
||||
+163
-3
@@ -12,13 +12,14 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.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 type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { 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 { join } from 'node:path';
|
||||
import { actionMenu } from '../src/web/game.ts';
|
||||
@@ -36,7 +37,7 @@ const competitive: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
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 =>
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
+78
-32
@@ -25,7 +25,6 @@ const competitive: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
@@ -33,17 +32,20 @@ const competitive: GameConfig = {
|
||||
const solitaire: GameConfig = { ...competitive, mode: 'solitaire' };
|
||||
|
||||
describe('creating and joining', () => {
|
||||
it('the creator is the host, takes seat 0, and is first in join order', () => {
|
||||
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001');
|
||||
it('seats the host at 0 and lays out the whole table at once', () => {
|
||||
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 3);
|
||||
assert.equal(session.player, 0);
|
||||
assert.equal(lobby.hostToken, session.token);
|
||||
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.slice(1), [null, null]);
|
||||
assert.deepEqual(lobby.joinOrder, [session.token]);
|
||||
});
|
||||
|
||||
it('fills the next empty seat, in order', () => {
|
||||
const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001');
|
||||
const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
|
||||
const j2 = joinLobby(l1, 'Bob');
|
||||
assert.ok(j2.ok);
|
||||
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]);
|
||||
});
|
||||
|
||||
it('refuses a 5th join to a competitive lobby (cap 4)', () => {
|
||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0001').lobby;
|
||||
it('refuses a join once every chair is taken', () => {
|
||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0001', 4).lobby;
|
||||
for (const name of ['Bob', 'Carol', 'Dave']) {
|
||||
const r = joinLobby(lobby, name);
|
||||
assert.ok(r.ok);
|
||||
@@ -66,8 +68,8 @@ describe('creating and joining', () => {
|
||||
assert.deepEqual(fifth, { ok: false, code: 'LOBBY_FULL' });
|
||||
});
|
||||
|
||||
it('refuses a 2nd join to a solitaire lobby (cap 1)', () => {
|
||||
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002');
|
||||
it('refuses a 2nd join to a one-chair table', () => {
|
||||
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002', 1);
|
||||
const second = joinLobby(lobby, 'Bob');
|
||||
assert.deepEqual(second, { ok: false, code: 'LOBBY_FULL' });
|
||||
});
|
||||
@@ -75,20 +77,32 @@ describe('creating and joining', () => {
|
||||
it('rejoins into a seat an earlier player vacated, not past the end', () => {
|
||||
// Joining always fills the FIRST empty seat, so a bot-seat cleared back to empty (setBotSeat)
|
||||
// is exactly as joinable as one nobody ever filled.
|
||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0003').lobby;
|
||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0003', 3).lobby;
|
||||
lobby = setBotSeat(lobby, 1, true);
|
||||
lobby = setBotSeat(lobby, 1, false);
|
||||
const r = joinLobby(lobby, 'Bob');
|
||||
assert.ok(r.ok);
|
||||
if (!r.ok) return;
|
||||
assert.equal(r.session.player, 1, 'should take the reopened seat 1, not append at seat 1 anyway by coincidence — check seat 2 stays empty');
|
||||
assert.equal(r.lobby.seats.length, 2);
|
||||
assert.equal(r.session.player, 1, 'should take the reopened chair 1');
|
||||
assert.equal(r.lobby.seats.length, 3, 'joining must never resize the table');
|
||||
assert.equal(r.lobby.seats[2], null);
|
||||
});
|
||||
|
||||
it('never grows the table, whoever asks', () => {
|
||||
// The old model appended a seat for anyone who turned up, which is how a lobby could end up
|
||||
// holding more chairs than the host ever asked for.
|
||||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0013', 2);
|
||||
const bob = joinLobby(lobby, 'Bob');
|
||||
assert.ok(bob.ok);
|
||||
if (!bob.ok) return;
|
||||
assert.equal(bob.lobby.seats.length, 2);
|
||||
assert.deepEqual(joinLobby(bob.lobby, 'Carol'), { ok: false, code: 'LOBBY_FULL' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot seats', () => {
|
||||
it('fills only an empty seat, and clears only a bot seat', () => {
|
||||
const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004');
|
||||
const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004', 2);
|
||||
const withBot = setBotSeat(l0, 1, true);
|
||||
assert.deepEqual(withBot.seats[1], { kind: 'bot' });
|
||||
|
||||
@@ -103,11 +117,21 @@ describe('bot seats', () => {
|
||||
const cleared = setBotSeat(withBot, 1, false);
|
||||
assert.equal(cleared.seats[1], null);
|
||||
});
|
||||
|
||||
it('refuses a chair that is not at the table, instead of padding one in', () => {
|
||||
// Padding is what used to put a hole in the seats array: dropping a bot into chair 3 of a
|
||||
// 2-chair table grew it to 4 with a null at 2, and Start then refused for reasons the host
|
||||
// had no way to see.
|
||||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0014', 2);
|
||||
assert.equal(setBotSeat(lobby, 3, true), lobby);
|
||||
assert.equal(setBotSeat(lobby, 2, true), lobby);
|
||||
assert.equal(lobby.seats.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host transfer', () => {
|
||||
it('passes to the earliest-joined remaining human seat when the host departs', () => {
|
||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0005').lobby;
|
||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0005', 2).lobby;
|
||||
const hostToken = lobby.hostToken;
|
||||
const j2 = joinLobby(lobby, 'Bob');
|
||||
assert.ok(j2.ok);
|
||||
@@ -121,13 +145,13 @@ describe('host transfer', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
assert.equal(after, lobby);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal(after.hostToken, session.token);
|
||||
});
|
||||
@@ -135,40 +159,62 @@ describe('host transfer', () => {
|
||||
|
||||
describe('starting', () => {
|
||||
it('refuses a non-host caller', () => {
|
||||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0008');
|
||||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0008', 2);
|
||||
joinLobby(lobby, 'Bob');
|
||||
assert.deepEqual(startLobby(lobby, 'someone-elses-token'), { ok: false, code: 'NOT_HOST' });
|
||||
});
|
||||
|
||||
it('refuses to start with a gap in the seats', () => {
|
||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0009').lobby;
|
||||
it('refuses to start while a chair is still empty', () => {
|
||||
const lobby = createLobby(competitive, 'Alice', 'RAIL-0009', 3).lobby;
|
||||
const j2 = joinLobby(lobby, 'Bob');
|
||||
assert.ok(j2.ok);
|
||||
if (!j2.ok) return;
|
||||
const j3 = joinLobby(j2.lobby, 'Carol');
|
||||
assert.ok(j3.ok);
|
||||
if (!j3.ok) return;
|
||||
lobby = { ...j3.lobby, seats: [j3.lobby.seats[0]!, null, j3.lobby.seats[2]!] };
|
||||
assert.deepEqual(startLobby(j2.lobby, j2.lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
|
||||
});
|
||||
|
||||
it('refuses a solo human at a table sized for more', () => {
|
||||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010', 2);
|
||||
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
|
||||
});
|
||||
|
||||
it('refuses a solo human in a competitive lobby (needs 2-4)', () => {
|
||||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010');
|
||||
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;
|
||||
it('starts a full 2-player lobby, naming humans by their display name and numbering the bot', () => {
|
||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0011', 2).lobby;
|
||||
lobby = setBotSeat(lobby, 1, true);
|
||||
const r = startLobby(lobby, lobby.hostToken);
|
||||
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot'], 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', () => {
|
||||
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012');
|
||||
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012', 1);
|
||||
const r = startLobby(lobby, lobby.hostToken);
|
||||
assert.deepEqual(r, { ok: true, playerNames: ['Alice'], botSeats: [] });
|
||||
});
|
||||
|
||||
it('seat index is player index, with no compaction to shift it', () => {
|
||||
// The seats array is never resized or squeezed, so the chair a player joined into is the
|
||||
// player index the game gives them — which is what every PlayerSession already recorded at
|
||||
// join time, and what /api/stream and /api/intent route by.
|
||||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0015', 4).lobby;
|
||||
const bob = joinLobby(lobby, 'Bob');
|
||||
assert.ok(bob.ok);
|
||||
if (!bob.ok) return;
|
||||
lobby = setBotSeat(setBotSeat(bob.lobby, 2, true), 3, true);
|
||||
const r = startLobby(lobby, lobby.hostToken);
|
||||
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bob', 'Bot 1', 'Bot 2'], botSeats: [2, 3] });
|
||||
assert.equal(bob.session.player, 1, "Bob's stored player index still names his chair");
|
||||
});
|
||||
});
|
||||
|
||||
describe('playerCountAllowed', () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
@@ -46,29 +45,30 @@ describe('game persistence (Phase 3)', () => {
|
||||
it('writes and reads back exactly what was written', () =>
|
||||
withTempDir(async (dir) => {
|
||||
await writeGame(dir, saved, '1.2.3');
|
||||
const result = await loadGame(dir, '1.2.3');
|
||||
const result = await loadGame(dir);
|
||||
assert.equal(result.found, true);
|
||||
if (!result.found) return;
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.deepEqual(result.saved, saved);
|
||||
}));
|
||||
|
||||
it('refuses a version mismatch explicitly, naming both versions', () =>
|
||||
it('reports the version that wrote the file without judging it', () =>
|
||||
withTempDir(async (dir) => {
|
||||
// Reading a save no longer refuses on the version. The stamp is the PACKAGE version, which
|
||||
// moves for reasons unrelated to the rules, and gating on it destroyed every game in progress
|
||||
// across four releases — one of which only changed how the board is drawn. Whether a save
|
||||
// still replays is decided by replaying it (`tryResumeSession`); the version is kept because
|
||||
// it is worth naming in a failure, and nothing else.
|
||||
await writeGame(dir, saved, '1.2.3');
|
||||
const result = await loadGame(dir, '9.9.9');
|
||||
const result = await loadGame(dir);
|
||||
assert.equal(result.found, true);
|
||||
if (!result.found) return;
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.storedVersion, '1.2.3');
|
||||
assert.equal(result.currentVersion, '9.9.9');
|
||||
assert.deepEqual(result.saved, saved);
|
||||
}));
|
||||
|
||||
it('reports not-found rather than throwing when nothing has been saved yet', () =>
|
||||
withTempDir(async (dir) => {
|
||||
const result = await loadGame(dir, '1.2.3');
|
||||
const result = await loadGame(dir);
|
||||
assert.deepEqual(result, { found: false });
|
||||
}));
|
||||
|
||||
@@ -77,11 +77,9 @@ describe('game persistence (Phase 3)', () => {
|
||||
await writeGame(dir, saved, '1.2.3');
|
||||
const grown: SavedGame = { ...saved, history: [...saved.history, { type: 'draw.end' }] };
|
||||
await writeGame(dir, grown, '1.2.3');
|
||||
const result = await loadGame(dir, '1.2.3');
|
||||
const result = await loadGame(dir);
|
||||
assert.equal(result.found, true);
|
||||
if (!result.found) return;
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.equal(result.saved.history.length, 2);
|
||||
}));
|
||||
|
||||
|
||||
+116
-3
@@ -6,8 +6,8 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts';
|
||||
import type { Push } from '../../src/server/session.ts';
|
||||
import { createSession, resumeSession } from '../../src/server/session.ts';
|
||||
import type { GameSession, Push } from '../../src/server/session.ts';
|
||||
import { createSession, resumeSession, tryResumeSession } from '../../src/server/session.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
@@ -18,7 +18,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
@@ -234,3 +233,117 @@ describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => {
|
||||
assert.equal(session.exportSave().status, 'active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summary() — what an administrator sees without replaying the game', () => {
|
||||
it('describes a fresh game: who is at the table, where it has got to, and who it waits on', () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob']);
|
||||
const s = session.summary();
|
||||
|
||||
assert.equal(s.playerCount, 2);
|
||||
assert.deepEqual(s.playerNames, ['Alice', 'Bob']);
|
||||
assert.equal(s.status, 'active');
|
||||
assert.equal(s.day, 1);
|
||||
assert.equal(s.stage, 1);
|
||||
assert.equal(typeof s.phase, 'string');
|
||||
assert.ok(s.waitingOn, 'a game in play must be waiting on somebody');
|
||||
assert.equal(s.waitingOn!.name, s.playerNames[s.waitingOn!.seat]);
|
||||
});
|
||||
|
||||
it('does not hand back a copy of the history the way exportSave must', () => {
|
||||
// The health check polls this on a timer, so it answering with every intent of every game
|
||||
// would make a question about none of them cost a copy of all of them.
|
||||
const session = createSession(42, config, ['Alice', 'Bob']);
|
||||
assert.equal('history' in session.summary(), false);
|
||||
});
|
||||
|
||||
it('moves lastMoveAt when a move is accepted, and leaves it alone when one is refused', async () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob']);
|
||||
const created = session.summary();
|
||||
assert.equal(created.lastMoveAt, created.createdAt, 'an untouched game has not moved since it began');
|
||||
|
||||
const actor = (session.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex;
|
||||
const idle = (1 - actor) as PlayerIndex;
|
||||
|
||||
// A rejection is not a move — a player poking at a game they cannot act in must not make it
|
||||
// look alive to whoever is deciding whether it has stalled.
|
||||
session.intent(idle, 1, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(session.summary().lastMoveAt, created.lastMoveAt, 'a refused intent moved the clock');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
const accepted = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(accepted.accepted, true);
|
||||
assert.ok(session.summary().lastMoveAt > created.lastMoveAt, 'an accepted intent did not move the clock');
|
||||
});
|
||||
|
||||
it('carries lastMoveAt across a restart, and falls back to createdAt for a save without one', async () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob']);
|
||||
const actor = (session.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex;
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
const saved = session.exportSave();
|
||||
assert.equal(resumeSession(saved).summary().lastMoveAt, saved.lastMoveAt);
|
||||
|
||||
// A game written before the field existed still has to load, and reads as untouched since it
|
||||
// began rather than as having just moved.
|
||||
const { lastMoveAt: _dropped, ...older } = saved;
|
||||
const revived = resumeSession(older).summary();
|
||||
assert.equal(revived.lastMoveAt, saved.createdAt);
|
||||
});
|
||||
|
||||
it('reports a finished game as waiting on nobody', () => {
|
||||
// Every seat a bot, so the game plays itself to a finish inside the constructor.
|
||||
const session = createSession(4242, config, ['A', 'B'], [0 as PlayerIndex, 1 as PlayerIndex]);
|
||||
const s = session.summary();
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.waitingOn, null, 'a finished game must not name somebody to wait for');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a save survives a release that did not change the rules', () => {
|
||||
/** Plays a couple of real moves so the history is worth replaying. */
|
||||
const played = (): ReturnType<GameSession['exportSave']> => {
|
||||
const s = createSession(42, config, ['Alice', 'Bob']);
|
||||
const actor = (s.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex;
|
||||
s.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
|
||||
return s.exportSave();
|
||||
};
|
||||
|
||||
it('resumes whatever version stamped it, so long as the moves still replay', () => {
|
||||
// This is the whole point. The engine version used to gate this, and it is the PACKAGE version
|
||||
// — it moves for a CSS fix. Four releases in a row destroyed every game in progress, one of
|
||||
// them for a change that only altered how the board is drawn.
|
||||
const saved = played();
|
||||
const r = tryResumeSession(saved);
|
||||
assert.equal(r.ok, true, 'a replayable save was refused');
|
||||
if (!r.ok) return;
|
||||
assert.deepEqual(r.session.exportSave().history, saved.history);
|
||||
});
|
||||
|
||||
it('refuses a save whose moves no longer replay, and says which move and why', () => {
|
||||
// A rules change is simulated by corrupting one intent — the engine cannot apply it, which is
|
||||
// exactly the shape a genuinely incompatible save has.
|
||||
const saved = played();
|
||||
const broken = {
|
||||
...saved,
|
||||
history: [...saved.history, { type: 'localOps.choose', option: 'not-a-real-option' } as never],
|
||||
};
|
||||
const r = tryResumeSession(broken);
|
||||
assert.equal(r.ok, false, 'a save the rules reject was accepted');
|
||||
if (r.ok) return;
|
||||
assert.equal(r.failure.of, broken.history.length);
|
||||
assert.equal(r.failure.stoppedAt, broken.history.length - 1, 'wrong move blamed');
|
||||
assert.equal(r.failure.intent, 'localOps.choose');
|
||||
assert.ok(r.failure.code.length > 0, 'no rejection code to act on');
|
||||
});
|
||||
|
||||
it('never silently truncates — the old loop stopped at a bad move and said nothing', () => {
|
||||
// The silence was survivable only because the version check meant a doomed replay was never
|
||||
// attempted. Now that the replay IS the check, a partial one must be impossible to mistake for
|
||||
// a whole one.
|
||||
const saved = played();
|
||||
const broken = { ...saved, history: [{ type: 'draw.end' } as never, ...saved.history] };
|
||||
const r = tryResumeSession(broken);
|
||||
assert.equal(r.ok, false, 'a truncated replay was returned as a healthy session');
|
||||
});
|
||||
});
|
||||
|
||||
+28
-1
@@ -14,11 +14,12 @@
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.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.
|
||||
@@ -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', () => {
|
||||
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
|
||||
|
||||
@@ -41,7 +41,6 @@ const solitaireConfig: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ const config: GameConfig = {
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ function gameWith(area: OfficeArea): GameState {
|
||||
id: 'g', seed: 5,
|
||||
config: {
|
||||
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'],
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
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 at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
|
||||
+5
-5
@@ -2680,7 +2680,7 @@ describe('the Division map shows the whole route', () => {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
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),
|
||||
});
|
||||
@@ -2695,7 +2695,7 @@ describe('the Division map shows the whole route', () => {
|
||||
seed: 7,
|
||||
config: {
|
||||
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'],
|
||||
});
|
||||
@@ -2748,7 +2748,7 @@ describe('the Division map shows the whole route', () => {
|
||||
id: 'div-chips', seed: 7,
|
||||
config: {
|
||||
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'],
|
||||
});
|
||||
@@ -3077,7 +3077,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
|
||||
id: 'eng', seed: 1038389,
|
||||
config: {
|
||||
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'],
|
||||
});
|
||||
@@ -3127,7 +3127,7 @@ describe('the tray is an engine plus its Rolling Stock', () => {
|
||||
id: 'yards', seed: 1038389,
|
||||
config: {
|
||||
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'],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user