import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { defaultGameSaveName } from '../../shared/gameSave/buildSnapshot'; import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile, type GameSaveListItem } from '../../shared/types/gameSave'; function safeSegment(raw: string): string { const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, ''); if (!cleaned || cleaned === '.' || cleaned === '..') throw new Error('GAME_SAVE_BAD_ID'); return cleaned; } export class GameSavesStore { private readonly rootDir: string; constructor(userData: string) { this.rootDir = path.join(userData, 'game-saves'); } async list(projectId: string): Promise { const dir = this.projectDir(projectId); let names: string[] = []; try { names = await fs.readdir(dir); } catch { return []; } const items: GameSaveListItem[] = []; for (const name of names) { if (!name.endsWith('.json')) continue; try { const file = await this.readFile(path.join(dir, name)); if (file.projectId !== projectId) continue; items.push({ id: file.id, name: file.name, savedAt: file.savedAt }); } catch { continue; } } items.sort((a, b) => (a.savedAt < b.savedAt ? 1 : a.savedAt > b.savedAt ? -1 : 0)); return items; } async read(projectId: string, id: string): Promise { const filePath = this.filePath(projectId, id); try { const file = await this.readFile(filePath); if (file.projectId !== projectId || file.id !== id) throw new Error('GAME_SAVE_NOT_FOUND'); return file; } catch (err) { if (err instanceof Error && err.message.startsWith('GAME_SAVE_')) throw err; throw new Error('GAME_SAVE_NOT_FOUND'); } } async write(projectId: string, body: GameSaveBody, opts: { id: string | null; name: string }): Promise { if (body.projectId !== projectId) throw new Error('GAME_SAVE_PROJECT_MISMATCH'); const id = opts.id ? safeSegment(opts.id) : `gs_${crypto.randomBytes(8).toString('hex')}`; const name = opts.name.trim() || defaultGameSaveName(); const savedAt = new Date().toISOString(); const file: GameSaveFile = { ...body, id, name, savedAt, schemaVersion: GAME_SAVE_SCHEMA_VERSION }; const filePath = this.filePath(projectId, id); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, JSON.stringify(file), 'utf8'); return { id, name, savedAt }; } private projectDir(projectId: string): string { return path.join(this.rootDir, safeSegment(projectId)); } private filePath(projectId: string, id: string): string { return path.join(this.projectDir(projectId), `${safeSegment(id)}.json`); } private async readFile(filePath: string): Promise { const raw = await fs.readFile(filePath, 'utf8'); const parsed = JSON.parse(raw) as GameSaveFile; if (!parsed || parsed.schemaVersion !== GAME_SAVE_SCHEMA_VERSION || typeof parsed.id !== 'string') { throw new Error('GAME_SAVE_NOT_FOUND'); } return parsed; } }