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(fn: (dir: string) => Promise): Promise { 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[] = []; 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/); })); });