The second release from the audit. Every fault here was invisible in solitaire, and four of the five server faults were in the one file no test had ever stood up; `http.ts` now has an end-to-end suite on a real port. CHANGELOG has the reasoning. SERVER. Leaving a lobby freed the chair and kept the token, so a leaver could stream and move for whoever took the seat next — revoked now, in memory and on disk. The browser numbered intents from 1 per page load while the server remembered the seat's last number, so the first move after a reload was swallowed as a resend — the connect push carries the count and the client continues from it. Nothing serialised moves within a game and every write shared one `.tmp` name, so two moves at once tore `game.json` (measured: 6 of 200), and the boot's bare `JSON.parse` then took every game down — per-path write queues, a per-game move queue, and a boot that skips one bad file. An error after the SSE head was sent crashed the process. Bodies were unbounded before any secret check. BROWSER. A double-click did the thing twice: one submit in flight at a time. A failed submit is `false`, not an unhandled rejection. The documentation renderer flattened nested bullets into a literal "- " mid-sentence on the published home-deck page. The make-up panel promised cars the engine refuses; it asks `acceptsCar` now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
159 lines
6.6 KiB
TypeScript
159 lines
6.6 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, readIndex, upsertIndexEntry, writeGame } from '../../src/server/persistence.ts';
|
|
import { writeFile } from 'node:fs/promises';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'competitive',
|
|
days: 5,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
|
// Control Point — and several of them test exactly that. Named explicitly since the
|
|
// default became a Depot.
|
|
houseRules: { startingOffice: 'whistlePost' },
|
|
optionalRules: {
|
|
reducedVisibility: false,
|
|
employeeRotation: false,
|
|
emergencyToolbox: false,
|
|
},
|
|
};
|
|
|
|
const saved: SavedGame = {
|
|
seed: 42,
|
|
config,
|
|
playerNames: ['Alice', 'Bob'],
|
|
history: [{ type: 'localOps.choose', option: 'draw' }],
|
|
status: 'active',
|
|
createdAt: 1000,
|
|
botSeats: [],
|
|
};
|
|
|
|
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);
|
|
assert.equal(result.found, true);
|
|
if (!result.found) return;
|
|
assert.deepEqual(result.saved, saved);
|
|
}));
|
|
|
|
it('reports the version that wrote the file without judging it', () =>
|
|
withTempDir(async (dir) => {
|
|
// Reading a save no longer refuses on the version. The stamp is the PACKAGE version, which
|
|
// moves for reasons unrelated to the rules, and gating on it destroyed every game in progress
|
|
// across four releases — one of which only changed how the board is drawn. Whether a save
|
|
// still replays is decided by replaying it (`tryResumeSession`); the version is kept because
|
|
// it is worth naming in a failure, and nothing else.
|
|
await writeGame(dir, saved, '1.2.3');
|
|
const result = await loadGame(dir);
|
|
assert.equal(result.found, true);
|
|
if (!result.found) return;
|
|
assert.equal(result.storedVersion, '1.2.3');
|
|
assert.deepEqual(result.saved, saved);
|
|
}));
|
|
|
|
it('reports not-found rather than throwing when nothing has been saved yet', () =>
|
|
withTempDir(async (dir) => {
|
|
const result = await loadGame(dir);
|
|
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);
|
|
assert.equal(result.found, true);
|
|
if (!result.found) 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);
|
|
}));
|
|
|
|
// -- v0.8.4: concurrent writers ---------------------------------------------------------------
|
|
|
|
it('two hundred concurrent writes to one save leave it valid and never throw (v0.8.4)', () =>
|
|
withTempDir(async (dir) => {
|
|
/**
|
|
* One fixed `.tmp` per path, and no queue: measured at 200 rounds of two concurrent writes,
|
|
* every round lost one to `rename` ENOENT and six left the file as invalid JSON. Boot then
|
|
* died on it. Unique temp names and a per-path queue are the fix; this is the measurement.
|
|
*/
|
|
const writes: Promise<void>[] = [];
|
|
for (let i = 0; i < 200; i++) {
|
|
const grown: SavedGame = { ...saved, history: Array.from({ length: i + 1 }, () => ({ type: 'draw.end' })) };
|
|
writes.push(writeGame(dir, grown, '1.2.3'));
|
|
}
|
|
await Promise.all(writes);
|
|
const result = await loadGame(dir);
|
|
assert.equal(result.found, true, 'the save is unreadable after concurrent writes');
|
|
if (result.found) assert.equal(result.saved.history.length, 200, 'the last write did not win');
|
|
await assert.rejects(() => readFile(join(dir, 'game.json.tmp')));
|
|
}));
|
|
|
|
it('two concurrent index upserts both land (v0.8.4)', () =>
|
|
withTempDir(async (dir) => {
|
|
await Promise.all([
|
|
upsertIndexEntry(dir, { gameId: 'a', gameCode: 'AAA-1', status: 'active' }),
|
|
upsertIndexEntry(dir, { gameId: 'b', gameCode: 'BBB-2', status: 'lobby' }),
|
|
]);
|
|
const rows = await readIndex(dir);
|
|
assert.deepEqual(rows.map((r) => r.gameId).sort(), ['a', 'b'], 'a concurrent upsert lost a row');
|
|
}));
|
|
|
|
it('two concurrent timing appends both land (v0.8.4)', () =>
|
|
withTempDir(async (dir) => {
|
|
await Promise.all([
|
|
appendTiming(dir, { player: 0, phase: 'localOps', day: 1, stage: 1, startedAt: 1, endedAt: 2 }),
|
|
appendTiming(dir, { player: 1, phase: 'localOps', day: 1, stage: 1, startedAt: 2, endedAt: 3 }),
|
|
]);
|
|
const timings = JSON.parse(await readFile(join(dir, 'turn-timings.json'), 'utf8')) as unknown[];
|
|
assert.equal(timings.length, 2);
|
|
}));
|
|
|
|
it('reports a save that is not JSON instead of throwing (v0.8.4)', () =>
|
|
withTempDir(async (dir) => {
|
|
await writeFile(join(dir, 'game.json'), '{"seed": 42, "hist');
|
|
const result = await loadGame(dir);
|
|
assert.equal(result.found, false);
|
|
if (!result.found) assert.ok(result.corrupt, 'a torn file was reported as merely missing');
|
|
await writeFile(join(dir, 'game.json'), '{"seed": 42}');
|
|
const shape = await loadGame(dir);
|
|
assert.equal(shape.found, false);
|
|
if (!shape.found) assert.match(shape.corrupt ?? '', /history/);
|
|
}));
|
|
});
|