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