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
+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.