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.
102 lines
3.5 KiB
TypeScript
102 lines
3.5 KiB
TypeScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import type { GameConfig } from '../../src/engine/state.ts';
|
|
import type { SavedGame } from '../../src/server/session.ts';
|
|
import { appendTiming, loadGame, writeGame } from '../../src/server/persistence.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,
|
|
},
|
|
};
|
|
|
|
const saved: SavedGame = {
|
|
seed: 42,
|
|
config,
|
|
playerNames: ['Alice', 'Bob'],
|
|
history: [{ type: 'localOps.choose', option: 'draw' }],
|
|
status: 'active',
|
|
createdAt: 1000,
|
|
};
|
|
|
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'station-master-persist-'));
|
|
try {
|
|
return await fn(dir);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
describe('game persistence (Phase 3)', () => {
|
|
it('writes and reads back exactly what was written', () =>
|
|
withTempDir(async (dir) => {
|
|
await writeGame(dir, saved, '1.2.3');
|
|
const result = await loadGame(dir, '1.2.3');
|
|
assert.equal(result.found, true);
|
|
if (!result.found) return;
|
|
assert.equal(result.ok, true);
|
|
if (!result.ok) return;
|
|
assert.deepEqual(result.saved, saved);
|
|
}));
|
|
|
|
it('refuses a version mismatch explicitly, naming both versions', () =>
|
|
withTempDir(async (dir) => {
|
|
await writeGame(dir, saved, '1.2.3');
|
|
const result = await loadGame(dir, '9.9.9');
|
|
assert.equal(result.found, true);
|
|
if (!result.found) return;
|
|
assert.equal(result.ok, false);
|
|
if (result.ok) return;
|
|
assert.equal(result.storedVersion, '1.2.3');
|
|
assert.equal(result.currentVersion, '9.9.9');
|
|
}));
|
|
|
|
it('reports not-found rather than throwing when nothing has been saved yet', () =>
|
|
withTempDir(async (dir) => {
|
|
const result = await loadGame(dir, '1.2.3');
|
|
assert.deepEqual(result, { found: false });
|
|
}));
|
|
|
|
it('a later writeGame fully replaces the earlier one, not merges with it', () =>
|
|
withTempDir(async (dir) => {
|
|
await writeGame(dir, saved, '1.2.3');
|
|
const grown: SavedGame = { ...saved, history: [...saved.history, { type: 'draw.end' }] };
|
|
await writeGame(dir, grown, '1.2.3');
|
|
const result = await loadGame(dir, '1.2.3');
|
|
assert.equal(result.found, true);
|
|
if (!result.found) return;
|
|
assert.equal(result.ok, true);
|
|
if (!result.ok) return;
|
|
assert.equal(result.saved.history.length, 2);
|
|
}));
|
|
|
|
it('never leaves a stray .tmp file behind after a write', () =>
|
|
withTempDir(async (dir) => {
|
|
await writeGame(dir, saved, '1.2.3');
|
|
await assert.rejects(() => readFile(join(dir, 'game.json.tmp')));
|
|
}));
|
|
|
|
it('appendTiming accumulates across calls rather than overwriting', () =>
|
|
withTempDir(async (dir) => {
|
|
await appendTiming(dir, { player: 0, phase: 'localOps', day: 1, stage: 1, startedAt: 1, endedAt: 2 });
|
|
await appendTiming(dir, { player: 1, phase: 'localOps', day: 1, stage: 1, startedAt: 2, endedAt: 3 });
|
|
const text = await readFile(join(dir, 'turn-timings.json'), 'utf8');
|
|
const timings = JSON.parse(text) as unknown[];
|
|
assert.equal(timings.length, 2);
|
|
}));
|
|
});
|