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);
}
}
+24 -11
View File
@@ -31,6 +31,12 @@
import { pump } from '../engine/advance.ts';
import { createGame } from '../engine/setup.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
collectiveRevenueFloor,
lengthProfile,
} from '../engine/content.ts';
import type { GameLength } from '../engine/content.ts';
import type { GameConfig, GameMode } from '../engine/state.ts';
import type { BotPolicy, BotTweaks } from './bot.ts';
@@ -56,17 +62,24 @@ export type PairedResult = {
variantStats: GameStats[];
};
const SOLO = (length: GameLength, mode: GameMode): GameConfig => ({
mode,
victory: 'highestAfterDays',
length,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
});
// Always one bot (`runOne` below), so the revenue floor is `collectiveRevenueFloor(1, days)`.
const SOLO = (length: GameLength, mode: GameMode): GameConfig => {
const days = lengthProfile(length).days;
return {
mode,
days,
minCombinedRevenue: collectiveRevenueFloor(1, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
};
/**
* One game, one seed, one policy.
+53
View File
@@ -0,0 +1,53 @@
/**
* Live per-seat Frame delta — Phase 2 of `docs/architecture/multiplayer.md`.
*
* `src/sim/replay.ts`'s `compress()` looked like the thing to reuse here (D2/D3's "2.9 KB per push
* with the board omitted when unchanged" cites it) but it solves a different problem: it interns
* card/description strings across a WHOLE recorded array of frames, which only pays off when
* bundling many frames into one replay file. A live server pushes one frame at a time and has
* nothing to intern against. The part that genuinely carries over is much smaller — a one-step-back
* "null if unchanged since the last thing sent to THIS seat" check on the three fields that make up
* almost all of a Frame's size: `cells`, `facilities`, `division` (`replay.ts`'s own `keys` array).
*
* Node-free by design, unlike `replay.ts` (which imports `node:fs`) — both the server and a browser
* `RemoteSession` import this file directly.
*/
import type { CellView, DivisionView, FacilityView, Frame } from './view.ts';
/** A `Frame` with the three board-shaped fields replaced by `null` where unchanged since `previous`. */
export type FrameDelta = Omit<Frame, 'cells' | 'facilities' | 'division'> & {
cells: CellView[] | null;
facilities: FacilityView[] | null;
division: DivisionView[] | null;
};
const BOARD_KEYS = ['cells', 'facilities', 'division'] as const;
/**
* `previous` is the last Frame actually sent to THIS seat, or `null` for a first connect / a
* reconnect after a gap — Phase 2 has no persistence to replay a gap against (Phase 3), so a
* reconnect always gets a full Frame here rather than a delta.
*/
export function deltaFrame(previous: Frame | null, next: Frame): FrameDelta {
const out = { ...next } as unknown as FrameDelta;
for (const key of BOARD_KEYS) {
const unchanged = previous !== null && JSON.stringify(previous[key]) === JSON.stringify(next[key]);
(out as Record<string, unknown>)[key] = unchanged ? null : next[key];
}
return out;
}
/** The receiving side: merges a delta back onto the last full Frame this seat actually has. */
export function applyDelta(previous: Frame | null, delta: FrameDelta): Frame {
const out = { ...delta } as unknown as Frame;
for (const key of BOARD_KEYS) {
if (delta[key] === null) {
if (previous === null) {
throw new Error(`deltaFrame said "${key}" is unchanged, but there is no previous Frame to merge onto`);
}
(out as Record<string, unknown>)[key] = previous[key];
}
}
return out;
}
+15 -6
View File
@@ -12,7 +12,12 @@
*/
import { pump } from '../engine/advance.ts';
import { collectiveRevenueFloor, lengthProfile } from '../engine/content.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
collectiveRevenueFloor,
lengthProfile,
} from '../engine/content.ts';
import { createGame } from '../engine/setup.ts';
import type { GameLength } from '../engine/content.ts';
import type { GameConfig, GameMode } from '../engine/state.ts';
@@ -60,11 +65,15 @@ function statsOf(xs: number[]): Stats {
};
}
function configFor(mode: GameMode, length: GameLength): GameConfig {
function configFor(mode: GameMode, length: GameLength, players: number): GameConfig {
const days = lengthProfile(length).days;
return {
mode,
victory: 'highestAfterDays',
length,
days,
minCombinedRevenue: collectiveRevenueFloor(players, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -83,7 +92,7 @@ export function simulate(opts: SimOptions): SimReport {
const s = createGame({
id: `sim-${i}`,
seed,
config: configFor(opts.mode, opts.length),
config: configFor(opts.mode, opts.length, opts.players.length),
playerNames: opts.players,
});
// The probe watches the game as it is played: the funnel gates are conditions at a moment, not
@@ -154,7 +163,7 @@ export function formatReport(r: SimReport, length: GameLength, players: number):
// The provisional numbers this exists to test.
out.push('\n against the provisional targets:');
out.push(` target ${profile.target} over ${profile.days} Days`);
out.push(` ${profile.days} Days`);
out.push(
` predicted ~5-6 revenue/player/Day steady state; observed mean ` +
`${r.revenuePerPlayerPerDay.mean.toFixed(1)}`,
+16 -5
View File
@@ -21,7 +21,14 @@ import { writeFileSync } from 'node:fs';
import { advance } from '../engine/advance.ts';
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import type { GameLength } from '../engine/content.ts';
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
MAINLINE_PROFILES,
collectiveRevenueFloor,
lengthProfile,
officeProfile,
} from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
@@ -51,10 +58,14 @@ import { playCue } from '../web/sound.ts';
export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };
export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
const days = lengthProfile(length).days;
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length,
days,
minCombinedRevenue: collectiveRevenueFloor(1, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
@@ -263,7 +274,7 @@ export function rehydrateCells(
}
export function renderHtml(rec: Recording): string {
const target = lengthProfile(rec.length);
const profile = lengthProfile(rec.length);
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
@@ -360,7 +371,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
</style></head><body>
<header>
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (${profile.days} Days) · ${esc(rec.outcome)}</h1>
<div class="bar">
<span class="dim">fedora <span id="super">—</span></span>
<span>revenue <b class="big" id="rev">0</b></span>
+50 -8
View File
@@ -24,6 +24,7 @@ import {
import {
ACTION_CARDS,
ENHANCEMENT_CARDS,
HAND_LIMIT,
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
@@ -37,7 +38,6 @@ import {
industryProfile,
mainlineProfile,
modifierProfile,
lengthProfile,
officeProfile,
trainProfile,
houseRules,
@@ -348,6 +348,18 @@ export type Frame = {
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
*/
houseRules: HouseRules;
/**
* The victory-condition dials this game was configured with (`GameConfig`, `state.ts`), plus the
* running collision counts — same reasoning as `houseRules`: a remote client holds no `GameState`
* and needs to show live progress ("2 of 3 collisions today") without guessing a default. `0` on
* any `max*`/`minCombinedRevenue` field means that check is off.
*/
days: number;
minCombinedRevenue: number;
maxCollisionsPerDay: number;
maxCollisionsTotal: number;
collisionsToday: number;
collisionsTotal: number;
status: GameState['status'];
outcome: GameState['outcome'];
/**
@@ -360,6 +372,13 @@ export type Frame = {
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
handCount: number;
/**
* True when the VIEWER's hand is over §6.2's limit and their turn cannot end until it is played
* down. Duplicates `game.ts`'s `overHandLimit(game, seat)` at the engine-data level rather than
* importing the web layer here — a `RemoteSession` (Phase 2) has no `GameState` to compute this
* from, only a `Frame`, so it has to already be resolved on the wire.
*/
overHandLimit: boolean;
lines: { text: string; tone: string }[];
where: { row: number; col: number } | null;
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
@@ -1207,6 +1226,12 @@ export function snapshot(
wasted,
option: turnOf(s, viewer).option,
houseRules: houseRules(s.config),
days: s.config.days,
minCombinedRevenue: s.config.minCombinedRevenue,
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
maxCollisionsTotal: s.config.maxCollisionsTotal,
collisionsToday: s.collisionsToday,
collisionsTotal: s.collisionsTotal,
status: s.status,
outcome: s.outcome,
players: s.players.map((p) => ({
@@ -1217,6 +1242,8 @@ export function snapshot(
hand: (s.decks.hands.get(p.index) ?? []).length,
})),
handCount: (s.decks.hands.get(viewer) ?? []).length,
overHandLimit:
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
objective: objectiveOf(s, viewer),
runningRow: area.runningRow,
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
@@ -1633,21 +1660,36 @@ const SIMPLE_CARDS = [
...ACTION_CARDS,
];
/** The goal, and whether the VIEWER's score is keeping up with the clock. */
/**
* The goal, and whether the VIEWER's score is keeping up with the clock.
*
* `target` is `config.minCombinedRevenue` now (2026-08-20) — the floor below which everyone loses,
* not a per-player win threshold; `days` and `daysLeft` come off `config.days`. Paced against the
* VIEWER's own Revenue, same as before: exact for solitaire (the viewer IS the whole table), an
* approximation for competitive/coop until Phase 2 gives the objective panel a combined-progress
* view of its own. `0` means no floor is configured — nothing to pace against.
*/
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
const profile = lengthProfile(s.config.length);
const { days, minCombinedRevenue: target } = s.config;
const revenue = s.players[viewer]?.revenue ?? 0;
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
const elapsed = profile.days - daysLeft + 1;
const daysLeft = Math.max(0, days - s.clock.day + 1);
const elapsed = days - daysLeft + 1;
if (target <= 0) {
const note =
daysLeft === 0
? 'the last Day is over'
: `${revenue} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · no minimum this game`;
return { target: 0, days, daysLeft, onPace: true, note };
}
// Straight-line pace: by the end of Day N you want N/days of the target.
const expected = (profile.target * elapsed) / profile.days;
const expected = (target * elapsed) / days;
const onPace = revenue >= expected;
const note =
daysLeft === 0
? 'the last Day is over'
: `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
: `${revenue} of ${target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
(onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
return { target: profile.target, days: profile.days, daysLeft, onPace, note };
return { target, days, daysLeft, onPace, note };
}
/**