feat: save games and edit scene darkness
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>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user