Compare commits

...
2 Commits
Author SHA1 Message Date
Jesse 689de2ff0f v0.5.4 — the map says whose railroad is whose
Six things found playing the StartOS build, all of them the game telling
you what it already knew.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also carries the TODO items raised on 2026-08-21: the lobby offering no
game parameters (the floor bug within it now fixed, the form still
missing), and the four optionalRules — of which only reducedVisibility and
emergencyToolbox are read by anything, while sisterTrains and
employeeRotation are declared, defaulted, and consulted nowhere.
2026-08-21 14:53:53 -04:00
18 changed files with 893 additions and 77 deletions
+121
View File
@@ -19,6 +19,127 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 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`,
+69 -4
View File
@@ -21,7 +21,14 @@ 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.
Nothing else queued at the moment.
Queued 2026-08-21, from playing the StartOS build:
4. **The lobby must offer every game parameter the solitaire New Game dialog does** — and it
currently offers none of them. Reasoning in Multiplayer below; carries a live balance bug with
it (the combined-Revenue floor is sized for four players whatever the table's real size), so
this is not purely a UI job.
5. **Decide what the four `optionalRules` are** before either dialog offers them — two are live,
two are read by nothing at all. Reasoning in Multiplayer below.
---
@@ -395,9 +402,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 +487,63 @@ 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").
- [ ] **THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING.** Split out
at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby
item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`,
`sisterTrains`, `employeeRotation` and `emergencyToolbox`. Neither the solitaire New Game
dialog nor the lobby exposes any of them, and every construction site in the codebase
hardcodes all four to `false` (`web/game.ts`, `sim/harness.ts`, `sim/replay.ts`,
`sim/compare.ts`), so no game has ever been played with one on.
**Check what is real before building a form for it.** Only two are wired:
| rule | status |
| --- | --- |
| `reducedVisibility` | **live** — read at `advance.ts:53`, gates on `NIGHT_STAGES` |
| `emergencyToolbox` | **live** — read at `setup.ts:374`, seeds each player's Red Flags |
| `sisterTrains` | **nothing reads it.** Declared, defaulted, never consulted — and §9a Q9 records that the Second Section card *supersedes* the Sister Trains optional rule, so this flag is most likely dead rather than unbuilt. Decide whether to implement or delete it |
| `employeeRotation` | **nothing reads it.** Declared, defaulted, never consulted. Note the seat/player split (Phase 0, D9) was built specifically so this rule *could* exist — the groundwork is there, the rule is not |
So a dialog listing all four would offer two working toggles beside two that silently do
nothing — the exact failure `checkPlay`'s `NOT_IMPLEMENTED` and `enhancementText`'s
live/dormant/unbuilt table exist to prevent. Either implement the two dead ones, delete
them, or label them on screen the way an unbuilt Enhancement already labels itself. Doing
that is what decides whether this is a UI job or a rules job.
- [ ] **THE LOBBY OFFERS NO GAME PARAMETERS AT ALL, AND THE ONE IT INFERS IS WRONG.** Raised by
Jesse 2026-08-21 after playing the StartOS build. Creating a multiplayer game asks for a
display name and a mode, and nothing else — every other dial comes from
`defaultMultiplayerConfig(mode)` (`web/game.ts`), hardcoded, with no way to change it.
Solitaire's New Game dialog (`play.html`, `#ng-*`) asks for all of it: seed, starting hand
(`ng-hand` — three random / six random / three track + three other), the three revenue rates
(`ng-passenger` / `ng-freight` / `ng-transit`), `days`, `minCombinedRevenue`,
`maxCollisionsPerDay`, `maxCollisionsTotal` and `pvpCardsAllowed`. Multiplayer should ask for
the same set. Note that `GameConfig.optionalRules` (reduced visibility, sister trains,
employee rotation, emergency toolbox) is exposed by NEITHER dialog and is hardcoded false in
both — worth deciding on separately rather than folding in silently.
**~~The bug this hid~~ — fixed in v0.5.3.** `defaultMultiplayerConfig` defaults to
`players = 4` and `lobby.ts` called it without the argument, so `minCombinedRevenue` was
always `collectiveRevenueFloor(4, 5)` = 60 whatever the table's real size — a 2-player game
played against a floor meant for four (60 rather than 3x2x5 = 30), and missing that floor
means *everyone loses*. It fell out of the seat-control change: the host now picks the table
size when creating the game, so the real count reaches `defaultMultiplayerConfig` and the
ordering problem that caused this (config fixed at CREATE, seat count unknown until START)
no longer exists. **The form itself is still missing** — that is what this item is now.
- [ ] **D19's switching-instrumentation still needs writing, once real people are playing.** "13%
for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts`
or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land
+17 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.5.2",
"version": "0.5.4",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+129 -4
View File
@@ -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,137 @@ 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;
};
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 { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players);
await persistLobby(lobby);
await persistSession(session);
sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
+16
View File
@@ -20,6 +20,13 @@ 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);
@@ -69,6 +81,7 @@ startServer({
port,
bindAddress,
joinSecret,
adminSecret,
distDir,
dataDir,
engineVersion,
@@ -80,3 +93,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.');
}
+43 -11
View File
@@ -81,16 +81,40 @@ 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,
): 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(),
};
@@ -103,10 +127,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 +148,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 +183,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 };
}
+19 -1
View File
@@ -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';
@@ -111,6 +111,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 });
+62 -2
View File
@@ -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 ${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);
}
/**
@@ -267,5 +321,11 @@ export function createSession(
*/
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));
return buildSession(
game,
saved.playerNames,
saved.createdAt,
new Set(saved.botSeats),
saved.lastMoveAt ?? saved.createdAt,
);
}
+62 -5
View File
@@ -28,7 +28,23 @@ export type BoardTrain = { label: string; consist: string[] };
* The Division as a dispatcher would see it: one continuous line per running track, sections
* 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,10 @@ export const BOARD_CSS = `
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
seconds and which railroad is yours never does. */
.bs-name.bs-turn{fill:#f0b64a;font-weight:700}
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
+4 -1
View File
@@ -513,7 +513,10 @@ function render() {
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
$('division').innerHTML = divisionSvg(DIV);
// players, actor and viewer are not among the delta'd keys (see compress), so they ride whole on
// every frame and the replay names the districts exactly as the live page does. No backticks in
// this comment: it is inside the generated-page template literal, which they would terminate.
$('division').innerHTML = divisionSvg(DIV, { players: f.players, actor: f.actor, viewer: f.viewer });
// The same office renderer the playable app uses, so replay and game draw one board.
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
+23
View File
@@ -370,6 +370,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 +1258,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),
+34 -11
View File
@@ -66,18 +66,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' : ''}`;
@@ -90,6 +89,24 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
}
$('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 +114,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 }) => {
@@ -138,7 +153,15 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
setError('lb-create-err', 'enter a display name first');
return;
}
void postJson('/api/lobby/create', { secret: secret(), config: defaultMultiplayerConfig(mode), displayName }).then(
const players = Number($<HTMLSelectElement>('lb-players').value) || 4;
// The real seat count reaches `defaultMultiplayerConfig`, so the combined-Revenue floor is
// sized for the table actually being played rather than for an assumed four.
void postJson('/api/lobby/create', {
secret: secret(),
config: defaultMultiplayerConfig(mode, players),
displayName,
players,
}).then(
({ status, body }) => {
if (status !== 200) {
setError('lb-create-err', String(body['error'] ?? 'could not create the game'));
+33 -1
View File
@@ -408,6 +408,33 @@ 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;
if (f.players.length < 2) {
el.textContent = '';
return;
}
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat);
const chain = bySeat
.map((p) => {
const roll = f.openingRolls.division[p.index];
const marks = [p.index === f.viewer ? 'you' : '', p.index === f.actor ? 'now' : '']
.filter(Boolean)
.join(', ');
return `${p.name}${roll === undefined ? '' : ` (${roll})`}${marks ? ` [${marks}]` : ''}`;
})
.join(' → ');
el.textContent = `West to East: ${chain}. Order set by the opening D12 — highest roll takes the eastern end.`;
}
/**
* `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table
* has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
@@ -464,7 +491,12 @@ function render(): void {
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
+34 -3
View File
@@ -35,6 +35,10 @@ header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
header button:disabled:hover{border-color:#2c333d}
.zoom{display:inline-flex;align-items:center;gap:4px}
.zoom button{padding:3px 9px;line-height:1}
.lb-invite{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 10px;
background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px}
.lb-invite-label{font-size:11px;color:var(--dim)}
.lb-invite-code{font-size:22px;font-weight:700;letter-spacing:.08em;color:#f2e6cf}
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
@@ -165,6 +169,12 @@ button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);colo
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
button:hover{background:#39424e;border-color:#4d6fa8}
/* GENERIC, and it was not. `header button:disabled` and `#actions button:disabled` were the only
disabled styles on the page, so a disabled button anywhere else — #lb-start being the one that
mattered — kept its normal face AND still lit up under the cursor from the rule above. It was
advertising a click it would refuse. */
button:disabled{opacity:.45;cursor:not-allowed}
button:disabled:hover{background:#2a3038;border-color:var(--line)}
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
color:#f2e6cf;font-weight:600}
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
@@ -241,6 +251,14 @@ 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>
<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 +271,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 +337,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>
+82
View File
@@ -18,6 +18,7 @@ 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 { divisionSvg } from '../src/sim/board-svg.ts';
import { impediments } from '../src/sim/narrate.ts';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
@@ -712,3 +713,84 @@ 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);
});
});
+78 -31
View File
@@ -33,17 +33,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 +58,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 +69,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 +78,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 +118,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 +146,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 +160,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', () => {
+66
View File
@@ -234,3 +234,69 @@ 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');
});
});