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:
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* §7's redaction test — "the single most important test in the plan."
|
||||
*
|
||||
* Everything else about `Frame` degrades gracefully; a redaction bug hands one player's hand to
|
||||
* another and cannot be walked back once it has been seen. `test/multiplayer.test.ts`'s "the view
|
||||
* shows one seat at a time" section already proves `snapshot(s, ..., viewer)` gives each seat its
|
||||
* own hand, board and Revenue — spot-checks that today's code does the right thing. This is the
|
||||
* different, exhaustive check: serialize a seat's whole `Frame` and assert none of some OTHER seat's
|
||||
* actual secret data appears anywhere in it, so a future careless edit is caught rather than assumed
|
||||
* safe. No server needed — `snapshot()` and a multi-player `GameState` are all this exercises.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { snapshot } from '../src/sim/view.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,
|
||||
},
|
||||
};
|
||||
|
||||
/** Plays a real multi-player game partway — enough for every seat to hold a real, distinct hand. */
|
||||
function midGame(players: number, seed: number): GameState {
|
||||
const s = createGame({
|
||||
id: `redact-${players}`,
|
||||
seed,
|
||||
config,
|
||||
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
|
||||
});
|
||||
const r = playGame(s, developerBot, pump, 400);
|
||||
// A partial or finished game both exercise real hands — either is fine for this check.
|
||||
void r;
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('redaction — a seat\'s Frame never carries another seat\'s secrets', () => {
|
||||
it('never contains another seat\'s actual hand-card ids', () => {
|
||||
for (const players of [3, 4]) {
|
||||
const s = midGame(players, 1000 + players);
|
||||
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
|
||||
const serialized = JSON.stringify(snapshot(s, [], null, null, null, false, viewer));
|
||||
for (let other = 0 as PlayerIndex; other < players; other++) {
|
||||
if (other === viewer) continue;
|
||||
for (const cardId of s.decks.hands.get(other) ?? []) {
|
||||
assert.ok(
|
||||
!serialized.includes(`"${cardId}"`),
|
||||
`${players}p seed ${1000 + players}: seat ${viewer}'s Frame contains seat ${other}'s ` +
|
||||
`hand card id "${cardId}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('never contains the Home Office deck\'s order or contents, only its count', () => {
|
||||
for (const players of [3, 4]) {
|
||||
const s = midGame(players, 2000 + players);
|
||||
// The deck's own card ids are the thing that must never leak — distinct from any hand's ids,
|
||||
// since a card once dealt is removed from `homeOffice` (state.ts).
|
||||
const deckIds = new Set(s.decks.homeOffice);
|
||||
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
|
||||
const frame = snapshot(s, [], null, null, null, false, viewer);
|
||||
assert.equal(frame.deck, s.decks.homeOffice.length, 'deck field is not a plain count');
|
||||
const serialized = JSON.stringify(frame);
|
||||
for (const cardId of deckIds) {
|
||||
assert.ok(
|
||||
!serialized.includes(`"${cardId}"`),
|
||||
`${players}p seed ${2000 + players}: seat ${viewer}'s Frame contains a Home Office deck id "${cardId}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('never carries the seed or rngState — Frame has no field for either', () => {
|
||||
// A structural guarantee, not a runtime one: confirmed here so a future field addition to Frame
|
||||
// that reintroduces one of these is at least forced past a reader of this test, if not the type
|
||||
// system directly (see Frame in src/sim/view.ts, which carries neither today).
|
||||
const s = midGame(3, 3003);
|
||||
const frame = snapshot(s, [], null, null, null, false, 0);
|
||||
assert.ok(!('seed' in frame), 'Frame gained a seed field');
|
||||
assert.ok(!('rngState' in frame), 'Frame gained an rngState field');
|
||||
const serialized = JSON.stringify(frame);
|
||||
assert.ok(!serialized.includes(String(s.seed)), 'the seed value leaked into the Frame some other way');
|
||||
});
|
||||
|
||||
it('only the viewer\'s own hand and handCount are non-public — everything else matches across seats', () => {
|
||||
// The redaction surface is four fields (§7), not sixty event types. Cross-check that seats agree
|
||||
// on everything else a Frame carries about shared state.
|
||||
const s = midGame(3, 4004);
|
||||
const frames = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p as PlayerIndex));
|
||||
for (const f of frames) {
|
||||
assert.deepEqual(f.timetable, frames[0]!.timetable, 'the public timetable differs by seat');
|
||||
assert.deepEqual(f.deck, frames[0]!.deck, 'the deck count differs by seat');
|
||||
assert.deepEqual(
|
||||
f.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })),
|
||||
frames[0]!.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })),
|
||||
'public standing (names, Revenue, hand COUNTS) differs by seat',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user