v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted

Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary).
This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per
docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed
cards, StartOS packaging) are still ahead.

Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing
Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling
submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add
POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/.
src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found
and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server
computing every connected seat's Menu would have handed the acting player's legal moves to a
waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a
rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and
test/redaction.test.ts. Not verified: an actual browser (none available in this environment).

Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then-
rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing
it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment
there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified
live: server killed and restarted mid-game, both seats reconnected exactly where they left off.

Two rules bugs found while building this: the New Train phase never implemented its car-placement
round (every car of every train was placed by the Superintendent alone, in every mode, all along —
now reads the round position off tray.consist.length); and victory conditions are now one shared,
configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and
a dead firstToTarget condition.

Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching
train's crew badge failing to draw once it left the Office square, an unload that always took the
westmost car regardless of which was picked, and a legal decision that could render with zero
buttons.

docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json)
included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated
side-project work, not part of this release. 635 tests, 0 failures.
This commit is contained in:
Jesse
2026-08-20 23:50:38 -04:00
parent f9c4d9fa92
commit c3c5cbfeec
52 changed files with 5282 additions and 420 deletions
+70 -7
View File
@@ -474,6 +474,65 @@ export function officeSvg(
return out;
};
/**
* A 45° LEG, drawn as a smooth curve rather than two straight segments meeting at a hard corner.
*
* It is an EASEMENT, not an arbitrary curve: tangent to horizontal at `p0` (the east or west edge),
* so an abutting straight card's through-rail still reads as one unbroken line, and tangent to
* exactly 45° at `p3` (the north or south edge), so two stacked curves still read as one continuous
* diagonal and the edge-crossing angle the matching rule depends on is unchanged. `p1`/`p2` are
* cubic-Bezier control points chosen to hit those two tangents — see the call site.
*
* Sampled as a short polyline rather than left as one SVG path command, because the two parallel
* rails and the tie marks all need points evenly spaced ALONG THE CURVE with the local tangent at
* each one — `rail()`'s straight-line version does the same sampling, just on a line.
*/
const curvedRail = (
p0: { x: number; y: number },
p1: { x: number; y: number },
p2: { x: number; y: number },
p3: { x: number; y: number },
): string => {
const at = (t: number): { x: number; y: number } => {
const u = 1 - t;
return {
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
};
};
const tangentAt = (t: number): { x: number; y: number } => {
const u = 1 - t;
return {
x: 3 * u * u * (p1.x - p0.x) + 6 * u * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x),
y: 3 * u * u * (p1.y - p0.y) + 6 * u * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y),
};
};
const N = 24;
const left: string[] = [];
const right: string[] = [];
let ties = '';
for (let i = 0; i <= N; i++) {
const t = i / N;
const pt = at(t);
const tan = tangentAt(t);
const tlen = Math.hypot(tan.x, tan.y) || 1;
const nx = (-tan.y / tlen) * 2.5;
const ny = (tan.x / tlen) * 2.5;
left.push(`${pt.x + nx} ${pt.y + ny}`);
right.push(`${pt.x - nx} ${pt.y - ny}`);
// Every third sample — the same rough 9px spacing `rail()` uses for a straight run of similar
// length, not tied to `N` itself.
if (i % 3 === 0) {
ties += `<line class="bs-tie" x1="${pt.x + nx * 1.8}" y1="${pt.y + ny * 1.8}" x2="${pt.x - nx * 1.8}" y2="${pt.y - ny * 1.8}"/>`;
}
}
return (
`<path class="bs-rail" fill="none" d="M${left.join(' L')}"/>` +
`<path class="bs-rail" fill="none" d="M${right.join(' L')}"/>` +
ties
);
};
// Where each port meets the card edge. East and west sit at the rail height — the card's middle —
// so a straight run stays straight across the whole row; north and south are centred on the edge.
const port = (p: string): { x: number; y: number } => {
@@ -534,19 +593,23 @@ export function officeSvg(
out += rail(port(from).x, port(from).y, port(to).x, port(to).y);
} else {
/**
* A 45° LEG, drawn as the card prints it: along the centre line from the east or west edge
* to the FROG, then out at exactly 45° through the middle of the north or south edge.
* A 45° LEG — drawn as a smooth curve (v0.5.0) that still passes through the same FROG the
* old two-segment version bent at, so the underlying geometry (and the `frog` position used
* elsewhere for the actual joining/matching rules) is unchanged; only the picture is.
*
* The frog is `H/2` from the centre because a 45° run climbing half the card's height
* travels half its height sideways. This used to be a fixed elbow at (W/2, RAIL+(H-RAIL)/2)
* — below the rail on the assumption everything diverged downward — which drew a leg
* reaching north as a hook that dropped past the rail and came back up, and drew nothing at
* any angle the matching rule cares about.
* travels half its height sideways. The control points sit halfway from each endpoint to the
* frog, which is what gives the curve a horizontal tangent at the e/w edge (matching an
* abutting straight card's through-rail) and an exact 45° tangent at the n/s edge (matching
* the card above or below) — see `curvedRail`.
*/
const side = vertical === from ? to : from;
const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL };
const start = port(side);
const edge = port(vertical);
out += rail(port(side).x, port(side).y, frog.x, frog.y) + rail(frog.x, frog.y, edge.x, edge.y);
const c1 = { x: start.x + 0.5 * (frog.x - start.x), y: start.y };
const c2 = { x: edge.x + 0.5 * (frog.x - edge.x), y: edge.y + 0.5 * (frog.y - edge.y) };
out += curvedRail(start, c1, c2, edge);
}
}