v0.8.4 — the multiplayer transport: server and browser
The second release from the audit. Every fault here was invisible in solitaire, and four of the five server faults were in the one file no test had ever stood up; `http.ts` now has an end-to-end suite on a real port. CHANGELOG has the reasoning. SERVER. Leaving a lobby freed the chair and kept the token, so a leaver could stream and move for whoever took the seat next — revoked now, in memory and on disk. The browser numbered intents from 1 per page load while the server remembered the seat's last number, so the first move after a reload was swallowed as a resend — the connect push carries the count and the client continues from it. Nothing serialised moves within a game and every write shared one `.tmp` name, so two moves at once tore `game.json` (measured: 6 of 200), and the boot's bare `JSON.parse` then took every game down — per-path write queues, a per-game move queue, and a boot that skips one bad file. An error after the SSE head was sent crashed the process. Bodies were unbounded before any secret check. BROWSER. A double-click did the thing twice: one submit in flight at a time. A failed submit is `false`, not an unhandled rejection. The documentation renderer flattened nested bullets into a literal "- " mid-sentence on the published home-deck page. The make-up panel promised cars the engine refuses; it asks `acceptsCar` now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4d222a7eba
commit
7d778df748
@@ -19,6 +19,74 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.8.4 — 2026-09-29
|
||||||
|
|
||||||
|
The second release from the audit: the multiplayer transport, server and browser. Every fault here
|
||||||
|
was invisible in solitaire, and four of the five server faults were in the one file no test had
|
||||||
|
ever stood up — `http.ts` now has an end-to-end suite that binds a real port.
|
||||||
|
|
||||||
|
### A player who left could play the seat the next arrival took
|
||||||
|
|
||||||
|
Leaving a lobby freed the chair and kept the token: it stayed in memory and on disk, and the next
|
||||||
|
player to join was given the vacated chair. So once the game began, the leaver's browser still held
|
||||||
|
a token for that seat — `/api/stream` served it the newcomer's hand and menu, `/api/intent` let it
|
||||||
|
move for them, and its stream connection displaced theirs. `lobby-and-sessions.md` had said all
|
||||||
|
along that Leave "drops the token"; the code did not. It does now, for a player's own leave and for
|
||||||
|
the host's remove alike, in memory and in `sessions.json`, before the chair is offered to anyone.
|
||||||
|
|
||||||
|
### The first move after a reload could be silently swallowed
|
||||||
|
|
||||||
|
The browser numbered its intents from 1 on every page load; the server remembers a seat's last
|
||||||
|
accepted number for the life of the game and answers a repeat with "already applied". A seat that
|
||||||
|
had made one move, reloaded, and clicked again sent `seq: 1` twice — ok, nothing happened, nothing
|
||||||
|
pushed, the click looked dead. The connect push now carries the server's count (`lastSeq`) and the
|
||||||
|
client continues from it, never backwards.
|
||||||
|
|
||||||
|
### Two moves at once could tear the save, and a torn save took every game down
|
||||||
|
|
||||||
|
Nothing serialised moves within a game: the handler awaits the disk write between applying and
|
||||||
|
answering, and two moves arriving together interleaved across it — both applied in memory, both
|
||||||
|
writing the same `game.json.tmp`. Measured at 200 rounds of two concurrent writes: every round lost
|
||||||
|
one to `rename` ENOENT, six left the file as invalid JSON. And the boot did a bare `JSON.parse` on
|
||||||
|
each save at the top level, so one such file was a crash loop with every game on the server
|
||||||
|
unreachable. Three fixes, each pinned: every write to a path queues behind the one before it with a
|
||||||
|
unique temp name; each game's moves run one at a time through apply, persist, answer, broadcast; and
|
||||||
|
an unreadable save is logged and skipped rather than fatal, as is one whose replay throws.
|
||||||
|
|
||||||
|
### One error after the SSE head was sent was a whole-server crash
|
||||||
|
|
||||||
|
The handler's one `catch` answered every error with a JSON 500 — and on a response whose head was
|
||||||
|
already written (both streams, static files) `writeHead` throws inside the catch, with nothing above
|
||||||
|
it. Node exits on an unhandled rejection. `sendJson` now ends such a response instead; the lobby's
|
||||||
|
host-reassignment write and the static file stream have their own error paths; and the 500 no longer
|
||||||
|
echoes the error's message, which for a disk error carried the data directory's absolute path.
|
||||||
|
|
||||||
|
### Anyone could exhaust the process's memory with one POST
|
||||||
|
|
||||||
|
Request bodies were buffered whole, with no cap, before any secret was checked. A 64 KiB limit —
|
||||||
|
the largest body any route has a use for is a few hundred bytes — answers 413; a body that is not a
|
||||||
|
JSON object answers 400 rather than surfacing as a 500.
|
||||||
|
|
||||||
|
### In the browser
|
||||||
|
|
||||||
|
**A double-click did the thing twice.** The page redraws the same menu the instant a submit is sent,
|
||||||
|
so a second click before the round trip posted a second, fresh `seq` for the same option, and the
|
||||||
|
server applied it again: two cards drawn, two Moves spent, two cars coupled. One submit in flight at
|
||||||
|
a time now; a click that lands during one is dropped, and the push is milliseconds away. A network
|
||||||
|
failure or a non-JSON answer is `false` from `submit` rather than an unhandled rejection.
|
||||||
|
|
||||||
|
**The documentation renderer flattened nested bullets.** The comment said nesting was rendered by
|
||||||
|
recursion; the code appended the nested bullet to its parent as text, and the published home-deck
|
||||||
|
page read "…knows. - ABS Signals is the exception…" with a literal dash mid-sentence. Real nesting
|
||||||
|
now, and the test renders the real document to prove the dash is gone.
|
||||||
|
|
||||||
|
**"Still needs 2 boxcar" behind a caboose.** The make-up panel counted by category and promised cars
|
||||||
|
the engine would refuse: nothing couples behind a caboose (§A.3), so it printed a need while no yard
|
||||||
|
chip lit and the only offer was to send the train out as it stands. It asks `acceptsCar` per
|
||||||
|
category now, the way the engine's own make-up report does, and says why when nothing more couples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.8.3 — 2026-09-29
|
## 0.8.3 — 2026-09-29
|
||||||
|
|
||||||
The first of three releases from a code audit (engine, server, client, tests and hygiene, each read
|
The first of three releases from a code audit (engine, server, client, tests and hygiene, each read
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ clashing join is refused (`NAME_TAKEN`, compared trimmed and case-insensitively)
|
|||||||
suffixed: a player should play under the name they chose, or be asked for another.
|
suffixed: a player should play under the name they chose, or be asked for another.
|
||||||
|
|
||||||
**Anybody may leave, and the host may clear a chair.** `Lobby.Leave` frees the seat, drops the token
|
**Anybody may leave, and the host may clear a chair.** `Lobby.Leave` frees the seat, drops the token
|
||||||
from `joinOrder`, and passes host rights on exactly as a dropped connection does. Naming somebody
|
from `joinOrder`, **revokes it** — in memory and in `sessions.json`, since v0.8.4; until then the
|
||||||
|
leaver's token still opened the seat the next arrival took — and passes host rights on exactly as a
|
||||||
|
dropped connection does. Naming somebody
|
||||||
else's `seat` is host-only. When the last human leaves, the lobby is deleted outright — code, file and
|
else's `seat` is host-only. When the last human leaves, the lobby is deleted outright — code, file and
|
||||||
index row — rather than left as a table of bots waiting for a host who no longer exists. Before this
|
index row — rather than left as a table of bots waiting for a host who no longer exists. Before this
|
||||||
existed a mis-join or a player who wandered off wedged the whole table, since Start needs every chair
|
existed a mis-join or a player who wandered off wedged the whole table, since Start needs every chair
|
||||||
|
|||||||
@@ -151,8 +151,14 @@ multiplayer work: everything else degrades gracefully, a redaction bug hands a p
|
|||||||
- Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on
|
- Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on
|
||||||
a gap rather than guessing.
|
a gap rather than guessing.
|
||||||
- Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a
|
- Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a
|
||||||
reconnecting client can safely resend anything it is unsure about.
|
reconnecting client can safely resend anything it is unsure about. **The count is the server's,
|
||||||
|
for the life of the game**: the connect push carries the seat's last accepted `seq` (`lastSeq`)
|
||||||
|
and the client continues from it, never from 1 — a page that restarted its own count after a
|
||||||
|
reload re-sent a number the server had already applied, and the move was silently swallowed as
|
||||||
|
a resend (v0.8.4).
|
||||||
- **The server never applies two intents concurrently within a game.** A per-game queue is sufficient
|
- **The server never applies two intents concurrently within a game.** A per-game queue is sufficient
|
||||||
and there is nothing cleverer to do at this scale. Note this is a serialisation rule, not a
|
and there is nothing cleverer to do at this scale. It is a real queue (`http.ts`'s `inTurn`), not a
|
||||||
|
reliance on the single thread: the handler awaits the disk write between applying and answering,
|
||||||
|
and two moves arriving together used to interleave across that await (v0.8.4). Note this is a serialisation rule, not a
|
||||||
one-actor-at-a-time rule: per-player turn state (`turns: Map<PlayerIndex, TurnState>`) means several
|
one-actor-at-a-time rule: per-player turn state (`turns: Map<PlayerIndex, TurnState>`) means several
|
||||||
players may hold an open turn at once, and their intents still land one at a time.
|
players may hold an open turn at once, and their intents still land one at a time.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Station Master — Components and Markers
|
# Station Master — Components and Markers
|
||||||
|
|
||||||
**Version 0.8.3** · 2026-09-29
|
**Version 0.8.4** · 2026-09-29
|
||||||
|
|
||||||
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles,
|
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles,
|
||||||
hand state, timetable state and other markers are documented with their cards or in the
|
hand state, timetable state and other markers are documented with their cards or in the
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Station Master — Home Deck
|
# Station Master — Home Deck
|
||||||
|
|
||||||
**Version 0.8.3** · 2026-09-29
|
**Version 0.8.4** · 2026-09-29
|
||||||
|
|
||||||
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
|
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
|
||||||
rules are for playing each kind of card out of it.
|
rules are for playing each kind of card out of it.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Station Master — Mainline Deck
|
# Station Master — Mainline Deck
|
||||||
|
|
||||||
**Version 0.8.3** · 2026-09-29
|
**Version 0.8.4** · 2026-09-29
|
||||||
|
|
||||||
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a
|
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a
|
||||||
card does to a train crossing it, and the Home Deck cards played onto one.
|
card does to a train crossing it, and the Home Deck cards played onto one.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Station Master — Quickstart
|
# Station Master — Quickstart
|
||||||
|
|
||||||
**Version 0.8.3** · 2026-09-29
|
**Version 0.8.4** · 2026-09-29
|
||||||
|
|
||||||
For a player who has never played.
|
For a player who has never played.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Station Master — Rules
|
# Station Master — Rules
|
||||||
|
|
||||||
**Version 0.8.3** · 2026-09-29
|
**Version 0.8.4** · 2026-09-29
|
||||||
|
|
||||||
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable
|
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable
|
||||||
behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
|
behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.8.3",
|
"version": "0.8.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+43
-18
@@ -109,6 +109,39 @@ export type Rendered = { html: string; headings: Heading[] };
|
|||||||
* `linkHref` rewrites link targets — the build uses it to send `rules.md` to `rules.html` — and
|
* `linkHref` rewrites link targets — the build uses it to send `rules.md` to `rules.html` — and
|
||||||
* defaults to leaving them alone so the function is testable on its own.
|
* defaults to leaving them alone so the function is testable on its own.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* One list at one indent level. An item is its first line plus any lines indented under it; the
|
||||||
|
* more-indented BULLETS among those are the item's own nested list and render recursively, while
|
||||||
|
* plain indented lines are wrapped continuations of its text.
|
||||||
|
*/
|
||||||
|
function listHtml(block: string[], text: (s: string) => string): string {
|
||||||
|
const first = /^(\s*)([-*+]|\d+[.)])\s+/.exec(block.find((l) => l.trim() !== '') ?? '');
|
||||||
|
if (!first) return '';
|
||||||
|
const ordered = /\d/.test(first[2]!);
|
||||||
|
const base = first[1]!.length;
|
||||||
|
const items: { text: string[]; sub: string[] }[] = [];
|
||||||
|
for (const l of block) {
|
||||||
|
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l);
|
||||||
|
const current = items[items.length - 1];
|
||||||
|
if (m && m[1]!.length <= base) {
|
||||||
|
items.push({ text: [m[3]!], sub: [] });
|
||||||
|
} else if (!current) {
|
||||||
|
continue;
|
||||||
|
} else if (current.sub.length > 0 || (m && m[1]!.length > base)) {
|
||||||
|
// Once a nested list has begun, everything further belongs to it, wrapped lines included.
|
||||||
|
current.sub.push(l);
|
||||||
|
} else if (l.trim() !== '') {
|
||||||
|
current.text.push(l.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tag = ordered ? 'ol' : 'ul';
|
||||||
|
return (
|
||||||
|
`<${tag}>` +
|
||||||
|
items.map((it) => `<li>${text(it.text.join(' '))}${it.sub.length > 0 ? listHtml(it.sub, text) : ''}</li>`).join('') +
|
||||||
|
`</${tag}>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderMarkdown(src: string, linkHref: (href: string) => string = (h) => h): Rendered {
|
export function renderMarkdown(src: string, linkHref: (href: string) => string = (h) => h): Rendered {
|
||||||
const lines = src.replace(/\r\n/g, '\n').split('\n');
|
const lines = src.replace(/\r\n/g, '\n').split('\n');
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@@ -196,13 +229,14 @@ export function renderMarkdown(src: string, linkHref: (href: string) => string =
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lists. A bullet or a number opens one; continuation lines are indented under their item.
|
// Lists. A bullet or a number opens one; continuation lines are indented under their item, and
|
||||||
|
// a more-indented bullet under an item is a nested list, rendered by recursion (v0.8.4 — the
|
||||||
|
// comment said so before and the code appended the nested bullet to its parent as text, so the
|
||||||
|
// published home-deck page carried a literal "- " mid-sentence).
|
||||||
const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
||||||
if (bullet) {
|
if (bullet) {
|
||||||
const ordered = /\d/.test(bullet[2]!);
|
|
||||||
const baseIndent = bullet[1]!.length;
|
const baseIndent = bullet[1]!.length;
|
||||||
const items: string[] = [];
|
const block: string[] = [];
|
||||||
let current: string[] | null = null;
|
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const l = lines[i]!;
|
const l = lines[i]!;
|
||||||
if (l.trim() === '') {
|
if (l.trim() === '') {
|
||||||
@@ -210,28 +244,19 @@ export function renderMarkdown(src: string, linkHref: (href: string) => string =
|
|||||||
const next = lines[i + 1] ?? '';
|
const next = lines[i + 1] ?? '';
|
||||||
const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next);
|
const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next);
|
||||||
if (!continues) break;
|
if (!continues) break;
|
||||||
|
block.push('');
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l);
|
const m = /^(\s*)([-*+]|\d+[.)])\s+/.exec(l);
|
||||||
if (m && m[1]!.length <= baseIndent) {
|
if ((m && m[1]!.length <= baseIndent) || (m && m[1]!.length > baseIndent) || /^\s{2,}\S/.test(l)) {
|
||||||
if (current) items.push(current.join(' '));
|
block.push(l);
|
||||||
current = [m[3]!];
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (m || /^\s{2,}\S/.test(l)) {
|
|
||||||
// A nested item or a wrapped continuation. Nesting is rendered by recursion on the block.
|
|
||||||
if (!current) break;
|
|
||||||
current.push(l.trim());
|
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (current) items.push(current.join(' '));
|
ln(listHtml(block, text));
|
||||||
const tag = ordered ? 'ol' : 'ul';
|
|
||||||
ln(`<${tag}>${items.map((it) => `<li>${text(it)}</li>`).join('')}</${tag}>`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+133
-26
@@ -18,7 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { stat } from 'node:fs/promises';
|
import { stat } from 'node:fs/promises';
|
||||||
import { extname, join, normalize } from 'node:path';
|
import { extname, join, normalize } from 'node:path';
|
||||||
@@ -97,14 +97,57 @@ const MIME: Record<string, string> = {
|
|||||||
|
|
||||||
const HEARTBEAT_MS = 20_000;
|
const HEARTBEAT_MS = 20_000;
|
||||||
|
|
||||||
async function readJson(req: IncomingMessage): Promise<unknown> {
|
/**
|
||||||
|
* THE LARGEST BODY ANY ROUTE HERE HAS A USE FOR, with room to spare (v0.8.4). The biggest thing a
|
||||||
|
* client sends is a `GameConfig` on `/api/lobby/create` — a few hundred bytes. `readJson` used to
|
||||||
|
* buffer whatever arrived, before any secret was checked, so anyone who could reach the port could
|
||||||
|
* exhaust the process's memory with one POST. D14 expects this box to be reachable.
|
||||||
|
*/
|
||||||
|
const MAX_BODY_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
/** A refusal with a status — thrown from anywhere in a handler and answered by the one catch. */
|
||||||
|
class HttpError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||||
|
const declared = Number(req.headers['content-length'] ?? 0);
|
||||||
|
if (declared > MAX_BODY_BYTES) throw new HttpError(413, 'body too large');
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
let size = 0;
|
||||||
|
for await (const chunk of req) {
|
||||||
|
size += (chunk as Buffer).length;
|
||||||
|
if (size > MAX_BODY_BYTES) throw new HttpError(413, 'body too large');
|
||||||
|
chunks.push(chunk as Buffer);
|
||||||
|
}
|
||||||
const text = Buffer.concat(chunks).toString('utf8');
|
const text = Buffer.concat(chunks).toString('utf8');
|
||||||
return text.trim() === '' ? {} : JSON.parse(text);
|
if (text.trim() === '') return {};
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// A malformed body is the caller's mistake, answered as one — it used to surface as a 500.
|
||||||
|
throw new HttpError(400, 'body is not JSON');
|
||||||
|
}
|
||||||
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new HttpError(400, 'expected a JSON object');
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
/**
|
||||||
|
* NEVER A SECOND HEAD (v0.8.4). The SSE routes and `serveStatic` write their head and go on; a
|
||||||
|
* throw after that reached the handler's catch, which called this, and `writeHead` on a response
|
||||||
|
* whose head was sent throws `ERR_HTTP_HEADERS_SENT` — inside a `.catch`, with nothing above it,
|
||||||
|
* so Node exited on the unhandled rejection. One bad stream write was a whole-server crash.
|
||||||
|
*/
|
||||||
|
if (res.headersSent) {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const text = JSON.stringify(body);
|
const text = JSON.stringify(body);
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) });
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) });
|
||||||
res.end(text);
|
res.end(text);
|
||||||
@@ -149,7 +192,11 @@ async function serveStatic(
|
|||||||
'Content-Length': info.size,
|
'Content-Length': info.size,
|
||||||
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
|
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
|
||||||
});
|
});
|
||||||
createReadStream(full).pipe(res);
|
// A read that fails mid-stream (file replaced by a deploy, disk error) closes the response rather
|
||||||
|
// than raising an error nothing listens for.
|
||||||
|
createReadStream(full)
|
||||||
|
.on('error', () => res.destroy())
|
||||||
|
.pipe(res);
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
res.end('not found');
|
res.end('not found');
|
||||||
@@ -175,7 +222,8 @@ type LobbyPreview = {
|
|||||||
seated: { seat: number; who: string | null; bot: boolean }[];
|
seated: { seat: number; who: string | null; bot: boolean }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function startServer(opts: ServerOptions): void {
|
/** Returns the listening server — a test binds port 0 and reads the port back off it. */
|
||||||
|
export function startServer(opts: ServerOptions): Server {
|
||||||
const games = opts.initialGames;
|
const games = opts.initialGames;
|
||||||
const lobbies = opts.initialLobbies;
|
const lobbies = opts.initialLobbies;
|
||||||
const sessions = opts.initialSessions;
|
const sessions = opts.initialSessions;
|
||||||
@@ -283,8 +331,29 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
|
|
||||||
async function persistSession(ps: PlayerSession): Promise<void> {
|
async function persistSession(ps: PlayerSession): Promise<void> {
|
||||||
sessions.set(ps.token, ps);
|
sessions.set(ps.token, ps);
|
||||||
const all = [...sessions.values()].filter((s) => s.gameId === ps.gameId);
|
await persistSessionsOf(ps.gameId);
|
||||||
await writeSessions(opts.dataDir, ps.gameId, all);
|
}
|
||||||
|
|
||||||
|
/** Rewrites one game's `sessions.json` from what is in memory — after a token is revoked as much
|
||||||
|
* as after one is issued, or a restart would hand the seat back to a browser that left it. */
|
||||||
|
async function persistSessionsOf(gameId: string): Promise<void> {
|
||||||
|
const all = [...sessions.values()].filter((s) => s.gameId === gameId);
|
||||||
|
await writeSessions(opts.dataDir, gameId, all);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE INTENT AT A TIME PER GAME — `protocol.md` §5's serialisation rule, which the intent handler
|
||||||
|
* relied on Node's single thread to keep and did not (v0.8.4): the `await` on the disk write is
|
||||||
|
* an interleaving point, so two moves arriving together could both apply in memory, race their
|
||||||
|
* writes to one `.tmp`, and hand their broadcasts to the clients in the wrong order. Everything a
|
||||||
|
* move does — apply, persist, answer, broadcast — now runs as one unit behind the move before it.
|
||||||
|
*/
|
||||||
|
const gameQueues = new Map<string, Promise<unknown>>();
|
||||||
|
function inTurn<T>(gameId: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = gameQueues.get(gameId) ?? Promise.resolve();
|
||||||
|
const next = prev.then(fn, fn);
|
||||||
|
gameQueues.set(gameId, next.catch(() => undefined));
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
@@ -571,6 +640,17 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
sendJson(res, 403, { error: 'NOT_HOST' });
|
sendJson(res, 403, { error: 'NOT_HOST' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* THE SEAT GOES, AND SO DOES THE TOKEN THAT HELD IT (v0.8.4).
|
||||||
|
*
|
||||||
|
* Leaving used to free the chair and keep the session: the token stayed in `sessions` and
|
||||||
|
* on disk, and `joinLobby` hands a vacated chair to the next arrival. So a player who left
|
||||||
|
* (or was removed) still held a token for seat N, and once somebody else sat in seat N and
|
||||||
|
* the game began, `/api/stream` served that token the new occupant's hand and `/api/intent`
|
||||||
|
* let it move for them — and its stream connection displaced theirs. Revoked here, before
|
||||||
|
* the chair is offered to anyone.
|
||||||
|
*/
|
||||||
|
const occupant = seat === undefined ? ps : [...sessions.values()].find((s) => s.gameId === lobby.gameId && s.player === seat);
|
||||||
const result = leaveLobby(lobby, ps.token, seat);
|
const result = leaveLobby(lobby, ps.token, seat);
|
||||||
if (result.empty) {
|
if (result.empty) {
|
||||||
// Nobody human is left to start it. Everything about this lobby goes, including the code,
|
// Nobody human is left to start it. Everything about this lobby goes, including the code,
|
||||||
@@ -579,6 +659,7 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
gameCodes.delete(lobby.gameCode);
|
gameCodes.delete(lobby.gameCode);
|
||||||
for (const [, watcher] of lobbyConnections.get(lobby.gameId) ?? []) watcher.end();
|
for (const [, watcher] of lobbyConnections.get(lobby.gameId) ?? []) watcher.end();
|
||||||
lobbyConnections.delete(lobby.gameId);
|
lobbyConnections.delete(lobby.gameId);
|
||||||
|
for (const [token, s] of [...sessions]) if (s.gameId === lobby.gameId) sessions.delete(token);
|
||||||
await deleteLobby(opts.dataDir, lobby.gameId);
|
await deleteLobby(opts.dataDir, lobby.gameId);
|
||||||
// The row goes with the lobby rather than being marked: a game that never started is not a
|
// The row goes with the lobby rather than being marked: a game that never started is not a
|
||||||
// game an administrator has any use for a record of.
|
// game an administrator has any use for a record of.
|
||||||
@@ -586,6 +667,12 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
sendJson(res, 200, { ok: true, closed: true });
|
sendJson(res, 200, { ok: true, closed: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (occupant && result.lobby !== lobby) {
|
||||||
|
sessions.delete(occupant.token);
|
||||||
|
lobbyConnections.get(lobby.gameId)?.get(occupant.token)?.end();
|
||||||
|
lobbyConnections.get(lobby.gameId)?.delete(occupant.token);
|
||||||
|
await persistSessionsOf(lobby.gameId);
|
||||||
|
}
|
||||||
await persistLobby(result.lobby);
|
await persistLobby(result.lobby);
|
||||||
broadcastLobby(lobby.gameId);
|
broadcastLobby(lobby.gameId);
|
||||||
sendJson(res, 200, { ok: true });
|
sendJson(res, 200, { ok: true });
|
||||||
@@ -690,7 +777,9 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
// `lobby`, because it may have changed (another join, another bot toggle) since connect.
|
// `lobby`, because it may have changed (another join, another bot toggle) since connect.
|
||||||
const current = lobbies.get(lobby.gameId);
|
const current = lobbies.get(lobby.gameId);
|
||||||
if (current && current.hostToken === token) {
|
if (current && current.hostToken === token) {
|
||||||
void persistLobby(reassignHost(current, token)).then(() => broadcastLobby(lobby.gameId));
|
persistLobby(reassignHost(current, token))
|
||||||
|
.then(() => broadcastLobby(lobby.gameId))
|
||||||
|
.catch((err: unknown) => console.error(`reassigning the host of ${lobby.gameId} failed:`, err));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -820,31 +909,49 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
sendJson(res, 400, { error: 'expected { seq, intent }' });
|
sendJson(res, 400, { error: 'expected { seq, intent }' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = session.intent(ps.player, body.seq, body.intent);
|
const { seq, intent } = body;
|
||||||
if (result.accepted) {
|
await inTurn(ps.gameId, async () => {
|
||||||
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
|
const result = session.intent(ps.player, seq, intent);
|
||||||
// this scale, not just "applied in memory" (§12 step 14).
|
if (result.accepted) {
|
||||||
const dir = gameDir(opts.dataDir, ps.gameId);
|
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
|
||||||
await writeGame(dir, session.exportSave(), opts.engineVersion);
|
// this scale, not just "applied in memory" (§12 step 14).
|
||||||
if (result.timing) await appendTiming(dir, result.timing);
|
const dir = gameDir(opts.dataDir, ps.gameId);
|
||||||
if (session.exportSave().status === 'finished') {
|
try {
|
||||||
// `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be
|
await writeGame(dir, session.exportSave(), opts.engineVersion);
|
||||||
// carried forward here rather than left blank — `gameCodes` is the only place still
|
if (result.timing) await appendTiming(dir, result.timing);
|
||||||
// holding it once a lobby's own record is gone.
|
if (session.exportSave().status === 'finished') {
|
||||||
const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? '';
|
// `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be
|
||||||
await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' });
|
// carried forward here rather than left blank — `gameCodes` is the only place still
|
||||||
|
// holding it once a lobby's own record is gone.
|
||||||
|
const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? '';
|
||||||
|
await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// The move IS applied — every seat's game has moved on — so the table is told and the
|
||||||
|
// disk's failure is the operator's to see in the log. Answering 500 here told the one
|
||||||
|
// player who moved that their move was refused, while everyone else watched it happen.
|
||||||
|
console.error(`persisting ${ps.gameId} after a move failed:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code });
|
||||||
sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code });
|
if (result.accepted) broadcastGame(ps.gameId, result.pushes);
|
||||||
if (result.accepted) broadcastGame(ps.gameId, result.pushes);
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||||
})().catch((err: unknown) => {
|
})().catch((err: unknown) => {
|
||||||
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
if (err instanceof HttpError) {
|
||||||
|
sendJson(res, err.status, { error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Logged here, not echoed: an fs error's message carries the absolute path of the data
|
||||||
|
// directory, which is the operator's to know and not the caller's.
|
||||||
|
console.error(`${req.method ?? ''} ${req.url ?? ''} failed:`, err);
|
||||||
|
sendJson(res, 500, { error: 'internal error' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(opts.port, opts.bindAddress);
|
server.listen(opts.port, opts.bindAddress);
|
||||||
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -61,8 +61,22 @@ for (const entry of index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loaded = await loadGame(gameDir(dataDir, entry.gameId));
|
const loaded = await loadGame(gameDir(dataDir, entry.gameId));
|
||||||
|
if (!loaded.found && loaded.corrupt) {
|
||||||
|
// One unreadable file is one game lost, not every game (v0.8.4) — see `loadGame`.
|
||||||
|
console.error(`Skipping ${entry.gameId} (${entry.gameCode}): game.json is unreadable — ${loaded.corrupt}. The file is left untouched.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (loaded.found) {
|
if (loaded.found) {
|
||||||
const resumed = tryResumeSession(loaded.saved);
|
let resumed: ReturnType<typeof tryResumeSession>;
|
||||||
|
try {
|
||||||
|
resumed = tryResumeSession(loaded.saved);
|
||||||
|
} catch (err) {
|
||||||
|
// A save that parses but is not the shape the replay expects (no config, a history entry
|
||||||
|
// that is not an intent) throws inside the engine rather than being refused. Same answer:
|
||||||
|
// this game, not the server.
|
||||||
|
console.error(`Skipping ${entry.gameId} (${entry.gameCode}): the save could not be replayed — ${err instanceof Error ? err.message : String(err)}. The file is left untouched.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (resumed.ok) {
|
if (resumed.ok) {
|
||||||
initialGames.set(entry.gameId, resumed.session);
|
initialGames.set(entry.gameId, resumed.session);
|
||||||
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
|
||||||
|
|||||||
+69
-23
@@ -24,10 +24,33 @@ const INDEX_FILE = 'index.json';
|
|||||||
|
|
||||||
type PersistedGame = SavedGame & { engineVersion: string };
|
type PersistedGame = SavedGame & { engineVersion: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE WRITER AT A TIME PER FILE (v0.8.4).
|
||||||
|
*
|
||||||
|
* Every write here is read-modify-write or write-then-rename with an `await` in the middle, and
|
||||||
|
* Node's single thread is no protection across an `await`: two requests for the same game could
|
||||||
|
* both be inside `atomicWrite` at once. With ONE fixed `.tmp` name per path that tore the file —
|
||||||
|
* measured at 200 rounds of two concurrent writes: every round lost one write to `rename` ENOENT,
|
||||||
|
* and six left `game.json` as invalid JSON, which the boot then died on. So the temp name is unique
|
||||||
|
* per write, and every write to a given path queues behind the one before it, which is also what
|
||||||
|
* makes `upsertIndexEntry`'s "adds or updates exactly one row" claim true under two callers.
|
||||||
|
*/
|
||||||
|
const queues = new Map<string, Promise<unknown>>();
|
||||||
|
let writeSerial = 0;
|
||||||
|
|
||||||
|
function serial<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = queues.get(key) ?? Promise.resolve();
|
||||||
|
const next = prev.then(fn, fn);
|
||||||
|
queues.set(key, next.catch(() => undefined));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
async function atomicWrite(path: string, text: string): Promise<void> {
|
async function atomicWrite(path: string, text: string): Promise<void> {
|
||||||
const tmp = `${path}.tmp`;
|
await serial(path, async () => {
|
||||||
await writeFile(tmp, text);
|
const tmp = `${path}.${process.pid}.${++writeSerial}.tmp`;
|
||||||
await rename(tmp, path);
|
await writeFile(tmp, text);
|
||||||
|
await rename(tmp, path);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeGame(dataDir: string, saved: SavedGame, engineVersion: string): Promise<void> {
|
export async function writeGame(dataDir: string, saved: SavedGame, engineVersion: string): Promise<void> {
|
||||||
@@ -37,7 +60,8 @@ export async function writeGame(dataDir: string, saved: SavedGame, engineVersion
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type LoadResult =
|
export type LoadResult =
|
||||||
| { found: false }
|
/** `corrupt` names the parse error when the file is there and is not JSON — see `loadGame`. */
|
||||||
|
| { found: false; corrupt?: string }
|
||||||
/** The version that wrote the file, for diagnostics — it is no longer what decides. */
|
/** The version that wrote the file, for diagnostics — it is no longer what decides. */
|
||||||
| { found: true; saved: SavedGame; storedVersion: string };
|
| { found: true; saved: SavedGame; storedVersion: string };
|
||||||
|
|
||||||
@@ -61,7 +85,21 @@ export async function loadGame(dataDir: string): Promise<LoadResult> {
|
|||||||
} catch {
|
} catch {
|
||||||
return { found: false };
|
return { found: false };
|
||||||
}
|
}
|
||||||
const payload = JSON.parse(text) as PersistedGame;
|
/**
|
||||||
|
* A FILE THAT IS NOT JSON IS REPORTED, NOT THROWN (v0.8.4). This parse was bare, and `index.ts`
|
||||||
|
* awaited it at the top level — so one torn or half-edited `game.json` took the whole process
|
||||||
|
* down before the port opened, and StartOS restarted it into the same file: every game on the
|
||||||
|
* server unreachable because of one. The caller gets a reason to log and moves on.
|
||||||
|
*/
|
||||||
|
let payload: PersistedGame;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text) as PersistedGame;
|
||||||
|
} catch (err) {
|
||||||
|
return { found: false, corrupt: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
if (!payload || typeof payload !== 'object' || !Array.isArray(payload.history)) {
|
||||||
|
return { found: false, corrupt: 'not a saved game (no history array)' };
|
||||||
|
}
|
||||||
const { engineVersion, ...saved } = payload;
|
const { engineVersion, ...saved } = payload;
|
||||||
return { found: true, saved, storedVersion: engineVersion };
|
return { found: true, saved, storedVersion: engineVersion };
|
||||||
}
|
}
|
||||||
@@ -71,14 +109,18 @@ export async function loadGame(dataDir: string): Promise<LoadResult> {
|
|||||||
export async function appendTiming(dataDir: string, timing: TurnTiming): Promise<void> {
|
export async function appendTiming(dataDir: string, timing: TurnTiming): Promise<void> {
|
||||||
await mkdir(dataDir, { recursive: true });
|
await mkdir(dataDir, { recursive: true });
|
||||||
const path = join(dataDir, TIMINGS_FILE);
|
const path = join(dataDir, TIMINGS_FILE);
|
||||||
let existing: TurnTiming[];
|
// The read and the write are one unit under the file's queue, or two appends could each read the
|
||||||
try {
|
// same list and one of them would be lost.
|
||||||
existing = JSON.parse(await readFile(path, 'utf8')) as TurnTiming[];
|
await serial(`rmw:${path}`, async () => {
|
||||||
} catch {
|
let existing: TurnTiming[];
|
||||||
existing = [];
|
try {
|
||||||
}
|
existing = JSON.parse(await readFile(path, 'utf8')) as TurnTiming[];
|
||||||
existing.push(timing);
|
} catch {
|
||||||
await atomicWrite(path, JSON.stringify(existing, null, 1));
|
existing = [];
|
||||||
|
}
|
||||||
|
existing.push(timing);
|
||||||
|
await atomicWrite(path, JSON.stringify(existing, null, 1));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -113,11 +155,13 @@ async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise<v
|
|||||||
* silently drop every other game's row the moment two writes happened close together.
|
* silently drop every other game's row the moment two writes happened close together.
|
||||||
*/
|
*/
|
||||||
export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise<void> {
|
export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise<void> {
|
||||||
const entries = await readIndex(dataDir);
|
await serial(`rmw:${join(dataDir, INDEX_FILE)}`, async () => {
|
||||||
const i = entries.findIndex((e) => e.gameId === entry.gameId);
|
const entries = await readIndex(dataDir);
|
||||||
if (i >= 0) entries[i] = entry;
|
const i = entries.findIndex((e) => e.gameId === entry.gameId);
|
||||||
else entries.push(entry);
|
if (i >= 0) entries[i] = entry;
|
||||||
await writeIndex(dataDir, entries);
|
else entries.push(entry);
|
||||||
|
await writeIndex(dataDir, entries);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,11 +170,13 @@ export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry):
|
|||||||
* it on the next boot or leave `index.json` pointing at nothing.
|
* it on the next boot or leave `index.json` pointing at nothing.
|
||||||
*/
|
*/
|
||||||
export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> {
|
export async function removeIndexEntry(dataDir: string, gameId: string): Promise<void> {
|
||||||
const entries = await readIndex(dataDir);
|
await serial(`rmw:${join(dataDir, INDEX_FILE)}`, async () => {
|
||||||
await writeIndex(
|
const entries = await readIndex(dataDir);
|
||||||
dataDir,
|
await writeIndex(
|
||||||
entries.filter((e) => e.gameId !== gameId),
|
dataDir,
|
||||||
);
|
entries.filter((e) => e.gameId !== gameId),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */
|
/** Deletes a game's whole directory — its save, its turn timings, its sessions, its lobby file. */
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ export type Push = {
|
|||||||
scheduled?: number | null;
|
scheduled?: number | null;
|
||||||
announcement?: string | null;
|
announcement?: string | null;
|
||||||
justDrawn?: string | null;
|
justDrawn?: string | null;
|
||||||
|
/**
|
||||||
|
* THE LAST INTENT `seq` THIS SEAT HAD ACCEPTED — on the connect push only (v0.8.4).
|
||||||
|
*
|
||||||
|
* The client numbers its intents from 1 per page load, and this host remembers the seat's last
|
||||||
|
* accepted number for the life of the game and answers a repeat with "already applied" (§5). So
|
||||||
|
* a seat that had made one move, reloaded, and clicked again sent `seq: 1` a second time: the
|
||||||
|
* server said ok and did nothing, the page redrew nothing, and the click looked dead. Telling the
|
||||||
|
* client where the count stands lets it continue from there instead of starting over.
|
||||||
|
*/
|
||||||
|
lastSeq?: number;
|
||||||
/**
|
/**
|
||||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
|
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
|
||||||
*
|
*
|
||||||
@@ -415,6 +425,7 @@ function buildSession(
|
|||||||
// connect IS the history, which is what lets the Frame stop carrying a second copy.
|
// connect IS the history, which is what lets the Frame stop carrying a second copy.
|
||||||
sentLines.delete(seat);
|
sentLines.delete(seat);
|
||||||
const push = pushFor(seat, null);
|
const push = pushFor(seat, null);
|
||||||
|
push.lastSeq = lastSeq.get(seat) ?? 0;
|
||||||
/**
|
/**
|
||||||
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
|
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
|
||||||
* the shared delta chain has reached, so the next step merges onto it; before any step has
|
* the shared delta chain has reached, so the next step merges onto it; before any step has
|
||||||
|
|||||||
+26
-11
@@ -51,10 +51,10 @@ import {
|
|||||||
mainlineProfile,
|
mainlineProfile,
|
||||||
trainProfile,
|
trainProfile,
|
||||||
} from '../engine/content.ts';
|
} from '../engine/content.ts';
|
||||||
import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
|
import type { CarType, Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||||
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
import { acceptsCar, areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame } from '../sim/view.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1007,16 +1007,31 @@ function consistNeeds(game: Game, trayId: string): string | null {
|
|||||||
const have = (k: 'coach' | 'caboose' | 'freight'): number =>
|
const have = (k: 'coach' | 'caboose' | 'freight'): number =>
|
||||||
tray.consist.filter((c) => cat(c.type) === k).length;
|
tray.consist.filter((c) => cat(c.type) === k).length;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ASKED OF `acceptsCar`, NOT RECOUNTED (v0.8.4). Counting by category alone promised cars the
|
||||||
|
* engine would then refuse: with the caboose already coupled, nothing may go on behind it
|
||||||
|
* (§A.3), so "Still needs 2 boxcar" was printed while no yard chip lit and the only offer was
|
||||||
|
* to send the train out as it stands. One sample car per category settles it the same way
|
||||||
|
* `advance.ts`'s make-up report does.
|
||||||
|
*/
|
||||||
|
const sample: Record<'coach' | 'caboose' | 'freight', CarType> = {
|
||||||
|
coach: 'coach',
|
||||||
|
caboose: 'caboose',
|
||||||
|
freight: p.consist.freightTypes?.[0] ?? 'boxcar',
|
||||||
|
};
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const freight = p.consist.freight - have('freight');
|
let blocked = false;
|
||||||
const coach = p.consist.coach - have('coach');
|
const want = (k: 'coach' | 'caboose' | 'freight', label: (n: number) => string): void => {
|
||||||
const caboose = p.consist.caboose - have('caboose');
|
const n = (k === 'coach' ? p.consist.coach : k === 'caboose' ? p.consist.caboose : p.consist.freight) - have(k);
|
||||||
if (freight > 0) {
|
if (n <= 0) return;
|
||||||
parts.push(`${freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
if (acceptsCar(tray, sample[k])) parts.push(label(n));
|
||||||
}
|
else blocked = true;
|
||||||
if (coach > 0) parts.push(`${coach} coach${coach > 1 ? 'es' : ''}`);
|
};
|
||||||
if (caboose > 0) parts.push(`${caboose} caboose`);
|
want('freight', (n) => `${n} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
||||||
return parts.length > 0 ? parts.join(' + ') : null;
|
want('coach', (n) => `${n} coach${n > 1 ? 'es' : ''}`);
|
||||||
|
want('caboose', (n) => `${n} caboose`);
|
||||||
|
if (parts.length > 0) return parts.join(' + ');
|
||||||
|
return blocked ? 'nothing more — the caboose is on, and nothing couples behind it' : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+49
-10
@@ -282,6 +282,8 @@ type Push = {
|
|||||||
scheduled?: number | null;
|
scheduled?: number | null;
|
||||||
announcement?: string | null;
|
announcement?: string | null;
|
||||||
justDrawn?: string | null;
|
justDrawn?: string | null;
|
||||||
|
/** Where the server's count of this seat's accepted intents stands — connect push only (v0.8.4). */
|
||||||
|
lastSeq?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -322,7 +324,19 @@ export function createRemoteSession(
|
|||||||
let scheduled: number | null = null;
|
let scheduled: number | null = null;
|
||||||
let announcement: string | null = null;
|
let announcement: string | null = null;
|
||||||
let justDrawnCard: string | null = null;
|
let justDrawnCard: string | null = null;
|
||||||
|
/**
|
||||||
|
* NUMBERED FROM WHERE THE SERVER SAYS, NOT FROM 1 (v0.8.4).
|
||||||
|
*
|
||||||
|
* This started at 1 on every page load, and the server remembers a seat's last accepted number
|
||||||
|
* for the life of the game and answers a repeat with "already applied" (`protocol.md` §5). So a
|
||||||
|
* seat that had made one move, reloaded, and clicked again sent `seq: 1` twice: the server said
|
||||||
|
* ok, did nothing, pushed nothing, and the click looked dead. The connect push now carries the
|
||||||
|
* server's count and this continues from it — never backwards, in case a submit is in flight
|
||||||
|
* across a reconnect.
|
||||||
|
*/
|
||||||
let nextSeq = 1;
|
let nextSeq = 1;
|
||||||
|
/** One submit in flight at a time — see `submit`. */
|
||||||
|
let inFlight = false;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
const changed = (): void => {
|
const changed = (): void => {
|
||||||
for (const fn of [...listeners]) fn();
|
for (const fn of [...listeners]) fn();
|
||||||
@@ -355,7 +369,15 @@ export function createRemoteSession(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
source.onmessage = (ev: MessageEvent<string>) => {
|
source.onmessage = (ev: MessageEvent<string>) => {
|
||||||
const push = JSON.parse(ev.data) as Push;
|
let push: Push;
|
||||||
|
try {
|
||||||
|
push = JSON.parse(ev.data) as Push;
|
||||||
|
} catch {
|
||||||
|
// A push that is not JSON is dropped rather than thrown out of an event handler nothing
|
||||||
|
// catches; the next push carries a full delta chain from what this seat was last sent.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (push.lastSeq !== undefined) nextSeq = Math.max(nextSeq, push.lastSeq + 1);
|
||||||
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
|
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
|
||||||
// seat's turn — only a push that actually came from the game (always carries a real `frame`,
|
// seat's turn — only a push that actually came from the game (always carries a real `frame`,
|
||||||
// per `session.ts`'s `Push`) updates the board or the menu.
|
// per `session.ts`'s `Push`) updates the board or the menu.
|
||||||
@@ -402,16 +424,33 @@ export function createRemoteSession(
|
|||||||
actor: () => need().actor,
|
actor: () => need().actor,
|
||||||
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
||||||
async submit(intent: Intent): Promise<boolean> {
|
async submit(intent: Intent): Promise<boolean> {
|
||||||
|
/**
|
||||||
|
* ONE AT A TIME (v0.8.4). The page redraws the SAME menu the instant a submit is sent — the
|
||||||
|
* new one only arrives with the push — so a second click before the round trip posted a
|
||||||
|
* second, fresh `seq` for the same option, and the server, which de-duplicates on `seq`
|
||||||
|
* alone, applied it again when it was still legal: two cards drawn, two Moves spent, two cars
|
||||||
|
* coupled. A click that lands while one is in flight is dropped; the push is milliseconds away.
|
||||||
|
*/
|
||||||
|
if (inFlight) return false;
|
||||||
|
inFlight = true;
|
||||||
const seq = nextSeq++;
|
const seq = nextSeq++;
|
||||||
const res = await fetch(`/api/intent?${qs}`, {
|
try {
|
||||||
method: 'POST',
|
const res = await fetch(`/api/intent?${qs}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({ seq, intent }),
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
body: JSON.stringify({ seq, intent }),
|
||||||
const result = (await res.json()) as { ok: boolean; code?: string };
|
});
|
||||||
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
|
const result = (await res.json()) as { ok?: boolean; code?: string };
|
||||||
// not from this response — this only reports whether the rules accepted it.
|
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
|
||||||
return result.ok;
|
// not from this response — this only reports whether the rules accepted it.
|
||||||
|
return result.ok === true;
|
||||||
|
} catch {
|
||||||
|
// A network failure or a non-JSON answer used to reject out of a `void`ed promise — an
|
||||||
|
// unhandled rejection and nothing on screen. False is honest: the move was not confirmed.
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
subscribe(fn: () => void) {
|
subscribe(fn: () => void) {
|
||||||
listeners.add(fn);
|
listeners.add(fn);
|
||||||
|
|||||||
@@ -110,4 +110,19 @@ describe('the documentation renderer', () => {
|
|||||||
assert.ok(!/BEGIN CARDS/.test(out), `${name}.md leaked a build marker onto the page`);
|
assert.ok(!/BEGIN CARDS/.test(out), `${name}.md leaked a build marker onto the page`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('nests a more-indented bullet as a list inside its item (v0.8.4)', () => {
|
||||||
|
/**
|
||||||
|
* The code said "nesting is rendered by recursion" and appended the nested bullet to the parent
|
||||||
|
* as text, so the published home-deck page read "…knows. - ABS Signals is the exception…" with
|
||||||
|
* a literal dash mid-sentence.
|
||||||
|
*/
|
||||||
|
const out = html(['- parent', ' continues here.', ' - child one', ' wraps too', ' - child two', '- second'].join('\n'));
|
||||||
|
assert.equal(
|
||||||
|
out.trim(),
|
||||||
|
'<ul><li>parent continues here.<ul><li>child one wraps too</li><li>child two</li></ul></li><li>second</li></ul>',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(html(readFileSync(join(import.meta.dirname, '..', 'docs', 'home-deck.md'), 'utf8')), /\. - <strong>/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* The browser's half of the multiplayer transport, driven with a fake `EventSource` and `fetch`.
|
||||||
|
* `createRemoteSession` is pure otherwise — no DOM — so it runs here as it does in the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { createRemoteSession } from '../src/web/session.ts';
|
||||||
|
|
||||||
|
type Fake = { onmessage: ((ev: { data: string }) => void) | null; emit(data: unknown): void; close(): void };
|
||||||
|
|
||||||
|
function fakeTransport(): { source: () => Fake; bodies: () => { seq: number }[]; fail: (on: boolean) => void } {
|
||||||
|
let last: Fake | null = null;
|
||||||
|
const bodies: { seq: number }[] = [];
|
||||||
|
let failing = false;
|
||||||
|
const g = globalThis as unknown as Record<string, unknown>;
|
||||||
|
g['EventSource'] = class {
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
constructor() {
|
||||||
|
last = this;
|
||||||
|
}
|
||||||
|
emit(data: unknown): void {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
close(): void {}
|
||||||
|
};
|
||||||
|
g['fetch'] = async (_url: string, init?: { body?: string }) => {
|
||||||
|
if (init?.body) bodies.push(JSON.parse(init.body) as { seq: number });
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
if (failing) throw new Error('network down');
|
||||||
|
return { ok: true, status: 200, json: async () => ({ ok: true }) };
|
||||||
|
};
|
||||||
|
return { source: () => last!, bodies: () => bodies, fail: (on) => (failing = on) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectPush = (lastSeq: number): unknown => ({ frame: null, menu: null, lines: [], lastSeq });
|
||||||
|
|
||||||
|
describe('the remote session (v0.8.4)', () => {
|
||||||
|
it('continues the intent count from where the server says, not from 1', async () => {
|
||||||
|
const t = fakeTransport();
|
||||||
|
const s = createRemoteSession('tok', 0);
|
||||||
|
t.source().emit(connectPush(7));
|
||||||
|
await s.submit({ type: 'draw.end' });
|
||||||
|
assert.deepEqual(t.bodies().map((b) => b.seq), [8], 'the first intent after a reload re-used a number the server had already applied');
|
||||||
|
// A later reconnect never moves the count backwards.
|
||||||
|
t.source().emit(connectPush(3));
|
||||||
|
await s.submit({ type: 'draw.end' });
|
||||||
|
assert.deepEqual(t.bodies().map((b) => b.seq), [8, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a second submit while the first is still in flight', async () => {
|
||||||
|
const t = fakeTransport();
|
||||||
|
const s = createRemoteSession('tok', 0);
|
||||||
|
t.source().emit(connectPush(0));
|
||||||
|
const [a, b] = await Promise.all([s.submit({ type: 'draw.end' }), s.submit({ type: 'draw.end' })]);
|
||||||
|
assert.equal(a, true);
|
||||||
|
assert.equal(b, false, 'a double-click posted twice');
|
||||||
|
assert.equal(t.bodies().length, 1, 'two intents went over the wire for one click');
|
||||||
|
// And the next one, after the round trip, goes through as normal.
|
||||||
|
assert.equal(await s.submit({ type: 'draw.end' }), true);
|
||||||
|
assert.equal(t.bodies().length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers false, not an unhandled rejection, when the network fails', async () => {
|
||||||
|
const t = fakeTransport();
|
||||||
|
const s = createRemoteSession('tok', 0);
|
||||||
|
t.source().emit(connectPush(0));
|
||||||
|
t.fail(true);
|
||||||
|
assert.equal(await s.submit({ type: 'draw.end' }), false);
|
||||||
|
t.fail(false);
|
||||||
|
assert.equal(await s.submit({ type: 'draw.end' }), true, 'the session did not recover after a failed submit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a push that is not JSON instead of throwing out of the handler', () => {
|
||||||
|
const t = fakeTransport();
|
||||||
|
createRemoteSession('tok', 0);
|
||||||
|
assert.doesNotThrow(() => t.source().onmessage?.({ data: '{not json' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* The HTTP layer, driven end to end over a real socket. `startServer` binds port 0 on a temp data
|
||||||
|
* directory; nothing here reads the built site, so `distDir` is a directory with nothing in it.
|
||||||
|
*
|
||||||
|
* Added in v0.8.4, when four faults in `http.ts` turned out to be uncovered because no test had ever
|
||||||
|
* stood the server up: a leaver's token surviving the leave, an unbounded body, a torn save under
|
||||||
|
* concurrent moves, and a crash on an error after the SSE head was sent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, after, before } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
|
||||||
|
import type { GameConfig } from '../../src/engine/state.ts';
|
||||||
|
import { startServer } from '../../src/server/http.ts';
|
||||||
|
|
||||||
|
const SECRET = 'test-secret';
|
||||||
|
const config: GameConfig = {
|
||||||
|
mode: 'competitive',
|
||||||
|
days: 5,
|
||||||
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
houseRules: { startingOffice: 'whistlePost' },
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: Server;
|
||||||
|
let base = '';
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
dataDir = await mkdtemp(join(tmpdir(), 'station-master-http-'));
|
||||||
|
server = startServer({
|
||||||
|
port: 0,
|
||||||
|
bindAddress: '127.0.0.1',
|
||||||
|
joinSecret: SECRET,
|
||||||
|
distDir: dataDir,
|
||||||
|
dataDir,
|
||||||
|
engineVersion: 'test',
|
||||||
|
initialGames: new Map(),
|
||||||
|
initialLobbies: new Map(),
|
||||||
|
initialSessions: new Map(),
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||||
|
const addr = server.address();
|
||||||
|
if (!addr || typeof addr === 'string') throw new Error('no port');
|
||||||
|
base = `http://127.0.0.1:${addr.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
// A write queued behind the last move may still be landing; retry rather than race it.
|
||||||
|
await rm(dataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = async (path: string, body: unknown): Promise<{ status: number; json: Record<string, unknown> }> => {
|
||||||
|
const res = await fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
return { status: res.status, json: (await res.json()) as Record<string, unknown> };
|
||||||
|
};
|
||||||
|
const get = async (path: string): Promise<number> => (await fetch(base + path)).status;
|
||||||
|
|
||||||
|
/** The first SSE message on a stream, then the stream is dropped. */
|
||||||
|
async function firstPush(path: string): Promise<Record<string, unknown>> {
|
||||||
|
const res = await fetch(base + path);
|
||||||
|
assert.equal(res.status, 200, `${path} answered ${res.status}`);
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) throw new Error('stream ended before a push');
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const m = /data: (.*)\n\n/.exec(buffer);
|
||||||
|
if (m) {
|
||||||
|
await reader.cancel();
|
||||||
|
return JSON.parse(m[1]!) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Seat = { token: string; player: number; gameId: string; gameCode: string };
|
||||||
|
|
||||||
|
async function table(): Promise<{ host: Seat; guest: Seat }> {
|
||||||
|
const created = await post('/api/lobby/create', { secret: SECRET, config, displayName: 'Host', players: 2 });
|
||||||
|
assert.equal(created.status, 200, JSON.stringify(created.json));
|
||||||
|
const host = created.json as unknown as Seat;
|
||||||
|
const joined = await post('/api/lobby/join', { secret: SECRET, gameCode: host.gameCode, displayName: 'Guest' });
|
||||||
|
assert.equal(joined.status, 200, JSON.stringify(joined.json));
|
||||||
|
return { host, guest: joined.json as unknown as Seat };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the HTTP layer (v0.8.4)', () => {
|
||||||
|
it('revokes the token of a player who leaves, so it cannot play the seat the next arrival takes', async () => {
|
||||||
|
const { host, guest } = await table();
|
||||||
|
const left = await post('/api/lobby/leave', { token: guest.token });
|
||||||
|
assert.equal(left.status, 200);
|
||||||
|
// The leaver's token is dead at once — for the lobby and for the game that follows.
|
||||||
|
assert.equal(await get(`/api/lobby/stream?token=${guest.token}`), 404, 'a leaver can still watch the lobby');
|
||||||
|
const again = await post('/api/lobby/join', { secret: SECRET, gameCode: host.gameCode, displayName: 'Newcomer' });
|
||||||
|
assert.equal(again.status, 200);
|
||||||
|
assert.equal(again.json['player'], guest.player, 'the vacated chair was not the one re-offered');
|
||||||
|
const started = await post('/api/lobby/start', { token: host.token });
|
||||||
|
assert.equal(started.status, 200, JSON.stringify(started.json));
|
||||||
|
assert.equal(await get(`/api/session?token=${guest.token}`), 404, 'the leaver still holds a seat in the running game');
|
||||||
|
assert.equal(await get(`/api/stream?token=${guest.token}`), 404, "the leaver can read the newcomer's stream");
|
||||||
|
const move = await post(`/api/intent?token=${guest.token}`, { seq: 1, intent: { type: 'localOps.choose', option: 'draw' } });
|
||||||
|
assert.equal(move.status, 404, 'the leaver can move for the newcomer');
|
||||||
|
// And the newcomer's own token works.
|
||||||
|
assert.equal(await get(`/api/session?token=${again.json['token'] as string}`), 200);
|
||||||
|
// On disk too, so a restart does not hand the seat back.
|
||||||
|
const onDisk = JSON.parse(await readFile(join(dataDir, 'games', host.gameId, 'sessions.json'), 'utf8')) as { token: string }[];
|
||||||
|
assert.ok(!onDisk.some((s) => s.token === guest.token), 'the revoked token is still in sessions.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the host remove a player, revoking that token the same way', async () => {
|
||||||
|
const { host, guest } = await table();
|
||||||
|
const removed = await post('/api/lobby/leave', { token: host.token, seat: guest.player });
|
||||||
|
assert.equal(removed.status, 200, JSON.stringify(removed.json));
|
||||||
|
assert.equal(await get(`/api/lobby/stream?token=${guest.token}`), 404);
|
||||||
|
assert.equal(await get(`/api/lobby/stream?token=${host.token}`), 200, 'the host lost their own seat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an oversized body before reading it, and a malformed one with 400', async () => {
|
||||||
|
const big = await fetch(base + '/api/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: 'x'.repeat(200_000) }),
|
||||||
|
});
|
||||||
|
assert.equal(big.status, 413);
|
||||||
|
const bad = await fetch(base + '/api/lobby/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{not json' });
|
||||||
|
assert.equal(bad.status, 400);
|
||||||
|
const notObject = await fetch(base + '/api/lobby/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'null' });
|
||||||
|
assert.equal(notObject.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells a connecting seat where its intent count stands', async () => {
|
||||||
|
const { host, guest } = await table();
|
||||||
|
assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200);
|
||||||
|
const hostPush = await firstPush(`/api/stream?token=${host.token}`);
|
||||||
|
const actor = hostPush['menu'] !== null ? host : guest;
|
||||||
|
assert.equal(hostPush['lastSeq'], 0);
|
||||||
|
const move = await post(`/api/intent?token=${actor.token}`, { seq: 1, intent: { type: 'localOps.choose', option: 'draw' } });
|
||||||
|
assert.deepEqual(move.json, { ok: true });
|
||||||
|
const reconnect = await firstPush(`/api/stream?token=${actor.token}`);
|
||||||
|
assert.equal(reconnect['lastSeq'], 1, 'the reconnect push does not carry the count');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a burst of concurrent moves one at a time and leaves the save readable', async () => {
|
||||||
|
const { host, guest } = await table();
|
||||||
|
assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200);
|
||||||
|
const hostPush = await firstPush(`/api/stream?token=${host.token}`);
|
||||||
|
const actor = hostPush['menu'] !== null ? host : guest;
|
||||||
|
// Three moves that are legal only in this order, fired together.
|
||||||
|
const intents = [
|
||||||
|
{ type: 'localOps.choose', option: 'draw' },
|
||||||
|
{ type: 'draw.fromHomeOffice' },
|
||||||
|
{ type: 'draw.end' },
|
||||||
|
];
|
||||||
|
const results = await Promise.all(intents.map((intent, i) => post(`/api/intent?token=${actor.token}`, { seq: i + 1, intent })));
|
||||||
|
assert.ok(results.every((r) => r.status === 200), 'a concurrent move was answered with an error');
|
||||||
|
const save = JSON.parse(await readFile(join(dataDir, 'games', host.gameId, 'game.json'), 'utf8')) as { history: unknown[] };
|
||||||
|
assert.ok(save.history.length >= 1, 'no move reached the save');
|
||||||
|
assert.equal(save.history.length, results.filter((r) => r.json['ok'] === true).length, 'the save and the answers disagree');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,8 @@ import { join } from 'node:path';
|
|||||||
|
|
||||||
import type { GameConfig } from '../../src/engine/state.ts';
|
import type { GameConfig } from '../../src/engine/state.ts';
|
||||||
import type { SavedGame } from '../../src/server/session.ts';
|
import type { SavedGame } from '../../src/server/session.ts';
|
||||||
import { appendTiming, loadGame, writeGame } from '../../src/server/persistence.ts';
|
import { appendTiming, loadGame, readIndex, upsertIndexEntry, writeGame } from '../../src/server/persistence.ts';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'competitive',
|
mode: 'competitive',
|
||||||
@@ -101,4 +102,57 @@ describe('game persistence (Phase 3)', () => {
|
|||||||
const timings = JSON.parse(text) as unknown[];
|
const timings = JSON.parse(text) as unknown[];
|
||||||
assert.equal(timings.length, 2);
|
assert.equal(timings.length, 2);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// -- v0.8.4: concurrent writers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
it('two hundred concurrent writes to one save leave it valid and never throw (v0.8.4)', () =>
|
||||||
|
withTempDir(async (dir) => {
|
||||||
|
/**
|
||||||
|
* One fixed `.tmp` per path, and no queue: measured at 200 rounds of two concurrent writes,
|
||||||
|
* every round lost one to `rename` ENOENT and six left the file as invalid JSON. Boot then
|
||||||
|
* died on it. Unique temp names and a per-path queue are the fix; this is the measurement.
|
||||||
|
*/
|
||||||
|
const writes: Promise<void>[] = [];
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const grown: SavedGame = { ...saved, history: Array.from({ length: i + 1 }, () => ({ type: 'draw.end' })) };
|
||||||
|
writes.push(writeGame(dir, grown, '1.2.3'));
|
||||||
|
}
|
||||||
|
await Promise.all(writes);
|
||||||
|
const result = await loadGame(dir);
|
||||||
|
assert.equal(result.found, true, 'the save is unreadable after concurrent writes');
|
||||||
|
if (result.found) assert.equal(result.saved.history.length, 200, 'the last write did not win');
|
||||||
|
await assert.rejects(() => readFile(join(dir, 'game.json.tmp')));
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('two concurrent index upserts both land (v0.8.4)', () =>
|
||||||
|
withTempDir(async (dir) => {
|
||||||
|
await Promise.all([
|
||||||
|
upsertIndexEntry(dir, { gameId: 'a', gameCode: 'AAA-1', status: 'active' }),
|
||||||
|
upsertIndexEntry(dir, { gameId: 'b', gameCode: 'BBB-2', status: 'lobby' }),
|
||||||
|
]);
|
||||||
|
const rows = await readIndex(dir);
|
||||||
|
assert.deepEqual(rows.map((r) => r.gameId).sort(), ['a', 'b'], 'a concurrent upsert lost a row');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('two concurrent timing appends both land (v0.8.4)', () =>
|
||||||
|
withTempDir(async (dir) => {
|
||||||
|
await Promise.all([
|
||||||
|
appendTiming(dir, { player: 0, phase: 'localOps', day: 1, stage: 1, startedAt: 1, endedAt: 2 }),
|
||||||
|
appendTiming(dir, { player: 1, phase: 'localOps', day: 1, stage: 1, startedAt: 2, endedAt: 3 }),
|
||||||
|
]);
|
||||||
|
const timings = JSON.parse(await readFile(join(dir, 'turn-timings.json'), 'utf8')) as unknown[];
|
||||||
|
assert.equal(timings.length, 2);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('reports a save that is not JSON instead of throwing (v0.8.4)', () =>
|
||||||
|
withTempDir(async (dir) => {
|
||||||
|
await writeFile(join(dir, 'game.json'), '{"seed": 42, "hist');
|
||||||
|
const result = await loadGame(dir);
|
||||||
|
assert.equal(result.found, false);
|
||||||
|
if (!result.found) assert.ok(result.corrupt, 'a torn file was reported as merely missing');
|
||||||
|
await writeFile(join(dir, 'game.json'), '{"seed": 42}');
|
||||||
|
const shape = await loadGame(dir);
|
||||||
|
assert.equal(shape.found, false);
|
||||||
|
if (!shape.found) assert.match(shape.corrupt ?? '', /history/);
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -611,3 +611,24 @@ describe('narration reaches a seat exactly once, by one path (#97)', () => {
|
|||||||
assert.deepEqual(third.lines, opening.lines, 'a reconnect is the full log, every time');
|
assert.deepEqual(third.lines, opening.lines, 'a reconnect is the full log, every time');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the intent sequence across a reconnect (v0.8.4)', () => {
|
||||||
|
it('tells a connecting seat the last seq it had accepted, so a reloaded page continues the count', () => {
|
||||||
|
const session = createSession(42, config, ['Alice', 'Bob']);
|
||||||
|
const first = session.connect(0 as PlayerIndex);
|
||||||
|
assert.equal(first.lastSeq, 0, 'a seat that has moved nothing should be told 0');
|
||||||
|
const actor = (first.menu !== null ? 0 : 1) as PlayerIndex;
|
||||||
|
const r = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
|
||||||
|
assert.ok(r.accepted);
|
||||||
|
// A reload: the client starts its own count from 1 again unless told otherwise.
|
||||||
|
const again = session.connect(actor);
|
||||||
|
assert.equal(again.lastSeq, 1, 'the reconnect push does not say where the count stands');
|
||||||
|
// The repeat the old client would have sent — silently swallowed as a resend.
|
||||||
|
const repeat = session.intent(actor, 1, { type: 'draw.fromHomeOffice' });
|
||||||
|
assert.ok(repeat.accepted && repeat.pushes.size === 0, 'seq 1 should still read as an idempotent resend');
|
||||||
|
// Continuing from lastSeq + 1 is a real move.
|
||||||
|
const next = session.intent(actor, 2, { type: 'draw.fromHomeOffice' });
|
||||||
|
assert.ok(next.accepted && next.pushes.size > 0, 'seq 2 was not applied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
+39
-2
@@ -8,7 +8,7 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { turnOf } from '../src/engine/state.ts';
|
import { turnOf } from '../src/engine/state.ts';
|
||||||
import { areaOf, check } from '../src/engine/apply.ts';
|
import { acceptsCar as acceptsCarOf, areaOf, check } from '../src/engine/apply.ts';
|
||||||
import type { Game } from '../src/web/game.ts';
|
import type { Game } from '../src/web/game.ts';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
@@ -22,7 +22,7 @@ import { cardDescription, cardName, describeIntent, variantLabel } from '../src/
|
|||||||
import { variantsFor } from '../src/engine/track.ts';
|
import { variantsFor } from '../src/engine/track.ts';
|
||||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
import { BOARD_CSS, divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
import type { DivisionView } from '../src/sim/view.ts';
|
import type { DivisionView } from '../src/sim/view.ts';
|
||||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile } from '../src/engine/content.ts';
|
import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile, trainProfile } from '../src/engine/content.ts';
|
||||||
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||||
import { TURNCHART_CSS, turnChartHtml } from '../src/sim/turnchart.ts';
|
import { TURNCHART_CSS, turnChartHtml } from '../src/sim/turnchart.ts';
|
||||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||||
@@ -2459,6 +2459,43 @@ describe('the static build', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never lists a car in "still needs" that the engine would refuse (v0.8.4)', () => {
|
||||||
|
/**
|
||||||
|
* `consistNeeds` counted by category on its own and could promise cars `acceptsCar` refuses —
|
||||||
|
* nothing couples behind a caboose (§A.3), and a card narrows which freight it takes. It asks
|
||||||
|
* `acceptsCar` per category now, so this holds every line of the panel to the engine's answer,
|
||||||
|
* and the reverse: a category the engine still takes is never left off.
|
||||||
|
*/
|
||||||
|
let panels = 0;
|
||||||
|
for (const seed of [430, 99, 270861860]) {
|
||||||
|
const game = newGame(seed);
|
||||||
|
for (let i = 0; i < 600 && currentActor(game) !== null; i++) {
|
||||||
|
const menu = actionMenu(game);
|
||||||
|
if (menu.makeUp) {
|
||||||
|
const tray = game.state.trays.get(menu.makeUp.trayId)!;
|
||||||
|
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)!;
|
||||||
|
const needs = menu.makeUp.needs ?? '';
|
||||||
|
const categories = [
|
||||||
|
{ name: 'freight', sample: profile.consist.freightTypes?.[0] ?? 'boxcar', re: /boxcar|hopper|reefer|tank|freight/ },
|
||||||
|
{ name: 'coach', sample: 'coach', re: /coach/ },
|
||||||
|
{ name: 'caboose', sample: 'caboose', re: /\d caboose/ },
|
||||||
|
] as const;
|
||||||
|
for (const c of categories) {
|
||||||
|
const listed = c.re.test(needs);
|
||||||
|
const takes = acceptsCarOf(tray, c.sample);
|
||||||
|
if (listed) assert.ok(takes, `seed ${seed}: the panel lists ${c.name} the engine refuses: "${needs}"`);
|
||||||
|
const wanted = (c.name === 'coach' ? profile.consist.coach : c.name === 'caboose' ? profile.consist.caboose : profile.consist.freight) > tray.consist.filter((x) => (x.type === 'coach' ? 'coach' : x.type === 'caboose' ? 'caboose' : 'freight') === c.name).length;
|
||||||
|
if (takes && wanted) assert.ok(listed, `seed ${seed}: the engine still takes a ${c.name} the panel does not list: "${needs}"`);
|
||||||
|
}
|
||||||
|
panels++;
|
||||||
|
}
|
||||||
|
const { options } = actionGroups(game);
|
||||||
|
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(panels > 0, 'no seed reached a train being made up');
|
||||||
|
});
|
||||||
|
|
||||||
it('keys each make-up car to the yard chip that shows it', () => {
|
it('keys each make-up car to the yard chip that shows it', () => {
|
||||||
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
|
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
|
||||||
// exactly those cars by type and load state. The yard is the surface.
|
// exactly those cars by type and load state. The yard is the surface.
|
||||||
|
|||||||
Reference in New Issue
Block a user