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,87 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { areaOf } from '../src/engine/apply.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { applyDelta, deltaFrame } from '../src/sim/frame-delta.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
const game = (seed = 1): GameState => createGame({ id: 'g', seed, config, playerNames: ['a'] });
|
||||
|
||||
describe('deltaFrame — the live one-step-back board diff', () => {
|
||||
it('nulls cells/facilities/division when the board has not changed, keeps everything else', () => {
|
||||
const s = game();
|
||||
const first = snapshot(s, [], null);
|
||||
s.players[0]!.revenue = 42; // a non-board change
|
||||
const second = snapshot(s, [], null);
|
||||
|
||||
const delta = deltaFrame(first, second);
|
||||
assert.equal(delta.cells, null, 'unchanged cells were not nulled');
|
||||
assert.equal(delta.facilities, null, 'unchanged facilities were not nulled');
|
||||
assert.equal(delta.division, null, 'unchanged division was not nulled');
|
||||
assert.equal(delta.revenue, 42, 'a real change was dropped along with the unchanged board');
|
||||
});
|
||||
|
||||
it('sends the real board when it changed', () => {
|
||||
const s = game();
|
||||
const first = snapshot(s, [], null);
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.set(coordKey({ row: area.runningRow - 1, col: 0 }), {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const second = snapshot(s, [], null);
|
||||
|
||||
const delta = deltaFrame(first, second);
|
||||
assert.notEqual(delta.cells, null, 'a real board change was nulled');
|
||||
assert.deepEqual(delta.cells, second.cells);
|
||||
});
|
||||
|
||||
it('never nulls anything on a first connect (no previous Frame)', () => {
|
||||
const s = game();
|
||||
const frame = snapshot(s, [], null);
|
||||
const delta = deltaFrame(null, frame);
|
||||
assert.deepEqual(delta.cells, frame.cells);
|
||||
assert.deepEqual(delta.facilities, frame.facilities);
|
||||
assert.deepEqual(delta.division, frame.division);
|
||||
});
|
||||
|
||||
it('applyDelta round-trips: merge(delta(A, B)) against A reconstructs B exactly', () => {
|
||||
const s = game();
|
||||
const a = snapshot(s, [], null);
|
||||
s.players[0]!.revenue = 7;
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.set(coordKey({ row: area.runningRow - 1, col: 1 }), {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const b = snapshot(s, [], null);
|
||||
|
||||
const merged = applyDelta(a, deltaFrame(a, b));
|
||||
assert.deepEqual(merged, b);
|
||||
});
|
||||
|
||||
it('throws rather than silently reconstructing garbage when previous is missing but the delta says "unchanged"', () => {
|
||||
const s = game();
|
||||
const frame = snapshot(s, [], null);
|
||||
const delta = deltaFrame(frame, frame); // everything nulled, since nothing changed
|
||||
assert.throws(() => applyDelta(null, delta));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user