Files
station-master/test/server/session.test.ts
T
Jesse c3c5cbfeec 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.
2026-08-20 23:50:38 -04:00

201 lines
9.7 KiB
TypeScript

/**
* The game session host (`src/server/session.ts`) — pure logic, no sockets, exercised directly.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts';
import type { Push } from '../../src/server/session.ts';
import { createSession, resumeSession } from '../../src/server/session.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
describe('the game session host', () => {
it('gives a fresh connect a full Frame — nothing nulled', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex);
assert.notEqual(push.frame.cells, null, 'a first connect nulled the board');
assert.notEqual(push.frame.division, null, 'a first connect nulled the division');
});
it('only the current actor gets a real Menu; every other seat gets null', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const a = session.connect(0 as PlayerIndex);
const b = session.connect(1 as PlayerIndex);
const actorPushes = [a, b].filter((p) => p.menu !== null);
assert.equal(actorPushes.length, 1, 'more than one seat (or zero) was given a real Menu');
});
it('rejects an intent from a seat that is not the current actor, with NOT_YOUR_TURN', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
// Whichever seat is NOT the current actor should be refused, regardless of the intent's content.
const bPush = session.connect(1 as PlayerIndex);
const notActor = (bPush.menu === null ? 1 : 0) as PlayerIndex;
const result = session.intent(notActor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(result.accepted, false);
if (!result.accepted) assert.equal(result.code, 'NOT_YOUR_TURN');
});
it('accepts a legal intent from the current actor and pushes every seat', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
const result = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(result.accepted, true);
if (result.accepted) assert.equal(result.pushes.size, 2, 'not every connected seat was pushed to');
});
it('rejects an illegal intent with its real RejectionCode, and does not remember the seq', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
// Discarding before ever drawing is illegal on an opening hand at the limit — a safe "this is
// definitely rejected" fixture that does not depend on exactly which cards were dealt.
const bad = session.intent(actor, 1, { type: 'card.discard', cardId: 'not-a-real-card', toSlot: 0 });
assert.equal(bad.accepted, false);
if (!bad.accepted) assert.ok(bad.code.length > 0, 'a rejection carried no code at all');
// The same seq, now with a legal intent, must still go through — a rejection is not "applied".
const retry = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(retry.accepted, true, 'a seq burned by an earlier rejection could not be reused');
});
it('treats a repeated seq as an already-applied no-op, not a second application', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
const first = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(first.accepted, true);
const second = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(second.accepted, true, 'a resend of an already-applied seq was rejected instead of ignored');
if (second.accepted) assert.equal(second.pushes.size, 0, 'a resend produced pushes as if newly applied');
});
it('deltas the board on a second push when it has not changed since the first', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
session.connect(1 as PlayerIndex); // both seats need a baseline Frame before a delta means anything
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
const result = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(result.accepted, true);
if (!result.accepted) return;
// Choosing "draw" doesn't move a single card on the board — the division/cells should be nulled
// on this push for a seat that already had them from `connect`.
const push = result.pushes.get(actor)!;
assert.equal(push.frame.division, null, 'the board was resent even though nothing on it changed');
});
it('only sends narration NEW since the last push to that specific seat', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const aPush = session.connect(0 as PlayerIndex);
const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex;
// A first connect is this seat's first contact, so it gets everything narrated so far (the
// "Game Begins" intro and whatever `drain()` said entering the first Stage) — not an empty log.
assert.ok(aPush.lines.length > 0, 'a first connect got no narration at all, not even the game-begins intro');
const first = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(first.accepted, true);
if (!first.accepted) return;
const firstLines = first.pushes.get(actor)!.lines;
assert.ok(firstLines.length > 0, 'the acting seat got no narration for its own action');
const second = session.intent(actor, 2, { type: 'draw.end' });
assert.equal(second.accepted, true);
if (!second.accepted) return;
const secondLines = second.pushes.get(actor)!.lines;
assert.ok(secondLines.length > 0, 'a second real action produced no narration at all');
for (const line of firstLines) {
assert.ok(!secondLines.includes(line), 'the second push repeated narration already sent in the first');
}
});
});
describe('turn timings (lobby-and-sessions.md §5)', () => {
it('records a wall-clock span once the acting player, phase, Day or Stage changes', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
let pushes = new Map<PlayerIndex, Push>([
[0 as PlayerIndex, session.connect(0 as PlayerIndex)],
[1 as PlayerIndex, session.connect(1 as PlayerIndex)],
]);
const seq = new Map<PlayerIndex, number>([[0 as PlayerIndex, 1], [1 as PlayerIndex, 1]]);
const timings = [];
for (let step = 0; step < 60 && timings.length === 0; step++) {
const acting = [0, 1].find((seat) => pushes.get(seat as PlayerIndex)!.menu !== null) as PlayerIndex | undefined;
const menu = acting === undefined ? null : pushes.get(acting)!.menu;
if (acting === undefined || !menu || menu.options.length === 0) break;
const n = seq.get(acting)!;
seq.set(acting, n + 1);
const result = session.intent(acting, n, menu.options[0]!);
assert.equal(result.accepted, true, `step ${step}: ${JSON.stringify(menu.options[0])} rejected`);
if (!result.accepted) break;
if (result.timing) timings.push(result.timing);
pushes = result.pushes;
}
assert.ok(timings.length > 0, 'no turn timing ever closed across 60 real steps of actual play');
const [timing] = timings;
assert.ok(timing!.endedAt >= timing!.startedAt, 'a span ended before it started');
assert.ok([0, 1].includes(timing!.player), 'a timing named a player outside the table');
});
});
describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => {
it('exportSave carries enough to reconstruct the exact same game', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex);
const actor = (push.menu !== null ? 0 : 1) as PlayerIndex;
const applied = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
assert.equal(applied.accepted, true);
const saved = session.exportSave();
assert.equal(saved.seed, 42);
assert.deepEqual(saved.playerNames, ['Alice', 'Bob']);
assert.equal(saved.history.length, 1);
assert.equal(saved.status, 'active');
assert.ok(saved.createdAt > 0, 'createdAt was not set');
const resumed = resumeSession(saved);
const before = session.connect(actor);
const after = resumed.connect(actor);
assert.deepEqual(after.frame, before.frame, 'resumeSession did not reconstruct the same board/state');
});
it('a resumed session keeps enforcing whose turn it is', () => {
const session = createSession(7, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex);
const actor = (push.menu !== null ? 0 : 1) as PlayerIndex;
session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' });
const resumed = resumeSession(session.exportSave());
const notActor = (actor === 0 ? 1 : 0) as PlayerIndex;
const rejected = resumed.intent(notActor, 1, { type: 'draw.end' });
assert.equal(rejected.accepted, false);
if (!rejected.accepted) assert.equal(rejected.code, 'NOT_YOUR_TURN');
});
it('marks status finished only once the game actually is', () => {
const session = createSession(42, config, ['Alice', 'Bob']);
assert.equal(session.exportSave().status, 'active');
});
});