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, 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, '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); })); });