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.
This commit is contained in:
Jesse
2026-08-21 14:53:53 -04:00
parent 62b6ed7e1b
commit 2fbfe11977
13 changed files with 561 additions and 59 deletions
+61
View File
@@ -19,6 +19,67 @@ 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 ## 0.5.2 — 2026-08-21
Found packaging Phase 6 for StartOS: the splash's "Play multiplayer" door had sat `disabled`, Found packaging Phase 6 for StartOS: the splash's "Play multiplayer" door had sat `disabled`,
+65 -1
View File
@@ -21,7 +21,14 @@ Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplaye
below. below.
3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below. 3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below.
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, and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now,
2026-08-20. 2026-08-20.
- [x] **~~The lobby's seat controls could not express "nobody in this chair"~~ — done in v0.5.3.**
Raised by Jesse 2026-08-21. The seats array grew as people joined, so the four rows on screen
were partly fictional: a 2-player game simply started with a 2-long array, and a host who
added a bot to a later chair padded the array with a `null` that silently disabled Start
behind a one-line note. **The host now picks the table size (2-4) when creating the game**
and the array is built at that length once, so a gap cannot be expressed rather than merely
being rejected. That also removed the need to compact seats at `Lobby.Start` — which would
have shifted the `player` index every `PlayerSession` records at join time and that
`/api/stream` and `/api/intent` route by, quietly handing a player somebody else's railroad.
Tested in `test/server/lobby.test.ts` ("seat index is player index, with no compaction to
shift it", "never grows the table, whoever asks", "refuses a chair that is not at the
table").
- [ ] **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% - [ ] **D19's switching-instrumentation still needs writing, once real people are playing.** "13%
for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts` for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts`
or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land
+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/lobby/create, /api/lobby/join lobby
POST /api/intent { gameId, seq, intent } POST /api/intent { gameId, seq, intent }
GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume
GET /api/health { ok, service, engineVersion } — 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 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 `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 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 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 Chosen over WebSocket because this game is **idle most of the time** — turn-based with human
think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.5.2", "version": "0.5.3",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "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 type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { import {
appendTiming, appendTiming,
deleteGame,
deleteLobby, deleteLobby,
gameDir, gameDir,
readIndex,
removeIndexEntry,
upsertIndexEntry, upsertIndexEntry,
writeGame, writeGame,
writeLobby, writeLobby,
@@ -39,6 +42,7 @@ import type { GameSession, Push } from './session.ts';
import { import {
createLobby, createLobby,
freshGameCode, freshGameCode,
playerCountAllowed,
joinLobby, joinLobby,
reassignHost, reassignHost,
setBotSeat, setBotSeat,
@@ -58,6 +62,14 @@ export type ServerOptions = {
dataDir: string; dataDir: string;
/** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */ /** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */
engineVersion: string; engineVersion: string;
/**
* Gates the administrative routes — listing, exporting and deleting games — and is DELIBERATELY
* not the join secret. Every player holds that one, so gating a delete with it would let anyone
* at the table destroy anyone else's game. This is held by whoever runs the server and nobody
* else. When it is unset the admin routes do not exist at all (404, the same answer as any other
* unknown path), so a server that was never given one cannot be administered by guessing.
*/
adminSecret?: string | undefined;
/** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */ /** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */
initialGames: Map<string, GameSession>; initialGames: Map<string, GameSession>;
initialLobbies: Map<string, Lobby>; initialLobbies: Map<string, Lobby>;
@@ -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. * 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') { 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; return;
} }
// -- Lobby: creating and joining (the door — join-secret gated) -------------------------- // -- Lobby: creating and joining (the door — join-secret gated) --------------------------
if (url.pathname === '/api/lobby/create' && req.method === 'POST') { if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string }; const body = (await readJson(req)) as {
secret?: string;
config?: GameConfig;
displayName?: string;
players?: number;
};
if (body.secret !== opts.joinSecret) { if (body.secret !== opts.joinSecret) {
sendJson(res, 403, { error: 'bad or missing secret' }); sendJson(res, 403, { error: 'bad or missing secret' });
return; return;
} }
if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') { if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
sendJson(res, 400, { error: 'expected { secret, config, displayName }' }); sendJson(res, 400, { error: 'expected { secret, config, displayName, players }' });
return;
}
// The table size is the host's to choose and is fixed from here on, so it is validated at
// the door rather than at Start — `createLobby` builds the seats array from it.
const players = body.players ?? 0;
if (!Number.isInteger(players) || !playerCountAllowed(body.config.mode, players)) {
sendJson(res, 400, { error: 'BAD_PLAYER_COUNT' });
return; return;
} }
const gameCode = freshGameCode((code) => gameCodes.has(code)); const gameCode = freshGameCode((code) => gameCodes.has(code));
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode); const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players);
await persistLobby(lobby); await persistLobby(lobby);
await persistSession(session); await persistSession(session);
sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player }); sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player });
+16
View File
@@ -20,6 +20,13 @@ import type { Lobby, PlayerSession } from './lobby.ts';
const port = Number(process.env['PORT'] ?? 8081); const port = Number(process.env['PORT'] ?? 8081);
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0'; const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
const joinSecret = process.env['JOIN_SECRET']; const joinSecret = process.env['JOIN_SECRET'];
/**
* Optional, unlike `JOIN_SECRET`: a server with no administrator is a perfectly good server, and
* refusing to boot without one would break every existing deployment and every dev run. Unset
* simply means the admin routes are not there (`http.ts`), which is the safe default — the
* capability has to be granted, never merely left ungated.
*/
const adminSecret = process.env['ADMIN_SECRET'];
const distDir = resolve(process.env['DIST_DIR'] ?? 'dist'); const distDir = resolve(process.env['DIST_DIR'] ?? 'dist');
const dataDir = resolve(process.env['DATA_DIR'] ?? 'data'); const dataDir = resolve(process.env['DATA_DIR'] ?? 'data');
@@ -38,6 +45,11 @@ const initialLobbies = new Map<string, Lobby>();
const initialSessions = new Map<string, PlayerSession>(); const initialSessions = new Map<string, PlayerSession>();
const index = await readIndex(dataDir); const index = await readIndex(dataDir);
// Said before the loop, not after it: replaying is the reason a restart pauses before the port
// opens, and a log that only reports each game once it is done gives no warning of how much is
// still to come.
const resumable = index.filter((e) => e.status !== 'finished').length;
if (resumable > 0) console.log(`Resuming ${resumable} saved game(s)…`);
for (const entry of index) { for (const entry of index) {
const sessions = await readSessions(dataDir, entry.gameId); const sessions = await readSessions(dataDir, entry.gameId);
for (const s of sessions) initialSessions.set(s.token, s); for (const s of sessions) initialSessions.set(s.token, s);
@@ -69,6 +81,7 @@ startServer({
port, port,
bindAddress, bindAddress,
joinSecret, joinSecret,
adminSecret,
distDir, distDir,
dataDir, dataDir,
engineVersion, engineVersion,
@@ -80,3 +93,6 @@ console.log(
`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` + `Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` +
`${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`, `${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`,
); );
if (!adminSecret) {
console.log('ADMIN_SECRET is unset — the /api/games administration routes are disabled.');
}
+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). */ /** 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 gameId = randomUUID();
const token = randomUUID(); const token = randomUUID();
const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName }; const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName };
const seats: LobbySeat[] = Array.from({ length: players }, (_, i) =>
i === 0 ? { kind: 'human', token, displayName: hostDisplayName } : null,
);
const lobby: Lobby = { const lobby: Lobby = {
gameId, gameId,
gameCode, gameCode,
hostToken: token, hostToken: token,
config, config,
seats: [{ kind: 'human', token, displayName: hostDisplayName }], seats,
joinOrder: [token], joinOrder: [token],
createdAt: Date.now(), createdAt: Date.now(),
}; };
@@ -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 * play in, but the running count is checked against `playerCountAllowed` at every join too, so a
* lobby can never grow the seats array past what could legally start). */ * lobby can never grow the seats array past what could legally start). */
export function joinLobby(lobby: Lobby, displayName: string): JoinResult { export function joinLobby(lobby: Lobby, displayName: string): JoinResult {
const cap = lobby.config.mode === 'solitaire' ? 1 : 4; // The table was sized at creation, so joining takes an empty chair or none at all — there is no
const empty = lobby.seats.findIndex((s) => s === null); // longer an "append another seat" path for a late arrival to grow the game through.
const seatIndex = empty >= 0 ? empty : lobby.seats.length; const seatIndex = lobby.seats.findIndex((s) => s === null);
if (seatIndex >= cap) return { ok: false, code: 'LOBBY_FULL' }; if (seatIndex < 0) return { ok: false, code: 'LOBBY_FULL' };
const token = randomUUID(); const token = randomUUID();
const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName }; const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName };
@@ -124,7 +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. */ * human seat; the host removes a person by them leaving, not by overwriting their seat. */
export function setBotSeat(lobby: Lobby, seat: PlayerIndex, filled: boolean): Lobby { export function setBotSeat(lobby: Lobby, seat: PlayerIndex, filled: boolean): Lobby {
const seats = [...lobby.seats]; const seats = [...lobby.seats];
while (seats.length <= seat) seats.push(null); // No padding: a seat outside the table the host chose is not a seat, and inventing one is how
// the old array grew holes in it.
if (seat < 0 || seat >= seats.length) return lobby;
if (filled) { if (filled) {
if (seats[seat] !== null) return lobby; if (seats[seat] !== null) return lobby;
seats[seat] = { kind: 'bot' }; seats[seat] = { kind: 'bot' };
@@ -157,11 +183,14 @@ export function reassignHost(lobby: Lobby, departingToken: string): Lobby {
*/ */
export function startLobby(lobby: Lobby, callerToken: string): StartResult { export function startLobby(lobby: Lobby, callerToken: string): StartResult {
if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' }; if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' };
const filled = lobby.seats.filter((s) => s !== null); // Every chair at the table must be taken. The size itself was validated at creation and cannot
if (filled.length !== lobby.seats.length || !playerCountAllowed(lobby.config.mode, filled.length)) { // have moved since, so this is only ever waiting on the last empty seat to fill.
if (lobby.seats.some((s) => s === null) || !playerCountAllowed(lobby.config.mode, lobby.seats.length)) {
return { ok: false, code: 'BAD_PLAYER_COUNT' }; return { ok: false, code: 'BAD_PLAYER_COUNT' };
} }
const playerNames = filled.map((s) => (s!.kind === 'human' ? s.displayName : 'Bot')); // Seat index IS player index — no compaction, because there is nothing to compact past.
const botSeats = filled.flatMap((s, i) => (s!.kind === 'bot' ? [i as PlayerIndex] : [])); const taken = lobby.seats as Exclude<LobbySeat, null>[];
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 }; 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. * the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet.
*/ */
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import type { SavedGame, TurnTiming } from './session.ts'; import type { SavedGame, TurnTiming } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts'; import type { Lobby, PlayerSession } from './lobby.ts';
@@ -111,6 +111,24 @@ export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry):
await writeIndex(dataDir, entries); await writeIndex(dataDir, entries);
} }
/**
* Removes a game from the index. Paired with `deleteGame` — the directory holds the game, the
* index says the game exists, and a delete that did one without the other would either resurrect
* it on the next boot or leave `index.json` pointing at nothing.
*/
export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> {
const entries = await readIndex(dataDir);
await writeIndex(
dataDir,
entries.filter((e) => e.gameId !== gameId),
);
}
/** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */
export async function deleteGame(dataDir: string, gameId: string): Promise<void> {
await rm(gameDir(dataDir, gameId), { recursive: true, force: true });
}
export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> { export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> {
const dir = gameDir(dataDir, lobby.gameId); const dir = gameDir(dataDir, lobby.gameId);
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
+62 -2
View File
@@ -82,6 +82,33 @@ export type SavedGame = {
* guessing from a display name rather than reading a fact. * guessing from a display name rather than reading a fact.
*/ */
botSeats: PlayerIndex[]; botSeats: PlayerIndex[];
/**
* Wall-clock of the last accepted intent, so "has this game stalled?" survives a restart.
*
* Optional because it postdates the format, and defaulted to `createdAt` when absent — a game
* whose last move is unrecorded reads as untouched since it began, which is the honest answer
* rather than a fabricated one. Kept OUT of `history`, like the turn timings and for the same
* reason: a replay must reproduce a game from decisions alone, and wall-clock is not a decision.
*/
lastMoveAt?: number;
};
/** What an administrator needs to see about a game without replaying it themselves. */
export type GameSummary = {
playerCount: number;
playerNames: string[];
botSeats: PlayerIndex[];
status: 'active' | 'finished';
createdAt: number;
lastMoveAt: number;
day: number;
stage: number;
phase: string;
/**
* Whose move it is, or `null` — which is not an error state: the Mainline Phase runs itself, and
* a finished game waits on nobody.
*/
waitingOn: { seat: PlayerIndex; name: string } | null;
}; };
export type IntentResult = export type IntentResult =
@@ -96,6 +123,12 @@ export type GameSession = {
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult; intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult;
/** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */ /** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */
exportSave(): SavedGame; exportSave(): SavedGame;
/**
* A cheap description of where this game has got to. Deliberately does not copy `history` the
* way `exportSave` must — the health check polls this on a timer, and an administrator listing
* games wants the state of each, not a copy of every intent in all of them.
*/
summary(): GameSummary;
}; };
type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number }; type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
@@ -105,7 +138,9 @@ function buildSession(
playerNames: string[], playerNames: string[],
createdAt: number, createdAt: number,
botSeats: Set<PlayerIndex>, botSeats: Set<PlayerIndex>,
lastMoveAtInit: number,
): GameSession { ): GameSession {
let lastMoveAt = lastMoveAtInit;
const lastSeq = new Map<PlayerIndex, number>(); const lastSeq = new Map<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>(); const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>(); const sentLines = new Map<PlayerIndex, number>();
@@ -229,6 +264,7 @@ function buildSession(
if (!applied) return { accepted: false, code: 'REJECTED' }; if (!applied) return { accepted: false, code: 'REJECTED' };
lastSeq.set(seat, seq); lastSeq.set(seat, seq);
lastMoveAt = Date.now();
const timing = settleTiming(); const timing = settleTiming();
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism // Any bot due to act now plays out entirely before this push goes back — the delta mechanism
// diffs against whatever was last sent, so it captures the bots' moves along with the human's // diffs against whatever was last sent, so it captures the bots' moves along with the human's
@@ -246,6 +282,23 @@ function buildSession(
status: game.state.status === 'finished' ? 'finished' : 'active', status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt, createdAt,
botSeats: [...botSeats], botSeats: [...botSeats],
lastMoveAt,
};
},
summary() {
const actor = currentActor(game);
return {
playerCount: playerNames.length,
playerNames: [...playerNames],
botSeats: [...botSeats],
status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt,
lastMoveAt,
day: game.state.clock.day,
stage: game.state.clock.stage,
phase: game.state.clock.phase,
waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${actor}` },
}; };
}, },
}; };
@@ -257,7 +310,8 @@ export function createSession(
playerNames: string[], playerNames: string[],
botSeats: PlayerIndex[] = [], botSeats: PlayerIndex[] = [],
): GameSession { ): GameSession {
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now(), new Set(botSeats)); const now = Date.now();
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, now, new Set(botSeats), now);
} }
/** /**
@@ -267,5 +321,11 @@ export function createSession(
*/ */
export function resumeSession(saved: SavedGame): GameSession { export function resumeSession(saved: SavedGame): GameSession {
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history); 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,
);
} }
+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 { function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
$('lb-gamecode').textContent = `— code ${lobby.gameCode}`; $('lb-gamecode').textContent = `— code ${lobby.gameCode}`;
const isHost = lobby.hostToken === token; const isHost = lobby.hostToken === token;
const cap = lobby.config.mode === 'solitaire' ? 1 : 4;
let html = ''; let html = '';
for (let seat = 0; seat < cap; seat++) { for (let seat = 0; seat < lobby.seats.length; seat++) {
const occupant = lobby.seats[seat] ?? null; const occupant = lobby.seats[seat] ?? null;
const isYou = occupant?.kind === 'human' && occupant.token === token; const isYou = occupant?.kind === 'human' && occupant.token === token;
const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken; const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken;
const who = const who =
occupant === null occupant === null
? '<span class="dim">— empty —</span>' ? '<span class="dim">— waiting —</span>'
: occupant.kind === 'bot' : occupant.kind === 'bot'
? 'Bot' ? 'Bot'
: `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`; : `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`;
@@ -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 }); btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: false });
} }
const filled = lobby.seats.filter((s) => s !== null).length; const waiting = lobby.seats.filter((s) => s === null).length;
const noGaps = filled === lobby.seats.length;
const legalCount = lobby.config.mode === 'solitaire' ? filled === 1 : filled >= 2 && filled <= 4;
const startBtn = $<HTMLButtonElement>('lb-start'); const startBtn = $<HTMLButtonElement>('lb-start');
startBtn.hidden = !isHost; startBtn.hidden = !isHost;
startBtn.disabled = !(noGaps && legalCount); startBtn.disabled = waiting > 0;
$('lb-start-note').textContent = isHost $('lb-start-note').textContent = isHost
? noGaps && legalCount ? waiting === 0
? '' ? ''
: 'Needs 2–4 seated players (human or bot), no empty seats in between.' : `Waiting on ${waiting} more ${waiting === 1 ? 'player' : 'players'} — add a bot to any empty chair to start now.`
: 'Waiting for the host to start the game.'; : 'Waiting for the host to start the game.';
startBtn.onclick = () => { startBtn.onclick = () => {
void postJson('/api/lobby/start', { token }).then(({ status, body }) => { void postJson('/api/lobby/start', { token }).then(({ status, body }) => {
@@ -138,7 +135,15 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
setError('lb-create-err', 'enter a display name first'); setError('lb-create-err', 'enter a display name first');
return; return;
} }
void postJson('/api/lobby/create', { secret: secret(), config: defaultMultiplayerConfig(mode), displayName }).then( const players = 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 }) => { ({ status, body }) => {
if (status !== 200) { if (status !== 200) {
setError('lb-create-err', String(body['error'] ?? 'could not create the game')); setError('lb-create-err', String(body['error'] ?? 'could not create the game'));
+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> <span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="lb-mode" value="coop"> <label class="ng-radio"><input type="radio" name="lb-mode" value="coop">
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label> <span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
<label class="ng-num"><span>Players at the table</span>
<select id="lb-players">
<option value="2">2</option>
<option value="3">3</option>
<option value="4" selected>4</option>
</select></label>
<p class="ng-note">Every chair has to be taken before the game can start — by a person or by a
bot. Pick the size of the table now; it cannot change once the game is created.</p>
<button id="lb-create">Create game</button> <button id="lb-create">Create game</button>
<p class="dim" id="lb-create-err" role="alert"></p> <p class="dim" id="lb-create-err" role="alert"></p>
+62 -27
View File
@@ -33,17 +33,20 @@ const competitive: GameConfig = {
const solitaire: GameConfig = { ...competitive, mode: 'solitaire' }; const solitaire: GameConfig = { ...competitive, mode: 'solitaire' };
describe('creating and joining', () => { describe('creating and joining', () => {
it('the creator is the host, takes seat 0, and is first in join order', () => { it('seats the host at 0 and lays out the whole table at once', () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001'); const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 3);
assert.equal(session.player, 0); assert.equal(session.player, 0);
assert.equal(lobby.hostToken, session.token); assert.equal(lobby.hostToken, session.token);
assert.equal(lobby.seats.length, 1); // The table is its full size immediately — the empty chairs exist and are waiting, rather
// than being appended as people arrive.
assert.equal(lobby.seats.length, 3);
assert.deepEqual(lobby.seats[0], { kind: 'human', token: session.token, displayName: 'Alice' }); assert.deepEqual(lobby.seats[0], { kind: 'human', token: session.token, displayName: 'Alice' });
assert.deepEqual(lobby.seats.slice(1), [null, null]);
assert.deepEqual(lobby.joinOrder, [session.token]); assert.deepEqual(lobby.joinOrder, [session.token]);
}); });
it('fills the next empty seat, in order', () => { it('fills the next empty seat, in order', () => {
const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001'); const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
const j2 = joinLobby(l1, 'Bob'); const j2 = joinLobby(l1, 'Bob');
assert.ok(j2.ok); assert.ok(j2.ok);
if (!j2.ok) return; if (!j2.ok) return;
@@ -55,8 +58,8 @@ describe('creating and joining', () => {
assert.deepEqual(j3.lobby.joinOrder, [s1.token, j2.session.token, j3.session.token]); assert.deepEqual(j3.lobby.joinOrder, [s1.token, j2.session.token, j3.session.token]);
}); });
it('refuses a 5th join to a competitive lobby (cap 4)', () => { it('refuses a join once every chair is taken', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0001').lobby; let lobby = createLobby(competitive, 'Alice', 'RAIL-0001', 4).lobby;
for (const name of ['Bob', 'Carol', 'Dave']) { for (const name of ['Bob', 'Carol', 'Dave']) {
const r = joinLobby(lobby, name); const r = joinLobby(lobby, name);
assert.ok(r.ok); assert.ok(r.ok);
@@ -66,8 +69,8 @@ describe('creating and joining', () => {
assert.deepEqual(fifth, { ok: false, code: 'LOBBY_FULL' }); assert.deepEqual(fifth, { ok: false, code: 'LOBBY_FULL' });
}); });
it('refuses a 2nd join to a solitaire lobby (cap 1)', () => { it('refuses a 2nd join to a one-chair table', () => {
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002'); const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002', 1);
const second = joinLobby(lobby, 'Bob'); const second = joinLobby(lobby, 'Bob');
assert.deepEqual(second, { ok: false, code: 'LOBBY_FULL' }); assert.deepEqual(second, { ok: false, code: 'LOBBY_FULL' });
}); });
@@ -75,20 +78,32 @@ describe('creating and joining', () => {
it('rejoins into a seat an earlier player vacated, not past the end', () => { it('rejoins into a seat an earlier player vacated, not past the end', () => {
// Joining always fills the FIRST empty seat, so a bot-seat cleared back to empty (setBotSeat) // Joining always fills the FIRST empty seat, so a bot-seat cleared back to empty (setBotSeat)
// is exactly as joinable as one nobody ever filled. // is exactly as joinable as one nobody ever filled.
let lobby = createLobby(competitive, 'Alice', 'RAIL-0003').lobby; let lobby = createLobby(competitive, 'Alice', 'RAIL-0003', 3).lobby;
lobby = setBotSeat(lobby, 1, true); lobby = setBotSeat(lobby, 1, true);
lobby = setBotSeat(lobby, 1, false); lobby = setBotSeat(lobby, 1, false);
const r = joinLobby(lobby, 'Bob'); const r = joinLobby(lobby, 'Bob');
assert.ok(r.ok); assert.ok(r.ok);
if (!r.ok) return; if (!r.ok) return;
assert.equal(r.session.player, 1, 'should take the reopened seat 1, not append at seat 1 anyway by coincidence — check seat 2 stays empty'); assert.equal(r.session.player, 1, 'should take the reopened chair 1');
assert.equal(r.lobby.seats.length, 2); assert.equal(r.lobby.seats.length, 3, 'joining must never resize the table');
assert.equal(r.lobby.seats[2], null);
});
it('never grows the table, whoever asks', () => {
// The old model appended a seat for anyone who turned up, which is how a lobby could end up
// holding more chairs than the host ever asked for.
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0013', 2);
const bob = joinLobby(lobby, 'Bob');
assert.ok(bob.ok);
if (!bob.ok) return;
assert.equal(bob.lobby.seats.length, 2);
assert.deepEqual(joinLobby(bob.lobby, 'Carol'), { ok: false, code: 'LOBBY_FULL' });
}); });
}); });
describe('bot seats', () => { describe('bot seats', () => {
it('fills only an empty seat, and clears only a bot seat', () => { it('fills only an empty seat, and clears only a bot seat', () => {
const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004'); const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004', 2);
const withBot = setBotSeat(l0, 1, true); const withBot = setBotSeat(l0, 1, true);
assert.deepEqual(withBot.seats[1], { kind: 'bot' }); assert.deepEqual(withBot.seats[1], { kind: 'bot' });
@@ -103,11 +118,21 @@ describe('bot seats', () => {
const cleared = setBotSeat(withBot, 1, false); const cleared = setBotSeat(withBot, 1, false);
assert.equal(cleared.seats[1], null); assert.equal(cleared.seats[1], null);
}); });
it('refuses a chair that is not at the table, instead of padding one in', () => {
// Padding is what used to put a hole in the seats array: dropping a bot into chair 3 of a
// 2-chair table grew it to 4 with a null at 2, and Start then refused for reasons the host
// had no way to see.
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0014', 2);
assert.equal(setBotSeat(lobby, 3, true), lobby);
assert.equal(setBotSeat(lobby, 2, true), lobby);
assert.equal(lobby.seats.length, 2);
});
}); });
describe('host transfer', () => { describe('host transfer', () => {
it('passes to the earliest-joined remaining human seat when the host departs', () => { it('passes to the earliest-joined remaining human seat when the host departs', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0005').lobby; let lobby = createLobby(competitive, 'Alice', 'RAIL-0005', 2).lobby;
const hostToken = lobby.hostToken; const hostToken = lobby.hostToken;
const j2 = joinLobby(lobby, 'Bob'); const j2 = joinLobby(lobby, 'Bob');
assert.ok(j2.ok); assert.ok(j2.ok);
@@ -121,13 +146,13 @@ describe('host transfer', () => {
}); });
it('does nothing when the departing token is not the host', () => { it('does nothing when the departing token is not the host', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0006'); const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0006', 2);
const after = reassignHost(lobby, 'not-a-real-token'); const after = reassignHost(lobby, 'not-a-real-token');
assert.equal(after, lobby); assert.equal(after, lobby);
}); });
it('leaves hostToken alone when no other human seat exists', () => { it('leaves hostToken alone when no other human seat exists', () => {
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0007'); const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0007', 2);
const after = reassignHost(lobby, session.token); const after = reassignHost(lobby, session.token);
assert.equal(after.hostToken, session.token); assert.equal(after.hostToken, session.token);
}); });
@@ -135,40 +160,50 @@ describe('host transfer', () => {
describe('starting', () => { describe('starting', () => {
it('refuses a non-host caller', () => { it('refuses a non-host caller', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0008'); const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0008', 2);
joinLobby(lobby, 'Bob'); joinLobby(lobby, 'Bob');
assert.deepEqual(startLobby(lobby, 'someone-elses-token'), { ok: false, code: 'NOT_HOST' }); assert.deepEqual(startLobby(lobby, 'someone-elses-token'), { ok: false, code: 'NOT_HOST' });
}); });
it('refuses to start with a gap in the seats', () => { it('refuses to start while a chair is still empty', () => {
let lobby = createLobby(competitive, 'Alice', 'RAIL-0009').lobby; const lobby = createLobby(competitive, 'Alice', 'RAIL-0009', 3).lobby;
const j2 = joinLobby(lobby, 'Bob'); const j2 = joinLobby(lobby, 'Bob');
assert.ok(j2.ok); assert.ok(j2.ok);
if (!j2.ok) return; if (!j2.ok) return;
const j3 = joinLobby(j2.lobby, 'Carol'); assert.deepEqual(startLobby(j2.lobby, j2.lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
assert.ok(j3.ok);
if (!j3.ok) return;
lobby = { ...j3.lobby, seats: [j3.lobby.seats[0]!, null, j3.lobby.seats[2]!] };
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
}); });
it('refuses a solo human in a competitive lobby (needs 2-4)', () => { it('refuses a solo human at a table sized for more', () => {
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010'); const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010', 2);
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' }); assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
}); });
it('starts a full 2-player lobby, naming bots "Bot" and humans by their display name', () => { 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); lobby = setBotSeat(lobby, 1, true);
const r = startLobby(lobby, lobby.hostToken); const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot'], botSeats: [1] }); assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot'], botSeats: [1] });
}); });
it('starts a solitaire lobby of exactly 1', () => { it('starts a solitaire lobby of exactly 1', () => {
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012'); const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012', 1);
const r = startLobby(lobby, lobby.hostToken); const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice'], botSeats: [] }); assert.deepEqual(r, { ok: true, playerNames: ['Alice'], botSeats: [] });
}); });
it('seat index is player index, with no compaction to shift it', () => {
// The seats array is never resized or squeezed, so the chair a player joined into is the
// player index the game gives them — which is what every PlayerSession already recorded at
// join time, and what /api/stream and /api/intent route by.
let lobby = createLobby(competitive, 'Alice', 'RAIL-0015', 4).lobby;
const bob = joinLobby(lobby, 'Bob');
assert.ok(bob.ok);
if (!bob.ok) return;
lobby = setBotSeat(setBotSeat(bob.lobby, 2, true), 3, true);
const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bob', 'Bot', 'Bot'], botSeats: [2, 3] });
assert.equal(bob.session.player, 1, "Bob's stored player index still names his chair");
});
}); });
describe('playerCountAllowed', () => { describe('playerCountAllowed', () => {
+66
View File
@@ -234,3 +234,69 @@ describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => {
assert.equal(session.exportSave().status, 'active'); assert.equal(session.exportSave().status, 'active');
}); });
}); });
describe('summary() — what an administrator sees without replaying the game', () => {
it('describes a fresh game: who is at the table, where it has got to, and who it waits on', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const s = session.summary();
assert.equal(s.playerCount, 2);
assert.deepEqual(s.playerNames, ['Alice', 'Bob']);
assert.equal(s.status, 'active');
assert.equal(s.day, 1);
assert.equal(s.stage, 1);
assert.equal(typeof s.phase, 'string');
assert.ok(s.waitingOn, 'a game in play must be waiting on somebody');
assert.equal(s.waitingOn!.name, s.playerNames[s.waitingOn!.seat]);
});
it('does not hand back a copy of the history the way exportSave must', () => {
// The health check polls this on a timer, so it answering with every intent of every game
// would make a question about none of them cost a copy of all of them.
const session = createSession(42, config, ['Alice', 'Bob']);
assert.equal('history' in session.summary(), false);
});
it('moves lastMoveAt when a move is accepted, and leaves it alone when one is refused', async () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const created = session.summary();
assert.equal(created.lastMoveAt, created.createdAt, 'an untouched game has not moved since it began');
const actor = (session.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex;
const idle = (1 - actor) as PlayerIndex;
// A rejection is not a move — a player poking at a game they cannot act in must not make it
// look alive to whoever is deciding whether it has stalled.
session.intent(idle, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(session.summary().lastMoveAt, created.lastMoveAt, 'a refused intent moved the clock');
await new Promise((r) => setTimeout(r, 2));
const accepted = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(accepted.accepted, true);
assert.ok(session.summary().lastMoveAt > created.lastMoveAt, 'an accepted intent did not move the clock');
});
it('carries lastMoveAt across a restart, and falls back to createdAt for a save without one', async () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const actor = (session.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex;
await new Promise((r) => setTimeout(r, 2));
session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
const saved = session.exportSave();
assert.equal(resumeSession(saved).summary().lastMoveAt, saved.lastMoveAt);
// A game written before the field existed still has to load, and reads as untouched since it
// began rather than as having just moved.
const { lastMoveAt: _dropped, ...older } = saved;
const revived = resumeSession(older).summary();
assert.equal(revived.lastMoveAt, saved.createdAt);
});
it('reports a finished game as waiting on nobody', () => {
// Every seat a bot, so the game plays itself to a finish inside the constructor.
const session = createSession(4242, config, ['A', 'B'], [0 as PlayerIndex, 1 as PlayerIndex]);
const s = session.summary();
assert.equal(s.status, 'finished');
assert.equal(s.waitingOn, null, 'a finished game must not name somebody to wait for');
});
});