877fbaa8e7
Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates. Co-authored-by: Cursor <cursoragent@cursor.com>
85 lines
3.1 KiB
TypeScript
85 lines
3.1 KiB
TypeScript
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<GameSaveListItem[]> {
|
|
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<GameSaveFile> {
|
|
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<GameSaveListItem> {
|
|
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<GameSaveFile> {
|
|
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;
|
|
}
|
|
}
|