Compare commits

..
2 Commits
Author SHA1 Message Date
Jesse 2fbfe11977 v0.5.3 — a table you size yourself, and games an administrator can see and end
Both halves came out of playing the StartOS build. The wrapper's health
check and admin actions consume this; they land separately.

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

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

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

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

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

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

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

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

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

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

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

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

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

The probe's own test was vacuous on the first attempt — both its "closes"
cases reached close() through the .catch arm, so deleting the body-naming
check outright still passed. Caught by mutating splash.ts and re-running;
the test now covers all three closing routes and fails without the check.
2026-08-21 11:00:06 -04:00
17 changed files with 829 additions and 62 deletions
+103
View File
@@ -19,6 +19,109 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.5.3 — 2026-08-21
Everything a StartOS administrator needs to see and manage a server full of games, plus the seat
control that came out of the first real multiplayer session.
### The host picks the table size, and a gap stops being expressible
The seats array used to GROW as people joined, which made the four rows on screen partly fiction:
a 2-player game just started with a 2-long array, while a host who dropped a bot into a later chair
padded the array with a `null` and silently disabled Start behind a one-line note. The host now
chooses 2, 3 or 4 when creating the game and the array is built at that length once. A gap cannot
be written down rather than merely being refused.
That also removed a trap nobody had sprung yet. Compacting seats at `Lobby.Start` — the obvious way
to support a "closed" chair — would have shifted the `player` index that every `PlayerSession`
stamps at join time and that `/api/stream` and `/api/intent` both route by, handing a player
somebody else's railroad without an error anywhere.
**And it fixed a live balance bug.** `minCombinedRevenue` is derived from the player count, but the
config was fixed at CREATE while the count was not known until START, so the lobby guessed 4. Every
2-player game was playing against a floor of 60 instead of 30 — and missing the floor means
everyone loses, so a 2-player competitive game was set up to fail for a reason that was a UI
artifact rather than a rule. The real count now reaches `defaultMultiplayerConfig`.
### Administration: what is running, and how to end it
`/api/health` gained `games: { active, lobby }`, which is what the StartOS package's health check
reports as "3 games in progress, 1 waiting to start". It reads `summary()` — a new, cheap
`GameSession` accessor — rather than `exportSave()`, which would copy every intent of every game to
answer a question about none of them.
Three administrative routes are new, gated by an `ADMIN_SECRET` env var in an `x-admin-secret`
header: `GET /api/games` (every game and lobby, summarised — players, names, started-at,
last-move-at, Day/Stage/phase, and who it waits on), `GET /api/games/<id>/save`, and
`DELETE /api/games/<id>`. Until this, a started game could not be ended by anybody: no route, no
player action, no resignation. An abandoned game stayed `active` in the index and was faithfully
resumed on every boot, forever.
Three deliberate choices in that:
- **The admin secret is not the join secret.** Every player holds the join secret, so gating a
delete with it would let anyone at the table destroy anyone else's game.
- **Unset means the routes are not there** — 404, the same answer as any unknown path, with or
without a header. A server never given an administrator does not advertise that it has one.
- **A delete returns the deleted game's save.** The intents are the game (D5), so that is the whole
thing and not a summary: nothing is destroyed without being handed to whoever destroyed it.
`SavedGame` gained `lastMoveAt` so "has this stalled?" survives a restart. It is optional and falls
back to `createdAt`, and it is kept out of `history` for the same reason the turn timings are — a
replay must reproduce a game from decisions alone, and wall-clock is not a decision.
### Boot
`Resuming N saved games…` is logged *before* the replay loop rather than one line per game after
it, so the pause before the port opens has a reason on screen while it is happening. Measured at
**100 ms** for a full 4-player game, and only unfinished games are replayed — so the pause is
tenths of a second in practice, and listening before loading would have bought nothing for the cost
of a "still loading" state on every route.
---
## 0.5.2 — 2026-08-21
Found packaging Phase 6 for StartOS: the splash's "Play multiplayer" door had sat `disabled`,
labelled "Coming soon," since before the server existed — Phases 2 through 4 built a working
lobby and nobody ever pointed a link at it. Loading the site landed on the exact same solitaire
splash whether a real multiplayer server was behind it or not, with no visible way in.
`index.html`'s door is now a real link to `./play.html?lobby`, matching the other two doors.
`main.ts`'s `start()` checks for `?lobby` and routes straight into the lobby screen — the same
`showScreen('lobby'); runLobby(beginRemote)` the in-game Multiplayer button already used — instead
of dealing a solitaire game first and leaving the player to find that button themselves.
### The page can now tell whether a server is behind it
The same `dist/` ships two ways — served by `src/server/`, or uploaded as flat files by
`scripts/deploy-web.ts` with no server at all — and the bundle is byte-identical in both, because
there is one client and the mode is decided at runtime (D4). So the splash could not know from its
own build which it was, and nothing else distinguished them either: every route in `http.ts`
answers a 404 for a path it does not have, exactly as a static host does.
`GET /api/health` is new, and exists to be failed: `{ ok, service, engineVersion }`, no
authentication (it says only that a Station Master server is answering, which is what the door is
about to offer anyway — no game, no seat). The splash probes it on load and closes the door when
nothing names itself in reply.
**The door starts open and only ever closes**, deliberately. A wrong "no server here" is the bug
above all over again — invisible, and it strands a player who *does* have a server. A wrong "there
is one" costs a click and a lobby that says it cannot reach a server, which is legible and
recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it. The
reply has to name itself rather than merely return 200, since a host that answers every path with
its own index page would otherwise pass.
Bug fix: the lobby machinery was already complete and tested (Phase 4, v0.5.1); this only
re-enables the door to it, and teaches the splash when to.
Worth recording about the tests: the first version of the probe's test passed with the naming
check deleted outright. Both of its "door closes" cases happened to reach `close()` through the
`.catch` arm, so the branch that actually reads the body was never run — and the comment claimed
otherwise. Caught by mutating `splash.ts` and re-running rather than by reading it.
---
## 0.5.1 — 2026-08-21
Multiplayer Phase 4 — lobby, sessions, reconnection (`docs/architecture/multiplayer.md` §12 steps
+65 -1
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.
---
@@ -479,6 +486,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
+22
View File
@@ -245,9 +245,31 @@ special handling: `pump` stops, and the next push simply carries a `Menu` contai
POST /api/lobby/create, /api/lobby/join lobby
POST /api/intent { gameId, seq, intent }
GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume
GET /api/health { ok, service, engineVersion, games } — unauthenticated; see below
GET /api/games every game and lobby, summarised ┐
GET /api/games/<id>/save the save, for keeping or replaying ├ ADMIN_SECRET
DELETE /api/games/<id> ends a game, and returns its save ┘
GET / the client
```
`/api/health` exists because the client cannot otherwise tell a server from a static host. The same
`dist/` is served both ways and the bundle is identical (D4), and every other route 404s an unknown
path exactly as a static host does — so the splash asks, and closes its multiplayer door only when
nothing names itself in reply. It is unauthenticated on purpose: it reveals that a Station Master
server is answering and nothing else, no game and no seat. Its `games` field — `{ active, lobby }`
— is what the StartOS package's health check reports as "3 games in progress".
**The three administrative routes are gated by `ADMIN_SECRET`, which is deliberately not the join
secret.** Every player holds the join secret, so gating a delete with it would let anyone at the
table destroy anyone else's game; this one belongs to whoever runs the server. It arrives in an
`x-admin-secret` header rather than the query string, so it stays out of logs and referrers. When
the variable is unset the routes answer 404 exactly as any unknown path does, so a server that was
never given an administrator does not advertise that it has one.
**A delete returns the deleted game's save.** The intents are the game (D5), so what comes back is
the whole thing and not a summary of it — the record survives even though the game does not, and
nothing is destroyed without being handed to whoever destroyed it first.
Chosen over WebSocket because this game is **idle most of the time** — turn-based with human
think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's
reconnection and `Last-Event-ID` resume are handled by the browser, and it needs no `Upgrade` support
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.5.1",
"version": "0.5.3",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+147 -3
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>;
@@ -197,20 +209,152 @@ export function startServer(opts: ServerOptions): void {
void (async () => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
// -- Is anyone home? --------------------------------------------------------------------
/**
* THE ONE ROUTE THAT EXISTS TO BE FAILED.
*
* The same `dist/` is served two ways: by this server, and as a plain static upload with no
* server behind it at all (`scripts/deploy-web.ts`). The bundle is byte-identical either way
* — one client, mode decided at runtime (D4) — so the page cannot know from its own build
* which it is, and every other route here answers a 404 for a path it does not have, exactly
* as a static host would. Nothing distinguished them until this did.
*
* Unauthenticated on purpose: it says only that a Station Master server is answering, which
* is what the client is about to offer the player anyway. It reveals no game and no seat.
*/
if (url.pathname === '/api/health' && req.method === 'GET') {
// `summary()` rather than `exportSave()`: this is polled on a timer, and the save copies
// every intent of every game to answer a question about none of them.
let active = 0;
for (const g of games.values()) if (g.summary().status === 'active') active++;
sendJson(res, 200, {
ok: true,
service: 'station-master',
engineVersion: opts.engineVersion,
games: { active, lobby: lobbies.size },
});
return;
}
// -- Administration: listing, exporting and deleting games ------------------------------
if (url.pathname === '/api/games' || url.pathname.startsWith('/api/games/')) {
// Unset means the routes are not here — indistinguishable from any other unknown path, so
// nothing advertises an administrative surface to someone probing for one.
if (!opts.adminSecret) {
await serveStatic(opts.distDir, url.pathname, res);
return;
}
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
sendJson(res, 403, { error: 'bad or missing admin secret' });
return;
}
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
if (url.pathname === '/api/games' && req.method === 'GET') {
const running = [...games.entries()].map(([gameId, g]) => ({
gameId,
gameCode: codes.get(gameId) ?? null,
state: 'running' as const,
...g.summary(),
}));
// A lobby has no game to summarize yet — it is reported as what it is, so an
// administrator sees a table that never started rather than nothing at all.
const waiting = [...lobbies.values()].map((l) => ({
gameId: l.gameId,
gameCode: l.gameCode,
state: 'lobby' as const,
playerCount: l.seats.length,
playerNames: l.seats.map((seat) =>
seat === null ? '(empty)' : seat.kind === 'bot' ? 'Bot' : seat.displayName,
),
createdAt: l.createdAt,
}));
sendJson(res, 200, { games: [...running, ...waiting] });
return;
}
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname);
const gameId = match?.[1];
if (!gameId) {
sendJson(res, 404, { error: 'no such route' });
return;
}
if (match?.[2] && req.method === 'GET') {
const session = games.get(gameId);
if (!session) {
sendJson(res, 404, { error: 'no such game' });
return;
}
sendJson(res, 200, { gameCode: codes.get(gameId) ?? null, save: session.exportSave() });
return;
}
if (req.method === 'DELETE') {
const session = games.get(gameId);
const lobby = lobbies.get(gameId);
if (!session && !lobby) {
sendJson(res, 404, { error: 'no such game' });
return;
}
// The save goes back with the deletion, so a game can never be destroyed without its
// record being handed to whoever destroyed it — the intents ARE the game (D5), so this
// is the whole thing, replayable later, not a summary of it.
const save = session?.exportSave() ?? null;
// Everyone watching is told the game is gone before its files are, rather than being
// left on a stream that will never push again.
for (const [, watcher] of gameConnections.get(gameId) ?? []) watcher.end();
gameConnections.delete(gameId);
for (const [, watcher] of lobbyConnections.get(gameId) ?? []) watcher.end();
lobbyConnections.delete(gameId);
games.delete(gameId);
lobbies.delete(gameId);
gameEventIds.delete(gameId);
const code = lobby?.gameCode ?? codes.get(gameId);
if (code) gameCodes.delete(code);
for (const [token, ps] of [...sessions]) if (ps.gameId === gameId) sessions.delete(token);
await removeIndexEntry(opts.dataDir, gameId);
await deleteGame(opts.dataDir, gameId);
sendJson(res, 200, { ok: true, gameCode: code ?? null, save });
return;
}
sendJson(res, 405, { error: 'method not allowed' });
return;
}
// -- Lobby: creating and joining (the door — join-secret gated) --------------------------
if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string };
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.');
}
+40 -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,14 @@ 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>[];
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : 'Bot'));
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,
);
}
+8 -6
View File
@@ -29,6 +29,8 @@ a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
.door.disabled .go{color:var(--dim)}
a.door.disabled{pointer-events:none}
a.door.disabled:hover{border-color:var(--line);background:var(--panel);transform:none}
.lightbox{position:fixed;inset:0;background:rgba(8,10,13,.92);display:flex;
align-items:center;justify-content:center;padding:32px;z-index:10;cursor:zoom-out}
.lightbox[hidden]{display:none}
@@ -69,13 +71,13 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</div>
<div class="doors">
<div class="door disabled">
<a class="door" id="door-multiplayer" href="./play.html?lobby">
<h2>Play multiplayer</h2>
<p>Play with your friends across the internet. You each run your own office area within the
entire division. You can play a normal game, or choose co-op or cutthroat. This requires
a Station Master server to be running.</p>
<span class="go">Coming soon</span>
</div>
<p id="door-multiplayer-blurb">Play with your friends across the internet. You each run your
own office area within the entire division. You can play a normal game, or choose co-op or
cutthroat.</p>
<span class="go" id="door-multiplayer-go">Set up a game &rarr;</span>
</a>
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
+15 -10
View File
@@ -68,16 +68,15 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
$('lb-gamecode').textContent = `— code ${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' : ''}`;
@@ -97,16 +96,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 +135,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'));
+9
View File
@@ -358,6 +358,15 @@ function start(): void {
return;
}
// The splash's "Play multiplayer" door (index.html) lands here — straight into the lobby,
// rather than dealing a solitaire game first and leaving the player to find the in-game
// Multiplayer button themselves.
if (params.get('lobby') !== null) {
showScreen('lobby');
runLobby(beginRemote);
return;
}
showScreen('gameui');
const requested = params.get('seed');
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
+8
View File
@@ -241,6 +241,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>
+43
View File
@@ -26,3 +26,46 @@ if (heroImage && lightbox) {
if (e instanceof KeyboardEvent && e.key === 'Escape') close();
});
}
/**
* Is a Station Master server actually behind this page?
*
* The same `dist/` ships two ways — served by `src/server/`, or uploaded as flat files with no
* server at all (`scripts/deploy-web.ts`) — and the bundle is identical in both, so the only
* honest way to answer is to ask. `/api/health` is the one route that exists to be failed.
*
* THE DOOR STARTS OPEN AND ONLY EVER CLOSES. Getting this wrong in the "no server" direction is
* the bug this whole change exists to fix: a disabled door is invisible and leaves a player who
* DOES have a server with no way in, and nothing on screen to explain it. Getting it wrong the
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
*/
const mpDoor = document.getElementById('door-multiplayer');
if (mpDoor) {
const close = (): void => {
mpDoor.classList.add('disabled');
mpDoor.removeAttribute('href');
const go = document.getElementById('door-multiplayer-go');
if (go) go.textContent = 'Requires a Station Master server';
const blurb = document.getElementById('door-multiplayer-blurb');
if (blurb) {
blurb.textContent =
'Play with your friends across the internet, each running your own office area within the ' +
'entire division. This copy of the game is a plain website with no server behind it, so ' +
'there is nowhere to host a table — multiplayer needs a Station Master server.';
}
};
// Relative, never absolute: the page must work at whatever address it is reached by, and the
// server is always the origin that served it (D16).
void fetch('./api/health')
.then((r) => (r.ok ? (r.json() as Promise<unknown>) : null))
.then((body) => {
// A static host that answers unknown paths with 200 and its own index page would sail past
// an `r.ok` check, so the body has to name itself before the door is believed.
const named =
typeof body === 'object' && body !== null && (body as { service?: unknown }).service === 'station-master';
if (!named) close();
})
.catch(() => close());
}
+62 -27
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,50 @@ 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(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
assert.deepEqual(startLobby(j2.lobby, j2.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');
it('refuses a solo human at a table sized for more', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010', 2);
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
});
it('starts a full 2-player lobby, naming bots "Bot" and humans by their display name', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0011').lobby;
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] });
});
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', 'Bot'], 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');
});
});
+143
View File
@@ -2515,6 +2515,149 @@ describe('the static build', () => {
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
});
it('opens the multiplayer door from the splash, straight into the lobby', () => {
// This door sat `disabled` and labelled "Coming soon" from before the server existed until
// v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a
// real server in front of them saw the same dead card as everyone else.
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(splash, /href="\.\/play\.html\?lobby"/, 'the splash does not link to the lobby');
assert.doesNotMatch(splash, /Coming soon/, 'the multiplayer door still says it is unbuilt');
// The probe addresses all three by id; renaming one silently un-wires it, which is precisely
// the class of break that put "Coming soon" on a working feature for two releases.
for (const id of ['door-multiplayer', 'door-multiplayer-go', 'door-multiplayer-blurb']) {
assert.ok(splash.includes(`id="${id}"`), `the splash is missing #${id}`);
}
});
/**
* The probe decides whether the door above is real, so both of its answers are worth a test —
* and the OPEN one especially: a false "no server here" is invisible to the player and is the
* exact failure this release exists to remove.
*/
it('closes the multiplayer door only when nothing answers the health probe', async () => {
const served = new Set(
[...readFileSync(join(dist, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const loadSplash = async (fetchImpl: () => Promise<unknown>): Promise<Record<string, unknown>> => {
const els = new Map<string, Record<string, unknown>>();
const make = (): Record<string, unknown> => {
const classes = new Set<string>();
return {
textContent: '', innerHTML: '', hidden: false, href: './play.html?lobby',
classList: { add: (c: string) => void classes.add(c), remove: (c: string) => void classes.delete(c), has: (c: string) => classes.has(c) },
removeAttribute: (k: string) => { if (k === 'href') delete (els.get('door-multiplayer') ?? {})['href']; },
addEventListener: () => {}, appendChild: () => {},
};
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(), addEventListener: () => {},
body: { appendChild: () => {} }, head: { appendChild: () => {} },
};
g['fetch'] = fetchImpl;
await import(`file://${join(dist, 'web/splash.js')}?t=${Date.now()}${Math.random()}`);
// The probe settles a microtask or two after load; nothing in the page waits on it.
await new Promise((r) => setTimeout(r, 0));
return els.get('door-multiplayer')!;
};
const answered = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: true, service: 'station-master' }) }),
);
assert.equal(
(answered['classList'] as { has: (c: string) => boolean }).has('disabled'), false,
'the door closed even though a Station Master server answered',
);
const silent = await loadSplash(() => Promise.reject(new Error('nothing there')));
assert.equal(
(silent['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'the door stayed open with no server behind it',
);
assert.equal(silent['href'], undefined, 'the closed door is still clickable');
// A host that answers EVERY path 200 — with its own index page, or with some unrelated
// service's JSON — sails straight past an `ok` check, so the body has to name itself. Both
// shapes below reach the door by a DIFFERENT route through the probe than the rejection
// above does, which is the whole reason they are here: an earlier version of this test
// asserted only the rejection path and passed with the naming check deleted outright.
const unnamed = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ service: 'something-else', ok: true }) }),
);
assert.equal(
(unnamed['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'a 200 from some other service was taken for a Station Master server',
);
const unparseable = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.reject(new Error('not json')) }),
);
assert.equal(
(unparseable['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'a 200 that is not JSON at all was taken for a server',
);
});
/**
* `?lobby` is what the door above hands to `main.ts`. Asserted on the BUILT bundle rather than
* the source, because the one thing that broke here was a browser-vs-Node API difference
* (`params.has` against the stub below), which only a load of the real output catches.
*/
it('routes ?lobby straight to the lobby screen instead of dealing a solitaire game', async () => {
const shown: string[] = [];
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const els = new Map<string, Record<string, unknown>>();
const make = (id: string): Record<string, unknown> => ({
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false, innerHTML: '',
title: '', hidden: false, classList: { add: () => {}, remove: () => {}, has: () => false },
appendChild: () => {}, addEventListener: () => {}, removeAttribute: () => {},
querySelector: () => null, querySelectorAll: () => [],
setAttribute: (k: string, v: string) => { if (k === 'hidden') shown.push(`${id}=${v}`); },
});
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make(id));
return els.get(id);
},
createElement: () => make('?'), addEventListener: () => {},
body: { appendChild: () => {} }, head: { appendChild: () => {} },
};
g['location'] = { search: '?lobby' };
const store = new Map<string, string>();
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = class {
search: string;
constructor(search: string) { this.search = search; }
get(k: string): string | null {
// A bare flag with no `=value` is still present — `?lobby` is exactly that shape.
if (new RegExp(`[?&]${k}(?=$|[&=])`).test(this.search)) {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? '';
}
return null;
}
};
// The lobby screen opens an EventSource as soon as it is shown; nothing here drives a game.
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
assert.equal(els.get('lobby')!['hidden'], false, 'the lobby screen stayed hidden under ?lobby');
assert.equal(els.get('gameui')!['hidden'], true, 'it dealt a solitaire game instead of opening the lobby');
});
it('serves a page that loads the game as a module', () => {
const html = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);