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:
Jesse.Markowitz
2026-09-29 17:02:32 -04:00
co-authored by Claude Fable 5.1
parent 4d222a7eba
commit e47cd3d400
22 changed files with 813 additions and 101 deletions
+15
View File
@@ -110,4 +110,19 @@ describe('the documentation renderer', () => {
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>/);
});
});
+81
View File
@@ -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' }));
});
});
+171
View File
@@ -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');
});
});
+55 -1
View File
@@ -6,7 +6,8 @@ import { join } from 'node:path';
import type { GameConfig } from '../../src/engine/state.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 = {
mode: 'competitive',
@@ -101,4 +102,57 @@ describe('game persistence (Phase 3)', () => {
const timings = JSON.parse(text) as unknown[];
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/);
}));
});
+21
View File
@@ -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');
});
});
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
View File
@@ -8,7 +8,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
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 { execFileSync } from 'node:child_process';
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 { BOARD_CSS, divisionSvg, officeSvg } from '../src/sim/board-svg.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 { TURNCHART_CSS, turnChartHtml } from '../src/sim/turnchart.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', () => {
// 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.