import crypto from 'node:crypto'; import fssync from 'node:fs'; import fs from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { ZipFile } from 'yazl'; import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules'; import { canSetSideStoryStart, getSideStoryComponentNodeIds } from '../../shared/graph/sceneGraphLineage'; import { prependSceneListOrder, reconcileSceneListOrder, removeFromSceneListOrder, } from '../../shared/graph/sceneListOrder'; import { buildPartialExportProject, computeGraphImportOffsetX, listExportableStorylines, listImportableStorylines, mergeStorylinesIntoProject, newExportBundleProjectId, type NpcImportResolution, type SceneImportResolution, type StorylineImportMergeReport, type StorylineLabels, type StorylineListItem, type StorylineSelection, } from '../../shared/graph/storylineExportImport'; import type { ScenePatch } from '../../shared/ipc/contracts'; import { isProjectZipFileName, projectZipFileNameFromBase, stripProjectZipExtension, } from '../../shared/project/projectZipExtension'; import type { MaterialLegend, MediaAsset, MediaAssetType, NpcBinding, Project, ProjectId, ProjectNpc, ProjectNpcGroup, ProjectNpcRelation, Scene, SceneGraphEdge, SceneGraphNode, SceneId, SceneTrap, } from '../../shared/types'; import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; import { normalizeSceneTrap } from '../../shared/types/sceneTraps'; import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids'; import { asAssetId, asGraphNodeId, asMaterialId, asNpcGroupId, asNpcId, asNpcRelationId, asProjectId, } from '../../shared/types/ids'; import { clearNpcBindingsForDeletedScene, clearNpcBindingsForRemovedStoryline, noneBinding, normalizeNpcBinding, } from '../../shared/npcs/npcBinding'; import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor, normalizeNpcGroups, resolveNpcGroupId, wouldCreateGroupCycle, } from '../../shared/npcs/npcGroups'; import { buildProjectFromFoundryDocuments, loadFoundryDocumentsForImport, type FoundryImportProgress, } from '../foundry/foundryImport'; import { getAppSemanticVersion } from '../versionInfo'; import { reconcileAssetFiles } from './assetPrune'; import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace'; import { rmWithRetries } from './fsRetry'; import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs'; import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths'; import { generateScenePreviewThumbnailBytes } from './scenePreviewThumbnail'; import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip'; type ProjectIndexEntry = { id: ProjectId; name: string; updatedAt: string; fileName: string; }; type OpenProject = { id: ProjectId; zipPath: string; cacheDir: string; projectPath: string; project: Project; }; export class ZipProjectStore { private openProject: OpenProject | null = null; private saveQueued = false; private saving = false; /** Bumps on create/open so in-flight disk writes cannot commit after project switch. */ private projectSession = 0; /** Serializes project.json writes — parallel renames caused ENOENT on Windows. */ private projectWriteChain: Promise = Promise.resolve(); /** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */ private packChain: Promise = Promise.resolve(); /** Serializes open/close/unzip — concurrent IPC caused ghost open projects and deadlocks. */ private projectSwitchChain: Promise = Promise.resolve(); private saveDebounceTimer: ReturnType | null = null; private enqueuePack(cacheDir: string, zipPath: string): Promise { const next = this.packChain.then(async () => { await this.packZipFromCache(cacheDir, zipPath); }); this.packChain = next.catch(() => undefined); return next; } private enqueueProjectSwitch(fn: () => Promise): Promise { const task = this.projectSwitchChain.then(() => fn()); this.projectSwitchChain = task.then( () => undefined, () => undefined, ); return task; } private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise { return this.enqueueProjectSwitch(() => this.openProjectByIdInner(projectId, onUnzipPercent)); } /** Waits for debounced save, in-flight pack, and pending project.json writes before reading zip. */ private async drainSavePipeline(): Promise { if (this.saveDebounceTimer) { clearTimeout(this.saveDebounceTimer); this.saveDebounceTimer = null; } if (this.saveQueued) { this.saveQueued = false; await this.flushSave(); } while (this.saving) { await new Promise((r) => setTimeout(r, 10)); } await this.packChain; await this.projectWriteChain; } async ensureRoots(): Promise { await fs.mkdir(getProjectsRootDir(), { recursive: true }); await fs.mkdir(getProjectsCacheRootDir(), { recursive: true }); await this.migrateLegacyProjectZipsIfNeeded(); await recoverOrphanProjectZipTmpInRoot(getProjectsRootDir()); } /** Копирует архивы проектов из каталогов с «чужим» app name, если в текущем каталоге такого файла ещё нет. */ private async migrateLegacyProjectZipsIfNeeded(): Promise { const dest = getProjectsRootDir(); let destNames: string[]; try { destNames = await fs.readdir(dest); } catch { return; } const destZips = new Set(destNames.filter((n) => isProjectZipFileName(n))); for (const legacyRoot of getLegacyProjectsRootDirs()) { let legacyNames: string[]; try { legacyNames = await fs.readdir(legacyRoot); } catch { continue; } for (const name of legacyNames) { if (!isProjectZipFileName(name)) continue; if (destZips.has(name)) continue; const from = path.join(legacyRoot, name); const to = path.join(dest, name); try { const st = await fs.stat(from); if (!st.isFile()) continue; // Переносим (а не копируем), чтобы: // - не было дублей между разными appName // - удалённые пользователем проекты не «возрождались» при следующем ensureRoots() try { await fs.rename(from, to); } catch { await fs.copyFile(from, to); try { await rmWithRetries(fs.rm, from, { force: true }); } catch { // best effort: если zip уже скопирован в dest, миграцию считаем успешной; // legacy-копия может остаться (например из-за lock/AV), но удаление проекта // затем чистит legacy по fileName. } } destZips.add(name); } catch { /* ignore */ } } } } async listProjects(): Promise { await this.ensureRoots(); const root = getProjectsRootDir(); const entries = await fs.readdir(root, { withFileTypes: true }); const files = entries .filter((e) => e.isFile() && isProjectZipFileName(e.name)) .map((e) => path.join(root, e.name)); const out: ProjectIndexEntry[] = []; for (const filePath of files) { try { const project = await readProjectJsonFromZip(filePath); out.push({ id: project.id, name: project.meta.name, updatedAt: project.meta.updatedAt, fileName: path.basename(filePath), }); } catch { // Один битый архив не должен скрывать остальные проекты в списке. } } out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return out; } async createProject(name: string): Promise { await this.ensureRoots(); this.projectSession += 1; const id = asProjectId(this.randomId()); const now = new Date().toISOString(); const fileBaseName = `${sanitizeFileName(name)}_${id}`; const appVer = getAppSemanticVersion(); const project: Project = { id, meta: { name, fileBaseName, createdAt: now, updatedAt: now, createdWithAppVersion: appVer, appVersion: appVer, schemaVersion: PROJECT_SCHEMA_VERSION, }, scenes: {}, sceneListOrder: [], assets: {}, campaignAudios: [], materials: [], npcs: [], npcGroups: [], npcRelations: [], currentSceneId: null, currentGraphNodeId: null, sceneGraphNodes: [], sceneGraphEdges: [], }; const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(fileBaseName)); const cacheDir = path.join(getProjectsCacheRootDir(), id); const projectPath = path.join(cacheDir, 'project.json'); this.openProject = { id, zipPath, cacheDir, projectPath, project }; await this.writeCacheProject(cacheDir, project); await this.enqueuePack(cacheDir, zipPath); return this.openProject.project; } async openProjectById(projectId: ProjectId): Promise { return this.enqueueOpenProject(projectId); } private async openProjectByIdInner( projectId: ProjectId, onUnzipPercent?: (pct: number) => void, ): Promise { await this.ensureRoots(); if (this.openProject?.id === projectId) { return this.openProject.project; } const sessionAtStart = this.projectSession; // Mutations are persisted to cache immediately, but zip packing is debounced (queueSave). // When switching projects we delete the cache and restore it from the zip, so flush pending saves first. if (this.openProject) { await this.saveNow(); } await this.drainSavePipeline(); if (sessionAtStart !== this.projectSession) { throw new Error('Открытие проекта отменено'); } this.projectSession += 1; const openSession = this.projectSession; const list = await this.listProjects(); const entry = list.find((p) => p.id === projectId); if (!entry) { throw new Error('Project not found'); } const zipPath = path.join(getProjectsRootDir(), entry.fileName); const cacheDir = path.join(getProjectsCacheRootDir(), projectId); await fs.rm(cacheDir, { recursive: true, force: true }); await fs.mkdir(cacheDir, { recursive: true }); try { await unzipToDir(zipPath, cacheDir, (done, total) => { if (!onUnzipPercent) return; const pct = total > 0 ? Math.round((done / total) * 100) : 0; onUnzipPercent(Math.max(0, Math.min(100, pct))); }); } catch (err) { const detail = err instanceof Error ? err.message : String(err); throw new Error(`Не удалось открыть проект: архив повреждён или занят (${detail})`); } if (openSession !== this.projectSession) { await fs.rm(cacheDir, { recursive: true, force: true }).catch(() => undefined); throw new Error('Открытие проекта отменено'); } const projectPath = path.join(cacheDir, 'project.json'); const projectRaw = await fs.readFile(projectPath, 'utf8'); const parsed = JSON.parse(projectRaw) as unknown as Project; const project = normalizeProject(parsed); const fileBaseName = entry.fileName.replace(/\.dnd\.zip$/iu, ''); project.meta.fileBaseName = project.meta.fileBaseName.trim().length ? project.meta.fileBaseName : fileBaseName; this.openProject = { id: projectId, zipPath, cacheDir, projectPath, project }; return project; } private async openProjectByIdWithProgress( projectId: ProjectId, onUnzipPercent: (pct: number) => void, ): Promise { return this.enqueueOpenProject(projectId, onUnzipPercent); } getOpenProject(): Project | null { return this.openProject?.project ?? null; } /** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */ getAssetFileUrl(assetId: AssetId): string | null { if (!this.getAssetReadInfo(assetId)) return null; return `dnd://asset?id=${encodeURIComponent(assetId)}`; } getAssetReadInfo(assetId: AssetId): { absPath: string; mime: string } | null { const open = this.openProject; if (!open) return null; const asset = open.project.assets[assetId]; if (!asset) return null; return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime }; } getImageAssetReadInfo(assetId: AssetId): { absPath: string; mime: string } | null { const open = this.openProject; if (!open) return null; const asset = open.project.assets[assetId]; if (asset?.type !== 'image') return null; return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime }; } async importScenePreviewMedia( sceneId: SceneId, filePath: string, ): Promise<{ project: Project; assetId: AssetId; background: boolean }> { const open = this.openProject; if (!open) throw new Error('No open project'); const sc = open.project.scenes[sceneId]; if (!sc) throw new Error('Scene not found'); const kind0 = classifyMediaPath(filePath); if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) { throw new Error('Файл превью должен быть изображением или видео'); } const buf = await fs.readFile(filePath); const id = asAssetId(this.randomId()); const orig = path.basename(filePath); const safeOrig = sanitizeFileName(orig); const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, buf); const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length); const oldPreviewId = sc.previewAssetId; const oldThumbId = sc.previewThumbAssetId ?? null; await this.updateProject((p) => { const scene = p.scenes[sceneId]; if (!scene) throw new Error('Scene not found'); let assets: Record = { ...p.assets }; const drop = new Set(); if (oldPreviewId) drop.add(oldPreviewId); if (oldThumbId) drop.add(oldThumbId); if (drop.size > 0) { assets = Object.fromEntries( Object.entries(assets).filter(([k]) => !drop.has(k as AssetId)), ) as Record; } assets[id] = asset; return { ...p, assets, scenes: { ...p.scenes, [sceneId]: { ...scene, previewAssetId: id, previewAssetType: kind0.type, previewThumbAssetId: null, previewVideoAutostart: kind0.type === 'video' ? scene.previewVideoAutostart : false, }, }, }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return { project: latest, assetId: id, background: true }; } async finalizeScenePreviewImport( sceneId: SceneId, assetId: AssetId, ): Promise<{ project: Project; changed: boolean }> { const open = this.openProject; if (!open) throw new Error('No open project'); const sceneAtStart = open.project.scenes[sceneId]; if (sceneAtStart?.previewAssetId !== assetId) { return { project: open.project, changed: false }; } const sourceAsset = open.project.assets[assetId]; if (!sourceAsset || (sourceAsset.type !== 'image' && sourceAsset.type !== 'video')) { return { project: open.project, changed: false }; } const generatedRelPaths: string[] = []; let finalAsset = sourceAsset; let finalAssetId = assetId; let finalAbs = path.join(open.cacheDir, sourceAsset.relPath); if (sourceAsset.type === 'image') { const input = await fs.readFile(finalAbs); const opt = await optimizeImageBufferVisuallyLossless(input); if (!opt.passthrough) { finalAssetId = asAssetId(this.randomId()); const optimizedName = `${path.parse(sourceAsset.originalName).name}.${opt.ext}`; const safeOptimizedName = sanitizeFileName(optimizedName); const optimizedRelPath = `assets/${finalAssetId}_${safeOptimizedName}`; finalAbs = path.join(open.cacheDir, optimizedRelPath); const optimizedBuffer = Buffer.from(opt.buffer); await fs.writeFile(finalAbs, optimizedBuffer); generatedRelPaths.push(optimizedRelPath); const optimizedAsset = buildMediaAsset( finalAssetId, { type: 'image', mime: opt.mime }, optimizedName, optimizedRelPath, crypto.createHash('sha256').update(optimizedBuffer).digest('hex'), optimizedBuffer.length, ); if (optimizedAsset.type !== 'image') { throw new Error('Optimized preview asset must be an image'); } finalAsset = optimizedAsset; } } const thumbKind = finalAsset.type === 'image' ? 'image' : 'video'; const thumbBytes = await generateScenePreviewThumbnailBytes(finalAbs, thumbKind); let thumbAsset: MediaAsset | null = null; let thumbId: AssetId | null = null; if (thumbBytes !== null && thumbBytes.length > 0) { thumbId = asAssetId(this.randomId()); const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`; const thumbAbs = path.join(open.cacheDir, thumbRelPath); await fs.writeFile(thumbAbs, thumbBytes); generatedRelPaths.push(thumbRelPath); const thumbOrigName = `${path.parse(finalAsset.originalName).name}_preview_thumb.webp`; thumbAsset = buildMediaAsset( thumbId, { type: 'image', mime: 'image/webp' }, thumbOrigName, thumbRelPath, crypto.createHash('sha256').update(thumbBytes).digest('hex'), thumbBytes.length, ); } await this.updateProject((p) => { const scene = p.scenes[sceneId]; if (scene?.previewAssetId !== assetId) { return p; } const assets: Record = { ...p.assets, [finalAssetId]: finalAsset }; if (thumbAsset !== null && thumbId !== null) { assets[thumbId] = thumbAsset; } return { ...p, assets, scenes: { ...p.scenes, [sceneId]: { ...scene, previewAssetId: finalAssetId, previewAssetType: finalAsset.type, previewThumbAssetId: thumbId, previewVideoAutostart: finalAsset.type === 'video' ? scene.previewVideoAutostart : false, }, }, }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); const latestScene = latest.scenes[sceneId]; const applied = latestScene?.previewAssetId === finalAssetId && (thumbId === null || latestScene.previewThumbAssetId === thumbId); if (!applied) { await Promise.all( generatedRelPaths.map((relPath) => fs.unlink(path.join(open.cacheDir, relPath)).catch(() => undefined), ), ); } return { project: latest, changed: applied }; } async clearScenePreview(sceneId: SceneId): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const sc = open.project.scenes[sceneId]; if (!sc) throw new Error('Scene not found'); const oldId = sc.previewAssetId; const oldThumbId = sc.previewThumbAssetId ?? null; if (!oldId && !oldThumbId) { return open.project; } await this.updateProject((p) => { const drop = new Set(); if (oldId) drop.add(oldId); if (oldThumbId) drop.add(oldThumbId); const assets = Object.fromEntries( Object.entries(p.assets).filter(([k]) => !drop.has(k as AssetId)), ) as Record; return { ...p, assets, scenes: { ...p.scenes, [sceneId]: { ...p.scenes[sceneId], previewAssetId: null, previewAssetType: null, previewThumbAssetId: null, previewVideoAutostart: false, }, }, }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async updateProject(mutator: (draft: Project) => Project): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const prev = open.project; let next = mutator(prev); next = await reconcileAssetFiles(prev, next, open.cacheDir); open.project = next; await this.writeCacheProject(open.cacheDir, next); this.queueSave(); return next; } async updateScene(sceneId: SceneId, patch: ScenePatch): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const existing = open.project.scenes[sceneId]; const base: Scene = existing ?? ({ id: sceneId, title: '', description: '', media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, connections: [], layout: { x: 0, y: 0 }, previewAssetId: null, previewAssetType: null, previewThumbAssetId: null, previewVideoAutostart: false, previewRotationDeg: 0, darkenScene: false, traps: [], } satisfies Scene); const next: Scene = { ...base, traps: base.traps ?? [], ...(patch.title !== undefined ? { title: patch.title } : null), ...(patch.description !== undefined ? { description: patch.description } : null), ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null), ...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null), ...(patch.previewThumbAssetId !== undefined ? { previewThumbAssetId: patch.previewThumbAssetId } : null), ...(patch.previewVideoAutostart !== undefined ? { previewVideoAutostart: patch.previewVideoAutostart } : null), ...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.traps !== undefined ? { traps: patch.traps .map((t) => normalizeSceneTrap(t)) .filter((t): t is SceneTrap => Boolean(t)), } : null), ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...base.media, ...patch.media } } : null), ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), }; await this.updateProject((p) => { const scenes = { ...p.scenes, [sceneId]: next }; const sceneListOrder = existing != null ? reconcileSceneListOrder(scenes, p.sceneListOrder) : prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId); return { ...p, scenes, sceneListOrder }; }); return next; } async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const existing = open.project.scenes[sceneId]; if (!existing) throw new Error('Scene not found'); const next: Scene = { ...existing, connections: [...connections] }; await this.updateProject((p) => ({ ...p, scenes: { ...p.scenes, [sceneId]: next } })); return next; } async deleteScene(sceneId: SceneId): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); if (!open.project.scenes[sceneId]) throw new Error('Scene not found'); await this.updateProject((p) => { const sceneGraphNodes = p.sceneGraphNodes.filter((n) => n.sceneId !== sceneId); const nodeIds = new Set(sceneGraphNodes.map((n) => n.id)); const sceneGraphEdges = p.sceneGraphEdges.filter( (e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId), ); const scenes = Object.fromEntries( (Object.entries(p.scenes) as [SceneId, Scene][]).filter(([id]) => id !== sceneId), ) as Record; const withGraph: Project = { ...p, scenes, sceneGraphNodes, sceneGraphEdges, }; const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); const nextScenes = applyConnectionSets(withGraph.scenes, outgoing); let currentSceneId = p.currentSceneId; if (currentSceneId === sceneId) { const ids = Object.keys(nextScenes) as SceneId[]; currentSceneId = ids[0] ?? null; } const removedSideStarts = p.sceneGraphNodes.filter( (n) => n.sceneId === sceneId && n.isSideStoryStart, ); let npcs = clearNpcBindingsForDeletedScene(p.npcs ?? [], sceneId); for (const side of removedSideStarts) { npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'side', startGraphNodeId: side.id, }); } const hadMainOnScene = p.sceneGraphNodes.some((n) => n.sceneId === sceneId && n.isStartScene); if (hadMainOnScene) { npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' }); } return { ...withGraph, scenes: nextScenes, npcs, sceneListOrder: removeFromSceneListOrder( reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder), sceneId, ), currentSceneId, }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async setSceneListOrder(sceneListOrder: SceneId[]): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => ({ ...p, sceneListOrder: reconcileSceneListOrder(p.scenes, sceneListOrder), })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async updateSceneGraphNodePosition(nodeId: GraphNodeId, x: number, y: number): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => ({ ...p, sceneGraphNodes: p.sceneGraphNodes.map((n) => (n.id === nodeId ? { ...n, x, y } : n)), })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async addSceneGraphNode(sceneId: SceneId, x: number, y: number): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); if (!open.project.scenes[sceneId]) throw new Error('Scene not found'); const node: SceneGraphNode = { id: asGraphNodeId(`gn_${this.randomId()}`), sceneId, x, y, isStartScene: false, isSideStoryStart: false, sideStoryLineTitle: '', }; await this.updateProject((p) => ({ ...p, sceneGraphNodes: [...p.sceneGraphNodes, node] })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async setSceneGraphNodeStart(graphNodeId: GraphNodeId | null): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) { throw new Error('Graph node not found'); } const prevMain = open.project.sceneGraphNodes.find((n) => n.isStartScene); const clearingMain = graphNodeId === null || (prevMain && prevMain.id !== graphNodeId); await this.updateProject((p) => { let npcs = p.npcs ?? []; if (clearingMain && prevMain) { npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' }); } const demotedSides = p.sceneGraphNodes.filter( (n) => n.isSideStoryStart && graphNodeId !== null && n.id === graphNodeId, ); for (const side of demotedSides) { npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'side', startGraphNodeId: side.id, }); } return { ...p, npcs, sceneGraphNodes: p.sceneGraphNodes.map((n) => { const isMain = graphNodeId !== null && n.id === graphNodeId; if (isMain) { return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' }; } return { ...n, isStartScene: false }; }), }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async setSceneGraphNodeSideStoryStart(graphNodeId: GraphNodeId | null): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); if (graphNodeId === null) { throw new Error('Graph node id required'); } if (!open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) { throw new Error('Graph node not found'); } const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId); if (!node) throw new Error('Graph node not found'); const enabling = !node.isSideStoryStart; if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) { return open.project; } await this.updateProject((p) => { let npcs = p.npcs ?? []; if (!enabling) { npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'side', startGraphNodeId: graphNodeId, }); } if (enabling && node.isStartScene) { npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' }); } return { ...p, npcs, sceneGraphNodes: p.sceneGraphNodes.map((n) => { if (n.id !== graphNodeId) return n; if (enabling) { return { ...n, isSideStoryStart: true, isStartScene: false }; } return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' }; }), }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async updateSideStoryLineTitle(graphNodeId: GraphNodeId, title: string): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId); if (!node?.isSideStoryStart) throw new Error('Not a side story start node'); await this.updateProject((p) => ({ ...p, sceneGraphNodes: p.sceneGraphNodes.map((n) => n.id === graphNodeId ? { ...n, sideStoryLineTitle: title } : n, ), })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async removeSceneGraphNode(nodeId: GraphNodeId): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const node = open.project.sceneGraphNodes.find((n) => n.id === nodeId); if (!node) throw new Error('Graph node not found'); if (node.isSideStoryStart) { const outgoing = open.project.sceneGraphEdges .filter((e) => e.sourceGraphNodeId === nodeId) .sort((a, b) => a.id.localeCompare(b.id)); if (outgoing.length > 0) { const newStartId = outgoing[0]?.targetGraphNodeId; if (!newStartId) throw new Error('Invalid graph edge'); const nextNodes = open.project.sceneGraphNodes .filter((gn) => gn.id !== nodeId) .map((gn) => gn.id === newStartId ? { ...gn, isSideStoryStart: true, isStartScene: false, sideStoryLineTitle: node.sideStoryLineTitle, } : gn, ); const nextEdges = open.project.sceneGraphEdges.filter( (e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId, ); await this.updateProject((p) => { const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges }; const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); const npcs = (p.npcs ?? []).map((n) => { if ( n.binding?.kind === 'storyline' && n.binding.storyline.kind === 'side' && n.binding.storyline.startGraphNodeId === nodeId ) { return { ...n, binding: { kind: 'storyline' as const, storyline: { kind: 'side' as const, startGraphNodeId: newStartId }, }, }; } return n; }); return { ...withGraph, npcs, scenes: applyConnectionSets(withGraph.scenes, out) }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } const removeIds = getSideStoryComponentNodeIds( open.project.sceneGraphNodes, open.project.sceneGraphEdges, nodeId, ); const nextNodes = open.project.sceneGraphNodes.filter((gn) => !removeIds.has(gn.id)); const nextEdges = open.project.sceneGraphEdges.filter( (e) => !removeIds.has(e.sourceGraphNodeId) && !removeIds.has(e.targetGraphNodeId), ); await this.updateProject((p) => { const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges }; const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } const nextNodes = open.project.sceneGraphNodes.filter((gn) => gn.id !== nodeId); const nextEdges = open.project.sceneGraphEdges.filter( (e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId, ); await this.updateProject((p) => { const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges }; const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async addSceneGraphEdge(sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const { sceneGraphNodes, sceneGraphEdges } = open.project; if (isSceneGraphEdgeRejected(sceneGraphNodes, sceneGraphEdges, sourceGraphNodeId, targetGraphNodeId)) { return open.project; } const edge: SceneGraphEdge = { id: `e_${this.randomId()}`, sourceGraphNodeId, targetGraphNodeId, }; await this.updateProject((p) => { const nextEdges = [...p.sceneGraphEdges, edge]; const withGraph = { ...p, sceneGraphEdges: nextEdges }; const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async removeSceneGraphEdge(edgeId: string): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => { const nextEdges = p.sceneGraphEdges.filter((e) => e.id !== edgeId); const withGraph = { ...p, sceneGraphEdges: nextEdges }; const outgoing = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, outgoing) }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } /** * Copies files into cache `assets/` and registers them on the scene + project.assets. */ async importMediaFiles( sceneId: SceneId, filePaths: string[], ): Promise<{ project: Project; imported: MediaAsset[] }> { const open = this.openProject; if (!open) throw new Error('No open project'); const existingScene = open.project.scenes[sceneId]; if (!existingScene) throw new Error('Scene not found'); if (filePaths.length === 0) { return { project: open.project, imported: [] }; } const staged: MediaAsset[] = []; for (const filePath of filePaths) { const kind = classifyMediaPath(filePath); if (kind?.type !== 'audio') continue; const buf = await fs.readFile(filePath); const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const id = asAssetId(this.randomId()); const orig = path.basename(filePath); const safeOrig = sanitizeFileName(orig); const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.copyFile(filePath, abs); staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length)); } if (staged.length === 0) { return { project: open.project, imported: [] }; } await this.updateProject((p) => { const sc = p.scenes[sceneId]; if (!sc) throw new Error('Scene not found'); const assets = { ...p.assets }; const media = { videos: [...sc.media.videos], audios: [...sc.media.audios], }; for (const asset of staged) { assets[asset.id] = asset; if (asset.type === 'video') media.videos.push(asset.id); else media.audios.push({ assetId: asset.id, autoplay: sc.settings.autoplayAudio, loop: sc.settings.loopAudio, }); } return { ...p, assets, scenes: { ...p.scenes, [sceneId]: { ...sc, media } }, }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return { project: latest, imported: staged }; } /** * Copies audio files into cache `assets/` and registers them on the project as campaign audio. */ async importCampaignAudioFiles(filePaths: string[]): Promise<{ project: Project; imported: MediaAsset[] }> { const open = this.openProject; if (!open) throw new Error('No open project'); if (filePaths.length === 0) { return { project: open.project, imported: [] }; } const staged: MediaAsset[] = []; for (const filePath of filePaths) { const kind = classifyMediaPath(filePath); if (kind?.type !== 'audio') continue; const buf = await fs.readFile(filePath); const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const id = asAssetId(this.randomId()); const orig = path.basename(filePath); const safeOrig = sanitizeFileName(orig); const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.copyFile(filePath, abs); staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length)); } if (staged.length === 0) { return { project: open.project, imported: [] }; } await this.updateProject((p) => { const assets = { ...p.assets }; const campaignAudios = [...p.campaignAudios]; for (const asset of staged) { assets[asset.id] = asset; if (asset.type !== 'audio') continue; campaignAudios.push({ assetId: asset.id, autoplay: true, loop: true }); } return { ...p, assets, campaignAudios }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return { project: latest, imported: staged }; } async setCampaignAudios(audios: Project['campaignAudios']): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } /** * Создаёт или обновляет материал кампании. * При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку. */ async upsertMaterial( input: { materialId?: MaterialId; name: string; filePath?: string; }, onProgress?: (p: { percent: number; stage: string; detail?: string }) => void, ): Promise { const report = (percent: number, stage: string, detail?: string) => { onProgress?.({ percent, stage, ...(detail ? { detail } : {}) }); }; const open = this.openProject; if (!open) throw new Error('No open project'); const name = input.name.trim(); if (name.length < 1) throw new Error('Material name is required'); const nameKey = name.toLowerCase(); const existing = open.project.materials ?? []; const editingId = input.materialId ?? null; if (existing.some((m) => m.id !== editingId && m.name.trim().toLowerCase() === nameKey)) { throw new Error('Material name already exists'); } report(2, 'start', 'Подождите…'); let nextAssetId: AssetId | null = null; let stagedAsset: MediaAsset | null = null; if (input.filePath) { const kind = classifyMediaPath(input.filePath); if (kind?.type !== 'image') throw new Error('Material must be an image (png/jpg/webp)'); const ext = path.extname(input.filePath).toLowerCase(); if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { throw new Error('Material must be an image (png/jpg/webp)'); } report(8, 'read', 'Чтение изображения…'); let buf = await fs.readFile(input.filePath); report(18, 'optimize', 'Оптимизация изображения…'); try { const opt = await optimizeImageBufferVisuallyLossless(buf); if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer); } catch { // keep original buffer } report(72, 'write', 'Сохранение файла…'); const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const id = asAssetId(this.randomId()); const orig = path.basename(input.filePath); const safeOrig = sanitizeFileName(orig); const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, buf); stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); nextAssetId = id; } report(88, 'project', 'Обновление проекта…'); await this.updateProject((p) => { const materials = [...(p.materials ?? [])]; const assets = { ...p.assets }; if (stagedAsset) assets[stagedAsset.id] = stagedAsset; if (editingId) { const idx = materials.findIndex((m) => m.id === editingId); if (idx < 0) throw new Error('Material not found'); const prev = materials[idx]!; const assetId = nextAssetId ?? prev.assetId; materials[idx] = { id: editingId, name, assetId, rotationDeg: prev.rotationDeg ?? 0, ...(prev.legend ? { legend: prev.legend } : {}), }; } else { if (!nextAssetId) throw new Error('Material image is required'); materials.push({ id: asMaterialId(`mat_${this.randomId()}`), name, assetId: nextAssetId, rotationDeg: 0, }); } return { ...p, assets, materials }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); report(100, 'done', 'Готово'); return latest; } async setMaterialRotation( materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270, ): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => { const materials = (p.materials ?? []).map((m) => m.id === materialId ? { ...m, rotationDeg } : m, ); return { ...p, materials }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async setMaterialLegend(materialId: MaterialId, legend: MaterialLegend | null): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const normalized = legend ? normalizeMaterialLegend(legend) : undefined; await this.updateProject((p) => { const materials = (p.materials ?? []).map((m) => { if (m.id !== materialId) return m; if ( !normalized || (!normalized.enabled && normalized.items.length === 0 && normalized.markers.length === 0) ) { const { legend: _drop, ...rest } = m; void _drop; return rest; } return { ...m, legend: normalized }; }); return { ...p, materials }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async deleteMaterial(materialId: MaterialId): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => ({ ...p, materials: (p.materials ?? []).filter((m) => m.id !== materialId), })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async setMaterialsOrder(materialIds: MaterialId[]): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => { const byId = new Map((p.materials ?? []).map((m) => [m.id, m])); const next: NonNullable = []; for (const id of materialIds) { const m = byId.get(id); if (m) { next.push(m); byId.delete(id); } } for (const m of byId.values()) next.push(m); return { ...p, materials: next }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } /** * Создаёт или обновляет НПС. * При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар. */ async upsertNpc(input: { npcId?: NpcId; name: string; description?: string; filePath?: string; groupId?: NpcGroupId | null; binding?: NpcBinding; }): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const name = input.name.trim(); if (name.length < 1) throw new Error('NPC name is required'); const nameKey = name.toLowerCase(); const existing = open.project.npcs ?? []; const editingId = input.npcId ?? null; if (existing.some((n) => n.id !== editingId && n.name.trim().toLowerCase() === nameKey)) { throw new Error('NPC name already exists'); } let nextAssetId: AssetId | null = null; let stagedAsset: MediaAsset | null = null; if (input.filePath) { const kind = classifyMediaPath(input.filePath); if (kind?.type !== 'image') throw new Error('NPC avatar must be an image (png/jpg/webp)'); const ext = path.extname(input.filePath).toLowerCase(); if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { throw new Error('NPC avatar must be an image (png/jpg/webp)'); } let buf = await fs.readFile(input.filePath); try { const opt = await optimizeImageBufferVisuallyLossless(buf); if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer); } catch { // keep original buffer } const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const id = asAssetId(this.randomId()); const orig = path.basename(input.filePath); const safeOrig = sanitizeFileName(orig); const relPath = `assets/${id}_${safeOrig}`; const abs = path.join(open.cacheDir, relPath); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, buf); stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); nextAssetId = id; } await this.updateProject((p) => { const npcs = [...(p.npcs ?? [])]; const assets = { ...p.assets }; if (stagedAsset) assets[stagedAsset.id] = stagedAsset; const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id)); const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => { if (raw === undefined) return prev; if (raw === null) return null; return groupIds.has(raw) ? raw : null; }; if (editingId) { const idx = npcs.findIndex((n) => n.id === editingId); if (idx < 0) throw new Error('NPC not found'); const prev = npcs[idx]!; npcs[idx] = { ...prev, name, avatarAssetId: nextAssetId ?? prev.avatarAssetId, description: typeof input.description === 'string' ? input.description : prev.description, groupId: resolveGroup(input.groupId, prev.groupId), binding: input.binding !== undefined ? input.binding : prev.binding, }; } else { if (!nextAssetId) throw new Error('NPC avatar is required'); const count = npcs.length; npcs.push({ id: asNpcId(`npc_${this.randomId()}`), name, avatarAssetId: nextAssetId, description: typeof input.description === 'string' ? input.description : '', x: 80 + (count % 4) * 220, y: 80 + Math.floor(count / 4) * 200, groupId: resolveGroup(input.groupId, null), binding: input.binding ?? noneBinding(), }); } return { ...p, assets, npcs }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async updateNpcFields( npcId: NpcId, patch: { name?: string; description?: string; groupId?: NpcGroupId | null; binding?: NpcBinding; }, ): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const name = typeof patch.name === 'string' ? patch.name.trim() : undefined; if (name !== undefined) { if (name.length < 1) throw new Error('NPC name is required'); const nameKey = name.toLowerCase(); if ( (open.project.npcs ?? []).some( (n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey, ) ) { throw new Error('NPC name already exists'); } } await this.updateProject((p) => { const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id)); const npcs = (p.npcs ?? []).map((n) => { if (n.id !== npcId) return n; let groupId = n.groupId; if (patch.groupId !== undefined) { groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null; } return { ...n, ...(name !== undefined ? { name } : {}), ...(typeof patch.description === 'string' ? { description: patch.description } : {}), groupId, ...(patch.binding !== undefined ? { binding: patch.binding } : {}), }; }); return { ...p, npcs }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async updateNpcPosition(npcId: NpcId, x: number, y: number): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => ({ ...p, npcs: (p.npcs ?? []).map((n) => (n.id === npcId ? { ...n, x, y } : n)), })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async deleteNpc(npcId: NpcId): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => ({ ...p, npcs: (p.npcs ?? []).filter((n) => n.id !== npcId), npcRelations: (p.npcRelations ?? []).filter( (r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId, ), })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async setNpcsOrder(npcIds: NpcId[]): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => { const byId = new Map((p.npcs ?? []).map((n) => [n.id, n])); const next: ProjectNpc[] = []; for (const id of npcIds) { const n = byId.get(id); if (n) { next.push(n); byId.delete(id); } } for (const n of byId.values()) next.push(n); return { ...p, npcs: next }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async upsertNpcGroup(input: { groupId?: NpcGroupId; name: string; color?: string; parentId?: NpcGroupId | null; }): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const name = input.name.trim(); if (name.length < 1) throw new Error('Group name is required'); const color = normalizeHexColor(input.color, DEFAULT_NPC_GROUP_COLOR); await this.updateProject((p) => { const groups = [...(p.npcGroups ?? [])]; const editingId = input.groupId ?? null; const parentId = input.parentId === undefined ? editingId ? (groups.find((g) => g.id === editingId)?.parentId ?? null) : null : input.parentId; if (parentId && !groups.some((g) => g.id === parentId)) { throw new Error('Parent group not found'); } if (editingId && parentId && wouldCreateGroupCycle(groups, editingId, parentId)) { throw new Error('Invalid group parent'); } const nameKey = name.toLowerCase(); const siblingConflict = groups.some( (g) => g.id !== editingId && g.parentId === parentId && g.name.trim().toLowerCase() === nameKey, ); if (siblingConflict) throw new Error('Group name already exists'); if (editingId) { const idx = groups.findIndex((g) => g.id === editingId); if (idx < 0) throw new Error('Group not found'); groups[idx] = { ...groups[idx]!, name, color, parentId }; } else { groups.push({ id: asNpcGroupId(`ng_${this.randomId()}`), name, color, parentId, }); } return { ...p, npcGroups: groups }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async deleteNpcGroup(groupId: NpcGroupId): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => { const groups = p.npcGroups ?? []; if (!groups.some((g) => g.id === groupId)) throw new Error('Group not found'); const parentOfDeleted = groups.find((g) => g.id === groupId)?.parentId ?? null; const nextGroups = groups .filter((g) => g.id !== groupId) .map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g)); const npcs = (p.npcs ?? []).map((n) => n.groupId === groupId ? { ...n, groupId: null } : n, ); return { ...p, npcGroups: nextGroups, npcs }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async setNpcGroupsOrder(groupIds: NpcGroupId[]): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => { const byId = new Map((p.npcGroups ?? []).map((g) => [g.id, g])); const next: ProjectNpcGroup[] = []; for (const id of groupIds) { const g = byId.get(id); if (g) { next.push(g); byId.delete(id); } } for (const g of byId.values()) next.push(g); return { ...p, npcGroups: next }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async upsertNpcRelation(input: { relationId?: NpcRelationId; sourceNpcId: NpcId; targetNpcId: NpcId; label: string; }): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const label = input.label.trim(); if (label.length < 1) throw new Error('Relation label is required'); if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself'); const npcs = open.project.npcs ?? []; if ( !npcs.some((n) => n.id === input.sourceNpcId) || !npcs.some((n) => n.id === input.targetNpcId) ) { throw new Error('NPC not found'); } await this.updateProject((p) => { const relations = [...(p.npcRelations ?? [])]; if (input.relationId) { const idx = relations.findIndex((r) => r.id === input.relationId); if (idx < 0) throw new Error('Relation not found'); relations[idx] = { ...relations[idx]!, label }; } else { relations.push({ id: asNpcRelationId(`nrel_${this.randomId()}`), sourceNpcId: input.sourceNpcId, targetNpcId: input.targetNpcId, label, }); } return { ...p, npcRelations: relations }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async deleteNpcRelation(relationId: NpcRelationId): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); await this.updateProject((p) => ({ ...p, npcRelations: (p.npcRelations ?? []).filter((r) => r.id !== relationId), })); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } async saveNow(): Promise { const open = this.openProject; if (!open) return; await this.projectWriteChain; await this.enqueuePack(open.cacheDir, open.zipPath); } async closeOpenProject(): Promise { return this.enqueueProjectSwitch(async () => { this.projectSession += 1; if (!this.openProject) return; await this.saveNow(); await this.drainSavePipeline(); this.saveQueued = false; this.openProject = null; }); } async renameOpenProject(name: string, fileBaseName: string): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); const nextName = name.trim(); const nextBase = fileBaseName.trim(); if (nextName.length < 3) { throw new Error('Название проекта должно быть не короче 3 символов'); } if (nextBase.length < 3) { throw new Error('Название файла проекта должно быть не короче 3 символов'); } const sanitizedBase = sanitizeFileName(nextBase); if (sanitizedBase !== nextBase) { throw new Error('Название файла содержит недопустимые символы'); } await this.ensureRoots(); const root = getProjectsRootDir(); const list = await this.listProjects(); const oldBase = open.project.meta.fileBaseName; const nextFileName = projectZipFileNameFromBase(sanitizedBase); const nameClash = list.some( (p) => p.id !== open.id && p.name.trim().toLowerCase() === nextName.toLowerCase(), ); if (nameClash) { throw new Error('Проект с таким названием уже существует'); } const fileClash = list.some( (p) => p.id !== open.id && p.fileName.trim().toLowerCase() === nextFileName.toLowerCase(), ); if (fileClash) { throw new Error('Файл проекта с таким названием уже существует'); } // Update project meta first (will auto-save). await this.updateProject((p) => ({ ...p, meta: { ...p.meta, name: nextName, fileBaseName: sanitizedBase }, })); // Rename zip on disk (cache stays the same, just points to new zip path). if (nextBase !== oldBase) { const nextZipPath = path.join(root, nextFileName); await this.projectWriteChain; await this.enqueuePack(open.cacheDir, open.zipPath); await replaceFileAtomic(open.zipPath, nextZipPath); open.zipPath = nextZipPath; } const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); return latest; } private queueSave() { if (this.saveQueued) return; this.saveQueued = true; if (this.saveDebounceTimer) { clearTimeout(this.saveDebounceTimer); } this.saveDebounceTimer = setTimeout(() => { this.saveDebounceTimer = null; void this.flushSave(); }, 250); } private async flushSave() { if (this.saving) return; const open = this.openProject; if (!open) return; this.saveQueued = false; this.saving = true; try { await this.projectWriteChain; await this.enqueuePack(open.cacheDir, open.zipPath); } finally { this.saving = false; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- may change during async save if (this.saveQueued) { setTimeout(() => void this.flushSave(), 50); } } } private async writeCacheProject(cacheDir: string, project: Project): Promise { const sessionAtStart = this.projectSession; const run = async (): Promise => { await this.packChain; if (sessionAtStart !== this.projectSession) { return; } await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true }); const now = new Date().toISOString(); const withUpdated: Project = { ...project, meta: { ...project.meta, updatedAt: now, appVersion: getAppSemanticVersion(), }, }; const targetPath = path.join(cacheDir, 'project.json'); await atomicWriteFile(targetPath, JSON.stringify(withUpdated, null, 2)); if (sessionAtStart !== this.projectSession) { return; } if (this.openProject?.cacheDir === cacheDir) { this.openProject.project = withUpdated; } }; const chained = this.projectWriteChain.then(run); this.projectWriteChain = chained.catch(() => undefined); await chained; } private async packZipFromCache(cacheDir: string, zipPath: string): Promise { const tmpPath = `${zipPath}.tmp`; await fs.mkdir(path.dirname(zipPath), { recursive: true }); await zipDir(cacheDir, tmpPath); const st = await fs.stat(tmpPath).catch(() => null); if (!st?.isFile() || st.size < 22) { await fs.unlink(tmpPath).catch(() => undefined); throw new Error('Сборка архива проекта не удалась (пустой или повреждённый временный файл)'); } await replaceFileAtomic(tmpPath, zipPath); } /** * Копирует внешний `.dnd.zip` в каталог проектов и открывает его. * Если архив уже лежит в `projects`, только открывает. * При конфликте `id` с другим файлом перезаписывает `project.json` в копии с новым id. */ async importProjectFromExternalZip( sourcePath: string, onProgress?: (p: { stage: 'copy' | 'unzip' | 'done'; percent: number; detail?: string }) => void, ): Promise { await this.ensureRoots(); const resolved = path.resolve(sourcePath); const st = await fs.stat(resolved).catch(() => null); if (!st?.isFile()) { throw new Error('Файл проекта не найден'); } if (!isProjectZipFileName(resolved)) { throw new Error('Ожидается файл проекта с расширением .ttrpg.zip или .dnd.zip'); } const root = getProjectsRootDir(); const rootNorm = path.normalize(root).toLowerCase(); const dirNorm = path.normalize(path.dirname(resolved)).toLowerCase(); const baseName = path.basename(resolved); let destPath: string; let destFileName: string; if (dirNorm === rootNorm && isProjectZipFileName(baseName)) { destPath = resolved; destFileName = baseName; } else { destFileName = await uniqueProjectZipFileNameInRoot(root, baseName); destPath = path.join(root, destFileName); if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' }); await copyFileWithProgress(resolved, destPath, (pct) => { if (!onProgress) return; // Copy is ~70% of the operation; unzip/open happens after. onProgress({ stage: 'copy', percent: Math.max(1, Math.min(70, pct)), detail: 'Копирование…' }); }); } let project = await readProjectJsonFromZip(destPath); project = normalizeProject(project); const entries = await this.listProjects(); const othersWithSameId = entries.filter((e) => e.id === project.id && e.fileName !== destFileName); if (othersWithSameId.length > 0) { const newId = asProjectId(this.randomId()); const stem = stripProjectZipExtension(destFileName); project = { ...project, id: newId, meta: { ...project.meta, fileBaseName: project.meta.fileBaseName.trim().length ? project.meta.fileBaseName : stem, }, }; await rewriteProjectJsonInZip(destPath, project); } this.projectSession += 1; const opened = await this.openProjectByIdWithProgress(project.id, (pct) => { if (onProgress) onProgress({ stage: 'unzip', percent: pct, detail: 'Распаковка…' }); }); if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' }); return opened; } /** * Импорт мира/модуля Foundry VTT (папка или .zip/.fvtt) в новый проект. * Создаёт `.ttrpg.zip`, открывает проект и возвращает его. */ async importProjectFromFoundry( sourcePath: string, onProgress?: (p: FoundryImportProgress) => void, ): Promise { await this.ensureRoots(); const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress); try { this.projectSession += 1; const projectId = asProjectId(this.randomId()); const cacheDir = path.join(getProjectsCacheRootDir(), projectId); await fs.rm(cacheDir, { recursive: true, force: true }); await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true }); const { project } = await buildProjectFromFoundryDocuments( loaded.manifest, loaded.docs, cacheDir, onProgress, { projectId }, ); const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(project.meta.fileBaseName)); const projectPath = path.join(cacheDir, 'project.json'); this.openProject = { id: project.id, zipPath, cacheDir, projectPath, project, }; await this.writeCacheProject(cacheDir, project); onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' }); await this.enqueuePack(cacheDir, zipPath); onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' }); return this.openProject.project; } finally { await loaded.cleanup(); } } /** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */ async exportProjectZipToPath( projectId: ProjectId, destinationPath: string, onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, ): Promise { await this.ensureRoots(); // If exporting the currently open project, make sure pending debounced pack is flushed. if (this.openProject?.id === projectId) { await this.saveNow(); } const list = await this.listProjects(); const entry = list.find((p) => p.id === projectId); if (!entry) { throw new Error('Проект не найден'); } const src = path.join(getProjectsRootDir(), entry.fileName); const dest = path.resolve(destinationPath); await fs.mkdir(path.dirname(dest), { recursive: true }); if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' }); await copyFileWithProgress(src, dest, (pct) => { if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' }); }); if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' }); } /** Удаляет архив проекта и кэш распаковки с диска. Если проект открыт — сбрасывает сессию. */ async deleteProjectById(projectId: ProjectId): Promise { await this.ensureRoots(); const list = await this.listProjects(); const entry = list.find((p) => p.id === projectId); if (!entry) { throw new Error('Проект не найден'); } const zipPath = path.join(getProjectsRootDir(), entry.fileName); const cacheDir = path.join(getProjectsCacheRootDir(), projectId); if (this.openProject?.id === projectId) { await this.drainSavePipeline(); this.saveQueued = false; this.openProject = null; this.projectSession += 1; } await rmWithRetries(fs.rm, zipPath, { force: true }); await rmWithRetries(fs.rm, cacheDir, { recursive: true, force: true }); // Если проект подтянулся миграцией из legacy userData (другое имя приложения), // то после удаления из текущей папки он может снова появиться при следующем ensureRoots(). // Поэтому удаляем и legacy-копии архива. for (const legacyRoot of getLegacyProjectsRootDirs()) { const legacyZipPath = path.join(legacyRoot, entry.fileName); try { await rmWithRetries(fs.rm, legacyZipPath, { force: true }); } catch { /* ignore */ } } } /** Снимок проекта для экспорта/списка линий (распаковывает во временный кэш при необходимости). */ private async loadProjectSnapshot( projectId: ProjectId, ): Promise<{ project: Project; cacheDir: string; ownsCache: boolean }> { await this.drainSavePipeline(); if (this.openProject?.id === projectId) { await this.saveNow(); return { project: structuredClone(this.openProject.project), cacheDir: this.openProject.cacheDir, ownsCache: false, }; } const list = await this.listProjects(); const entry = list.find((p) => p.id === projectId); if (!entry) throw new Error('Проект не найден'); const zipPath = path.join(getProjectsRootDir(), entry.fileName); const project = normalizeProject(await readProjectJsonFromZip(zipPath)); const cacheDir = path.join(tmpdir(), `dnd_snap_${crypto.randomBytes(8).toString('hex')}`); await unzipToDir(zipPath, cacheDir); return { project, cacheDir, ownsCache: true }; } async getProjectStorylines( projectId: ProjectId, labels: StorylineLabels, ): Promise { const snap = await this.loadProjectSnapshot(projectId); try { return listExportableStorylines(snap.project, labels); } finally { if (snap.ownsCache) { await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined); } } } async exportStorylinesZipToPath( projectId: ProjectId, selections: StorylineSelection[], destinationPath: string, labels: StorylineLabels, onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void, ): Promise { if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия'); await this.ensureRoots(); const snap = await this.loadProjectSnapshot(projectId); const exportCache = path.join(tmpdir(), `dnd_pexport_${crypto.randomBytes(8).toString('hex')}`); try { const list = await this.listProjects(); const entry = list.find((p) => p.id === projectId); const partial = buildPartialExportProject(snap.project, selections, { newProjectId: newExportBundleProjectId(), exportTitle: entry?.name ?? snap.project.meta.name, labels, }); await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true }); const assetIds = Object.keys(partial.assets) as AssetId[]; for (let i = 0; i < assetIds.length; i += 1) { const id = assetIds[i]!; const a = partial.assets[id]; if (!a) continue; const srcAbs = path.join(snap.cacheDir, a.relPath); const destAbs = path.join(exportCache, a.relPath); await fs.mkdir(path.dirname(destAbs), { recursive: true }); await fs.copyFile(srcAbs, destAbs); if (onProgress) { const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 80); onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' }); } } const now = new Date().toISOString(); const toWrite: Project = { ...partial, meta: { ...partial.meta, updatedAt: now, appVersion: getAppSemanticVersion() }, }; await atomicWriteFile(path.join(exportCache, 'project.json'), JSON.stringify(toWrite, null, 2)); const tmpZip = `${path.resolve(destinationPath)}.tmp`; if (onProgress) onProgress({ stage: 'zip', percent: 90, detail: 'Сборка архива…' }); await zipDir(exportCache, tmpZip); await replaceFileAtomic(tmpZip, path.resolve(destinationPath)); if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' }); } finally { await fs.rm(exportCache, { recursive: true, force: true }).catch(() => undefined); if (snap.ownsCache) { await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined); } } } async readExternalProjectForImport(sourcePath: string): Promise { const resolved = path.resolve(sourcePath); const st = await fs.stat(resolved).catch(() => null); if (!st?.isFile()) throw new Error('Файл проекта не найден'); if (!isProjectZipFileName(resolved)) { throw new Error('Ожидается файл проекта с расширением .ttrpg.zip или .dnd.zip'); } return normalizeProject(await readProjectJsonFromZip(resolved)); } async peekImportFromProjectId( sourceProjectId: ProjectId, labels: StorylineLabels, targetHasMainStart: boolean, ): Promise<{ sourceProjectId: ProjectId; projectName: string; storylines: StorylineListItem[]; sourceProject: Project; }> { const snap = await this.loadProjectSnapshot(sourceProjectId); try { return { sourceProjectId, projectName: snap.project.meta.name, storylines: listExportableStorylines(snap.project, labels).map((item) => { if (item.selection.kind === 'main' && targetHasMainStart) { return { ...item, disabled: true, disabledReason: 'main_exists' }; } return item; }), sourceProject: structuredClone(snap.project), }; } finally { if (snap.ownsCache) { await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined); } } } async peekImportFromZipPath( sourcePath: string, labels: StorylineLabels, targetHasMainStart: boolean, ): Promise<{ filePath: string; projectName: string; storylines: StorylineListItem[]; sourceProject: Project; }> { const project = await this.readExternalProjectForImport(sourcePath); return { filePath: path.resolve(sourcePath), projectName: project.meta.name, storylines: listImportableStorylines(project, labels, targetHasMainStart), sourceProject: project, }; } private assertStorylineMergeAllowed(selections: StorylineSelection[]): void { if (!this.openProject) throw new Error('Нет открытого проекта'); if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия'); if ( selections.some((s) => s.kind === 'main') && this.openProject.project.sceneGraphNodes.some((n) => n.isStartScene) ) { throw new Error('В проекте уже есть основная сюжетная линия'); } } private async mergeStorylinesFromSourceCache( source: Project, sourceCache: string, selections: StorylineSelection[], sceneResolutions: SceneImportResolution[], onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, npcResolutions?: NpcImportResolution[], ): Promise<{ project: Project; report: StorylineImportMergeReport }> { if (!this.openProject) throw new Error('Нет открытого проекта'); const offsetX = computeGraphImportOffsetX(this.openProject.project); const { project: merged, report, assetCopies } = mergeStorylinesIntoProject( this.openProject.project, source, selections, sceneResolutions, { graphOffsetX: offsetX, ...(npcResolutions ? { npcResolutions } : {}), }, ); const targetCache = this.openProject.cacheDir; await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true }); for (let i = 0; i < assetCopies.length; i += 1) { const { fromId, toId } = assetCopies[i]!; const srcAsset = source.assets[fromId]; const tgtMeta = merged.assets[toId]; if (!srcAsset || !tgtMeta) continue; const srcAbs = path.join(sourceCache, srcAsset.relPath); const destAbs = path.join(targetCache, tgtMeta.relPath); await fs.mkdir(path.dirname(destAbs), { recursive: true }); await fs.copyFile(srcAbs, destAbs); if (onProgress) { const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90); onProgress({ stage: 'copy', percent: pct, detail: 'Копирование материалов…' }); } } const normalized = normalizeProject(merged); const reconciled = await reconcileAssetFiles(this.openProject.project, normalized, targetCache); await this.updateProject(() => reconciled); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' }); return { project: latest, report }; } async mergeStorylinesFromProjectId( sourceProjectId: ProjectId, selections: StorylineSelection[], sceneResolutions: SceneImportResolution[], onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, npcResolutions?: NpcImportResolution[], ): Promise<{ project: Project; report: StorylineImportMergeReport }> { this.assertStorylineMergeAllowed(selections); const snap = await this.loadProjectSnapshot(sourceProjectId); try { return await this.mergeStorylinesFromSourceCache( snap.project, snap.cacheDir, selections, sceneResolutions, onProgress, npcResolutions, ); } finally { if (snap.ownsCache) { await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined); } } } async mergeStorylinesFromExternalZip( sourcePath: string, selections: StorylineSelection[], sceneResolutions: SceneImportResolution[], onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, npcResolutions?: NpcImportResolution[], ): Promise<{ project: Project; report: StorylineImportMergeReport }> { this.assertStorylineMergeAllowed(selections); const source = await this.readExternalProjectForImport(sourcePath); const sourceCache = path.join(tmpdir(), `dnd_mimport_${crypto.randomBytes(8).toString('hex')}`); await unzipToDir(path.resolve(sourcePath), sourceCache); try { return await this.mergeStorylinesFromSourceCache( source, sourceCache, selections, sceneResolutions, onProgress, npcResolutions, ); } finally { await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined); } } private randomId(): string { return crypto.randomBytes(16).toString('hex'); } // id is stored in project.json inside the zip } function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map> { const gnMap = new Map(nodes.map((n) => [n.id, n])); const outgoing = new Map>(); for (const e of edges) { const a = gnMap.get(e.sourceGraphNodeId); const b = gnMap.get(e.targetGraphNodeId); if (!a || !b) continue; if (a.sceneId === b.sceneId) continue; let set = outgoing.get(a.sceneId); if (!set) { set = new Set(); outgoing.set(a.sceneId, set); } set.add(b.sceneId); } return outgoing; } function applyConnectionSets( scenes: Record, outgoing: Map>, ): Record { const next: Record = { ...scenes }; for (const sid of Object.keys(next) as SceneId[]) { const prev = next[sid]; if (prev === undefined) continue; const c = outgoing.get(sid); next[sid] = { ...prev, connections: c ? [...c] : [] }; } return next; } function migrateSceneGraphFromLegacy(scenes: Record): { sceneGraphNodes: SceneGraphNode[]; sceneGraphEdges: SceneGraphEdge[]; } { const sceneList = Object.values(scenes); const sceneGraphNodes: SceneGraphNode[] = sceneList.map((s) => ({ id: asGraphNodeId(`gn_legacy_${s.id}`), sceneId: s.id, x: s.layout.x, y: s.layout.y, isStartScene: false, isSideStoryStart: false, sideStoryLineTitle: '', })); const byScene = new Map(sceneGraphNodes.map((n) => [n.sceneId, n])); const sceneGraphEdges: SceneGraphEdge[] = []; for (const s of sceneList) { const srcGn = byScene.get(s.id); if (!srcGn) continue; for (const to of s.connections) { const tgtGn = byScene.get(to); if (!tgtGn) continue; if (srcGn.sceneId === tgtGn.sceneId) continue; sceneGraphEdges.push({ id: `e_legacy_${String(srcGn.id)}_${String(tgtGn.id)}`, sourceGraphNodeId: srcGn.id, targetGraphNodeId: tgtGn.id, }); } } return { sceneGraphNodes, sceneGraphEdges }; } /** Один флаг `isStartScene` на весь проект; лишние true сбрасываются. */ function normalizeSceneGraphNodeFlags(nodes: SceneGraphNode[]): SceneGraphNode[] { const withDefaults = nodes.map((n) => { const raw = n as unknown as { isStartScene?: boolean; isSideStoryStart?: boolean; sideStoryLineTitle?: string; }; const isStartScene = raw.isStartScene === true; const isSideStoryStart = raw.isSideStoryStart === true && !isStartScene; return { ...n, isStartScene, isSideStoryStart, sideStoryLineTitle: isSideStoryStart ? (raw.sideStoryLineTitle ?? '').trim() : '', }; }); const starters = withDefaults.filter((n) => n.isStartScene); if (starters.length <= 1) { return withDefaults; } const keep = starters[0]; if (!keep) return withDefaults; const keepId = keep.id; return withDefaults.map((n) => ({ ...n, isStartScene: n.id === keepId })); } function normalizeScene(s: Scene): Scene { const raw = s.media as unknown as { videos?: AssetId[]; audios?: unknown[] }; const layoutIn = (s as { layout?: Scene['layout'] }).layout; const rot = (s as unknown as { previewRotationDeg?: number }).previewRotationDeg; const previewRotationDeg: Scene['previewRotationDeg'] = rot === 90 || rot === 180 || rot === 270 ? rot : 0; const legacyPreviewId = (s as unknown as { previewImageAssetId?: AssetId | null }).previewImageAssetId ?? null; const previewAssetId = (s as unknown as { previewAssetId?: AssetId | null }).previewAssetId ?? legacyPreviewId; const previewAssetType = (s as unknown as { previewAssetType?: 'image' | 'video' | null }).previewAssetType ?? (legacyPreviewId ? 'image' : null); const previewVideoAutostart = Boolean( (s as unknown as { previewVideoAutostostart?: boolean; previewVideoAutostart?: boolean }) .previewVideoAutostart, ); const previewThumbAssetId = (s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null; const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene); const rawTraps = (s as unknown as { traps?: unknown[] }).traps; const traps = (Array.isArray(rawTraps) ? rawTraps : []) .map((t) => normalizeSceneTrap(t)) .filter((t): t is SceneTrap => Boolean(t)); const rawAudios = Array.isArray(raw.audios) ? raw.audios : []; const audios = rawAudios .map((a) => { if (typeof a === 'string') { return { assetId: a as AssetId, autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio), loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio), }; } if (a && typeof a === 'object') { const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean }; if (!obj.assetId) return null; return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) }; } return null; }) .filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x)); return { ...s, previewAssetId: previewAssetId ?? null, previewAssetType, previewThumbAssetId, previewVideoAutostart, previewRotationDeg, darkenScene, traps, layout: layoutIn ?? { x: 0, y: 0 }, media: { videos: raw.videos ?? [], audios, }, }; } function normalizeProject(p: Project): Project { const scenes: Record = {}; for (const sid of Object.keys(p.scenes) as SceneId[]) { const rawScene = p.scenes[sid]; if (!rawScene) continue; scenes[sid] = normalizeScene(rawScene); } const schemaVersion = (p.meta as { schemaVersion?: number }).schemaVersion ?? 1; const rawNodes = (p as { sceneGraphNodes?: SceneGraphNode[] }).sceneGraphNodes; const rawEdges = (p as { sceneGraphEdges?: SceneGraphEdge[] }).sceneGraphEdges; let sceneGraphNodes: SceneGraphNode[] = Array.isArray(rawNodes) ? rawNodes : []; let sceneGraphEdges: SceneGraphEdge[] = Array.isArray(rawEdges) ? rawEdges : []; const needsLegacyGraphMigration = schemaVersion < PROJECT_SCHEMA_VERSION && sceneGraphNodes.length === 0; if (needsLegacyGraphMigration) { const migrated = migrateSceneGraphFromLegacy(scenes); sceneGraphNodes = migrated.sceneGraphNodes; sceneGraphEdges = migrated.sceneGraphEdges; } sceneGraphNodes = normalizeSceneGraphNodeFlags(sceneGraphNodes); const currentGraphNodeId = (p as { currentGraphNodeId?: GraphNodeId | null }).currentGraphNodeId ?? null; const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios; const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : []) .map((a) => { if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false }; if (a && typeof a === 'object') { const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean }; if (!obj.assetId) return null; return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) }; } return null; }) .filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x)); const rawMaterials = (p as unknown as { materials?: unknown[] }).materials; const materials = (Array.isArray(rawMaterials) ? rawMaterials : []) .map((m) => { if (!m || typeof m !== 'object') return null; const obj = m as { id?: string; name?: string; assetId?: AssetId; rotationDeg?: number; legend?: unknown; }; if (!obj.id || !obj.assetId || typeof obj.name !== 'string') return null; const name = obj.name.trim(); if (!name) return null; const rot = obj.rotationDeg; const rotationDeg: 0 | 90 | 180 | 270 = rot === 90 || rot === 180 || rot === 270 ? rot : 0; const legend = normalizeMaterialLegend(obj.legend); return { id: asMaterialId(String(obj.id)), name, assetId: obj.assetId, rotationDeg, ...(legend ? { legend } : {}), }; }) .filter( ( x, ): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270; legend?: MaterialLegend; } => Boolean(x), ); const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups); const groupIdSet = new Set(npcGroups.map((g) => g.id)); const sceneIdSet = new Set(Object.keys(scenes) as SceneId[]); const sideStartIds = new Set( sceneGraphNodes.filter((n) => n.isSideStoryStart).map((n) => n.id), ); const hasMainStart = sceneGraphNodes.some((n) => n.isStartScene); const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs; const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : []) .map((n, index) => { if (!n || typeof n !== 'object') return null; const obj = n as { id?: string; name?: string; avatarAssetId?: AssetId; description?: string; x?: number; y?: number; groupId?: string | null; binding?: unknown; }; if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null; const name = obj.name.trim(); if (!name) return null; const x = typeof obj.x === 'number' && Number.isFinite(obj.x) ? obj.x : 80 + (index % 4) * 220; const y = typeof obj.y === 'number' && Number.isFinite(obj.y) ? obj.y : 80 + Math.floor(index / 4) * 200; return { id: asNpcId(String(obj.id)), name, avatarAssetId: obj.avatarAssetId, description: typeof obj.description === 'string' ? obj.description : '', x, y, groupId: resolveNpcGroupId(obj.groupId, groupIdSet), binding: normalizeNpcBinding(obj.binding, { sceneIds: sceneIdSet, sideStartIds, hasMainStart, }), }; }) .filter((x): x is ProjectNpc => Boolean(x)); const npcIdSet = new Set(npcs.map((n) => n.id)); const rawNpcRelations = (p as unknown as { npcRelations?: unknown[] }).npcRelations; const npcRelations: ProjectNpcRelation[] = (Array.isArray(rawNpcRelations) ? rawNpcRelations : []) .map((r) => { if (!r || typeof r !== 'object') return null; const obj = r as { id?: string; sourceNpcId?: string; targetNpcId?: string; /** legacy undirected fields — трактуем как source→target */ npcAId?: string; npcBId?: string; label?: string; }; const rawSource = obj.sourceNpcId ?? obj.npcAId; const rawTarget = obj.targetNpcId ?? obj.npcBId; if (!obj.id || !rawSource || !rawTarget || typeof obj.label !== 'string') return null; const label = obj.label.trim(); if (!label) return null; const sourceNpcId = asNpcId(String(rawSource)); const targetNpcId = asNpcId(String(rawTarget)); if (sourceNpcId === targetNpcId) return null; if (!npcIdSet.has(sourceNpcId) || !npcIdSet.has(targetNpcId)) return null; return { id: asNpcRelationId(String(obj.id)), sourceNpcId, targetNpcId, label }; }) .filter((x): x is ProjectNpcRelation => Boolean(x)); const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string }; const createdWithAppVersion = (() => { const c = metaRaw.createdWithAppVersion?.trim(); if (c && c.length > 0) return c; const a = metaRaw.appVersion?.trim(); if (a && a.length > 0) return a; return '0.0.0'; })(); return { ...p, meta: { ...p.meta, fileBaseName: (p.meta as unknown as { fileBaseName?: string }).fileBaseName?.trim().length ? (p.meta as unknown as { fileBaseName: string }).fileBaseName : '', createdWithAppVersion, schemaVersion: PROJECT_SCHEMA_VERSION, }, scenes, campaignAudios, materials, npcs, npcGroups, npcRelations, sceneGraphNodes, sceneGraphEdges, currentGraphNodeId, sceneListOrder: reconcileSceneListOrder( scenes, (p as { sceneListOrder?: SceneId[] }).sceneListOrder, ), }; } function sanitizeFileName(name: string): string { const trimmed = name.trim(); const safe = trimmed.replace(/[<>:"/\\|?*]/gu, '_'); return safe.length > 0 ? safe : 'Untitled'; } async function uniqueProjectZipFileNameInRoot(root: string, preferredBaseFileName: string): Promise { const stem = sanitizeFileName(stripProjectZipExtension(path.basename(preferredBaseFileName)) || 'project'); let candidate = projectZipFileNameFromBase(stem); let n = 0; for (;;) { try { await fs.access(path.join(root, candidate)); n += 1; candidate = projectZipFileNameFromBase(`${stem}_${String(n)}`); } catch { return candidate; } } } async function copyFileWithProgress( src: string, dest: string, onPercent: (pct: number) => void, ): Promise { const st = await fs.stat(src); const total = st.size || 0; if (total <= 0) { await fs.copyFile(src, dest); onPercent(100); return; } await fs.mkdir(path.dirname(dest), { recursive: true }); await new Promise((resolve, reject) => { let done = 0; const rs = fssync.createReadStream(src); const ws = fssync.createWriteStream(dest); const onErr = (e: unknown) => reject(e instanceof Error ? e : new Error(String(e))); rs.on('error', onErr); ws.on('error', onErr); rs.on('data', (chunk: Buffer) => { done += chunk.length; const pct = Math.round((done / total) * 100); try { onPercent(Math.max(0, Math.min(100, pct))); } catch { // ignore } }); ws.on('close', () => resolve()); rs.pipe(ws); }); } type MediaKind = { type: MediaAssetType; mime: string }; function classifyMediaPath(filePath: string): MediaKind | null { const ext = path.extname(filePath).toLowerCase(); switch (ext) { case '.png': return { type: 'image', mime: 'image/png' }; case '.jpg': case '.jpeg': return { type: 'image', mime: 'image/jpeg' }; case '.webp': return { type: 'image', mime: 'image/webp' }; case '.gif': return { type: 'image', mime: 'image/gif' }; case '.bmp': return { type: 'image', mime: 'image/bmp' }; case '.mp4': return { type: 'video', mime: 'video/mp4' }; case '.webm': return { type: 'video', mime: 'video/webm' }; case '.mov': return { type: 'video', mime: 'video/quicktime' }; case '.mp3': return { type: 'audio', mime: 'audio/mpeg' }; case '.wav': return { type: 'audio', mime: 'audio/wav' }; case '.ogg': return { type: 'audio', mime: 'audio/ogg' }; case '.m4a': return { type: 'audio', mime: 'audio/mp4' }; case '.aac': return { type: 'audio', mime: 'audio/aac' }; default: return null; } } function buildMediaAsset( id: AssetId, kind: MediaKind, originalName: string, relPath: string, sha256: string, sizeBytes: number, ): MediaAsset { const createdAt = new Date().toISOString(); const base = { id, mime: kind.mime, originalName, relPath, sha256, sizeBytes, createdAt, }; if (kind.type === 'image') return { ...base, type: 'image' }; if (kind.type === 'video') return { ...base, type: 'video' }; return { ...base, type: 'audio' }; } async function atomicWriteFile(filePath: string, contents: string): Promise { const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); const tmp = path.join(dir, `.tmp_${path.basename(filePath)}_${crypto.randomBytes(8).toString('hex')}`); await fs.writeFile(tmp, contents, 'utf8'); await replaceFileAtomic(tmp, filePath); } /** Уже сжатые контейнеры/кодеки — в ZIP кладём без deflate, качество не трогаем; project.json и сырьё — deflate 9. */ function zipOptionsForRelativeEntry(rel: string): { compressionLevel: number } { const norm = rel.replace(/\\/gu, '/').toLowerCase(); if (norm === 'project.json') { return { compressionLevel: 9 }; } if (norm.startsWith('assets/')) { const ext = path.extname(norm).toLowerCase(); if ( ext === '.jpg' || ext === '.jpeg' || ext === '.mp4' || ext === '.webm' || ext === '.mov' || ext === '.mp3' || ext === '.m4a' || ext === '.aac' || ext === '.ogg' || ext === '.webp' || ext === '.gif' ) { return { compressionLevel: 0 }; } } return { compressionLevel: 9 }; } async function zipDir(srcDir: string, outZipPath: string): Promise { const zipfile = new ZipFile(); const all = await listFilesRecursive(srcDir); for (const abs of all) { const rel = path.relative(srcDir, abs).replace(/\\/gu, '/'); zipfile.addFile(abs, rel, zipOptionsForRelativeEntry(rel)); } await fs.mkdir(path.dirname(outZipPath), { recursive: true }); const out = fssync.createWriteStream(outZipPath); const done = new Promise((resolve, reject) => { out.on('close', resolve); out.on('error', reject); }); zipfile.outputStream.pipe(out); zipfile.end(); await done; } async function rewriteProjectJsonInZip(zipPath: string, project: Project): Promise { const work = path.join(tmpdir(), `dnd_rewrite_${crypto.randomBytes(8).toString('hex')}`); await fs.mkdir(work, { recursive: true }); try { await unzipToDir(zipPath, work); await atomicWriteFile(path.join(work, 'project.json'), JSON.stringify(project, null, 2)); const outTmp = `${zipPath}.rewrite.tmp`; await zipDir(work, outTmp); await replaceFileAtomic(outTmp, zipPath); } finally { await fs.rm(work, { recursive: true, force: true }); } } async function listFilesRecursive(dir: string): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); const out: string[] = []; for (const e of entries) { if (e.name.startsWith('.tmp_')) { continue; } const abs = path.join(dir, e.name); if (e.isDirectory()) { out.push(...(await listFilesRecursive(abs))); } else if (e.isFile()) { out.push(abs); } } return out; }