diff --git a/CHANGELOG.md b/CHANGELOG.md index 3891667..d15e931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,67 @@ page as `v0.1.0 · · `, 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//save`, and +`DELETE /api/games/`. 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`, diff --git a/TODO.md b/TODO.md index 1a81533..02887d1 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/docs/architecture/multiplayer.md b/docs/architecture/multiplayer.md index 8ba10cd..779a722 100644 --- a/docs/architecture/multiplayer.md +++ b/docs/architecture/multiplayer.md @@ -245,7 +245,10 @@ special handling: `pump` stops, and the next push simply carries a `Menu` contai POST /api/lobby/create, /api/lobby/join lobby POST /api/intent { gameId, seq, intent } GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume -GET /api/health { ok, service, engineVersion } — unauthenticated; see below +GET /api/health { ok, service, engineVersion, games } — unauthenticated; see below +GET /api/games every game and lobby, summarised ┐ +GET /api/games//save the save, for keeping or replaying ├ ADMIN_SECRET +DELETE /api/games/ ends a game, and returns its save ┘ GET / the client ``` @@ -253,7 +256,19 @@ GET / the client `dist/` is served both ways and the bundle is identical (D4), and every other route 404s an unknown path exactly as a static host does — so the splash asks, and closes its multiplayer door only when nothing names itself in reply. It is unauthenticated on purpose: it reveals that a Station Master -server is answering and nothing else, no game and no seat. +server is answering and nothing else, no game and no seat. Its `games` field — `{ active, lobby }` +— is what the StartOS package's health check reports as "3 games in progress". + +**The three administrative routes are gated by `ADMIN_SECRET`, which is deliberately not the join +secret.** Every player holds the join secret, so gating a delete with it would let anyone at the +table destroy anyone else's game; this one belongs to whoever runs the server. It arrives in an +`x-admin-secret` header rather than the query string, so it stays out of logs and referrers. When +the variable is unset the routes answer 404 exactly as any unknown path does, so a server that was +never given an administrator does not advertise that it has one. + +**A delete returns the deleted game's save.** The intents are the game (D5), so what comes back is +the whole thing and not a summary of it — the record survives even though the game does not, and +nothing is destroyed without being handed to whoever destroyed it first. Chosen over WebSocket because this game is **idle most of the time** — turn-based with human think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's diff --git a/package.json b/package.json index c79a0cd..3134841 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.5.2", + "version": "0.5.3", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/server/http.ts b/src/server/http.ts index ebd5f9f..42f17a4 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -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; initialLobbies: Map; @@ -212,24 +224,137 @@ export function startServer(opts: ServerOptions): void { * is what the client is about to offer the player anyway. It reveals no game and no seat. */ if (url.pathname === '/api/health' && req.method === 'GET') { - sendJson(res, 200, { ok: true, service: 'station-master', engineVersion: opts.engineVersion }); + // `summary()` rather than `exportSave()`: this is polled on a timer, and the save copies + // every intent of every game to answer a question about none of them. + let active = 0; + for (const g of games.values()) if (g.summary().status === 'active') active++; + sendJson(res, 200, { + ok: true, + service: 'station-master', + engineVersion: opts.engineVersion, + games: { active, lobby: lobbies.size }, + }); + return; + } + + // -- Administration: listing, exporting and deleting games ------------------------------ + + if (url.pathname === '/api/games' || url.pathname.startsWith('/api/games/')) { + // Unset means the routes are not here — indistinguishable from any other unknown path, so + // nothing advertises an administrative surface to someone probing for one. + if (!opts.adminSecret) { + await serveStatic(opts.distDir, url.pathname, res); + return; + } + if (req.headers['x-admin-secret'] !== opts.adminSecret) { + sendJson(res, 403, { error: 'bad or missing admin secret' }); + return; + } + + const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode])); + + if (url.pathname === '/api/games' && req.method === 'GET') { + const running = [...games.entries()].map(([gameId, g]) => ({ + gameId, + gameCode: codes.get(gameId) ?? null, + state: 'running' as const, + ...g.summary(), + })); + // A lobby has no game to summarize yet — it is reported as what it is, so an + // administrator sees a table that never started rather than nothing at all. + const waiting = [...lobbies.values()].map((l) => ({ + gameId: l.gameId, + gameCode: l.gameCode, + state: 'lobby' as const, + playerCount: l.seats.length, + playerNames: l.seats.map((seat) => + seat === null ? '(empty)' : seat.kind === 'bot' ? 'Bot' : seat.displayName, + ), + createdAt: l.createdAt, + })); + sendJson(res, 200, { games: [...running, ...waiting] }); + return; + } + + const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname); + const gameId = match?.[1]; + if (!gameId) { + sendJson(res, 404, { error: 'no such route' }); + return; + } + + if (match?.[2] && req.method === 'GET') { + const session = games.get(gameId); + if (!session) { + sendJson(res, 404, { error: 'no such game' }); + return; + } + sendJson(res, 200, { gameCode: codes.get(gameId) ?? null, save: session.exportSave() }); + return; + } + + if (req.method === 'DELETE') { + const session = games.get(gameId); + const lobby = lobbies.get(gameId); + if (!session && !lobby) { + sendJson(res, 404, { error: 'no such game' }); + return; + } + // The save goes back with the deletion, so a game can never be destroyed without its + // record being handed to whoever destroyed it — the intents ARE the game (D5), so this + // is the whole thing, replayable later, not a summary of it. + const save = session?.exportSave() ?? null; + + // Everyone watching is told the game is gone before its files are, rather than being + // left on a stream that will never push again. + for (const [, watcher] of gameConnections.get(gameId) ?? []) watcher.end(); + gameConnections.delete(gameId); + for (const [, watcher] of lobbyConnections.get(gameId) ?? []) watcher.end(); + lobbyConnections.delete(gameId); + + games.delete(gameId); + lobbies.delete(gameId); + gameEventIds.delete(gameId); + const code = lobby?.gameCode ?? codes.get(gameId); + if (code) gameCodes.delete(code); + for (const [token, ps] of [...sessions]) if (ps.gameId === gameId) sessions.delete(token); + + await removeIndexEntry(opts.dataDir, gameId); + await deleteGame(opts.dataDir, gameId); + sendJson(res, 200, { ok: true, gameCode: code ?? null, save }); + return; + } + + sendJson(res, 405, { error: 'method not allowed' }); return; } // -- Lobby: creating and joining (the door — join-secret gated) -------------------------- if (url.pathname === '/api/lobby/create' && req.method === 'POST') { - const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string }; + const body = (await readJson(req)) as { + secret?: string; + config?: GameConfig; + displayName?: string; + players?: number; + }; if (body.secret !== opts.joinSecret) { sendJson(res, 403, { error: 'bad or missing secret' }); return; } if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') { - sendJson(res, 400, { error: 'expected { secret, config, displayName }' }); + sendJson(res, 400, { error: 'expected { secret, config, displayName, players }' }); + return; + } + // The table size is the host's to choose and is fixed from here on, so it is validated at + // the door rather than at Start — `createLobby` builds the seats array from it. + const players = body.players ?? 0; + if (!Number.isInteger(players) || !playerCountAllowed(body.config.mode, players)) { + sendJson(res, 400, { error: 'BAD_PLAYER_COUNT' }); return; } const gameCode = freshGameCode((code) => gameCodes.has(code)); - const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode); + const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players); await persistLobby(lobby); await persistSession(session); sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player }); diff --git a/src/server/index.ts b/src/server/index.ts index 2e777c1..7b49422 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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(); const initialSessions = new Map(); 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.'); +} diff --git a/src/server/lobby.ts b/src/server/lobby.ts index c4e41a2..5ca1c45 100644 --- a/src/server/lobby.ts +++ b/src/server/lobby.ts @@ -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[]; + 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 }; } diff --git a/src/server/persistence.ts b/src/server/persistence.ts index 76ac1af..9de11cb 100644 --- a/src/server/persistence.ts +++ b/src/server/persistence.ts @@ -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 { + 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 { + await rm(gameDir(dataDir, gameId), { recursive: true, force: true }); +} + export async function writeLobby(dataDir: string, lobby: Lobby): Promise { const dir = gameDir(dataDir, lobby.gameId); await mkdir(dir, { recursive: true }); diff --git a/src/server/session.ts b/src/server/session.ts index 240f075..922c496 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -82,6 +82,33 @@ export type SavedGame = { * guessing from a display name rather than reading a fact. */ botSeats: PlayerIndex[]; + /** + * Wall-clock of the last accepted intent, so "has this game stalled?" survives a restart. + * + * Optional because it postdates the format, and defaulted to `createdAt` when absent — a game + * whose last move is unrecorded reads as untouched since it began, which is the honest answer + * rather than a fabricated one. Kept OUT of `history`, like the turn timings and for the same + * reason: a replay must reproduce a game from decisions alone, and wall-clock is not a decision. + */ + lastMoveAt?: number; +}; + +/** What an administrator needs to see about a game without replaying it themselves. */ +export type GameSummary = { + playerCount: number; + playerNames: string[]; + botSeats: PlayerIndex[]; + status: 'active' | 'finished'; + createdAt: number; + lastMoveAt: number; + day: number; + stage: number; + phase: string; + /** + * Whose move it is, or `null` — which is not an error state: the Mainline Phase runs itself, and + * a finished game waits on nobody. + */ + waitingOn: { seat: PlayerIndex; name: string } | null; }; export type IntentResult = @@ -96,6 +123,12 @@ export type GameSession = { intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult; /** Everything needed to persist this game and, later, rebuild it via `resumeSession`. */ exportSave(): SavedGame; + /** + * A cheap description of where this game has got to. Deliberately does not copy `history` the + * way `exportSave` must — the health check polls this on a timer, and an administrator listing + * games wants the state of each, not a copy of every intent in all of them. + */ + summary(): GameSummary; }; type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number }; @@ -105,7 +138,9 @@ function buildSession( playerNames: string[], createdAt: number, botSeats: Set, + lastMoveAtInit: number, ): GameSession { + let lastMoveAt = lastMoveAtInit; const lastSeq = new Map(); const lastFrame = new Map(); const sentLines = new Map(); @@ -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, + ); } diff --git a/src/web/lobby.ts b/src/web/lobby.ts index 3858105..4cfbc9f 100644 --- a/src/web/lobby.ts +++ b/src/web/lobby.ts @@ -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 - ? '— empty —' + ? '— waiting —' : 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 = $('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($('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')); diff --git a/src/web/play.html b/src/web/play.html index 78491a3..1dc1787 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -241,6 +241,14 @@ ul.blocked li{padding:2px 0} Competitive
Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.
+ +

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.

diff --git a/test/server/lobby.test.ts b/test/server/lobby.test.ts index adfd6ff..590f4a8 100644 --- a/test/server/lobby.test.ts +++ b/test/server/lobby.test.ts @@ -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', () => { diff --git a/test/server/session.test.ts b/test/server/session.test.ts index f4f9a7e..19810b3 100644 --- a/test/server/session.test.ts +++ b/test/server/session.test.ts @@ -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'); + }); +});