From e08f5ef550b73dba9dbbb3d26021d95cc4f066f8 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 30 Jul 2026 10:05:09 +0800 Subject: [PATCH] feat(npcs): drop binding; select NPCs on storyline export Remove storyline/scene NPC binding and export chosen NPCs with relations/groups. Harden export modal so a missing npcs payload no longer blacks out the editor. Co-authored-by: Cursor --- app/main/foundry/foundryImport.ts | 2 - app/main/index.ts | 12 +- app/main/project/zipStore.ts | 93 +------ app/renderer/editor/EditorApp.tsx | 12 +- .../editor/StorylineTransferModals.tsx | 254 ++++++++++++------ app/renderer/editor/i18n/editorMessages.ts | 28 +- app/renderer/editor/main.tsx | 9 +- app/renderer/editor/state/projectState.ts | 13 +- app/renderer/npcs/NpcBindingFields.tsx | 135 ---------- app/renderer/npcs/NpcEditModal.tsx | 16 +- app/renderer/npcs/NpcsEditorApp.tsx | 18 -- .../ui/secondaryWindows.stability.test.ts | 19 ++ .../graph/storylineExportImport.test.ts | 158 ++++++++++- app/shared/graph/storylineExportImport.ts | 64 +---- app/shared/ipc/contracts.ts | 15 +- app/shared/npcs/npcBinding.ts | 80 ------ app/shared/types/domain.ts | 12 - 17 files changed, 418 insertions(+), 522 deletions(-) delete mode 100644 app/renderer/npcs/NpcBindingFields.tsx delete mode 100644 app/shared/npcs/npcBinding.ts diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index 19241e6..a3d08e9 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -18,7 +18,6 @@ import type { FoundryPlaylistDoc, FoundrySceneDoc, } from '../../shared/foundry/foundryTypes'; -import { noneBinding } from '../../shared/npcs/npcBinding'; import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups'; import type { MediaAsset, @@ -447,7 +446,6 @@ export async function buildProjectFromFoundryDocuments( x: 80 + (npcIndex % 4) * 220, y: 80 + Math.floor(npcIndex / 4) * 200, groupId, - binding: noneBinding(), }); npcIndex += 1; } diff --git a/app/main/index.ts b/app/main/index.ts index 5062115..74b800e 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -757,7 +757,7 @@ async function main() { }); registerHandler( ipcChannels.project.upsertNpc, - async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => { + async ({ npcId, name, description, filePath: pathFromDrop, groupId }) => { let filePath = pathFromDrop; if (!filePath && !npcId) { const { canceled, filePaths } = await dialog.showOpenDialog({ @@ -781,7 +781,6 @@ async function main() { ...(typeof description === 'string' ? { description } : {}), ...(filePath ? { filePath } : {}), ...(groupId !== undefined ? { groupId } : {}), - ...(binding !== undefined ? { binding } : {}), }, (p) => emitNpcUpsertProgress(p), ); @@ -793,12 +792,11 @@ async function main() { ); registerHandler( ipcChannels.project.updateNpcFields, - async ({ npcId, name, description, groupId, binding }) => { + async ({ npcId, name, description, groupId }) => { const project = await projectStore.updateNpcFields(npcId, { ...(typeof name === 'string' ? { name } : {}), ...(typeof description === 'string' ? { description } : {}), ...(groupId !== undefined ? { groupId } : {}), - ...(binding !== undefined ? { binding } : {}), }); emitSessionState(); return { project }; @@ -1017,8 +1015,7 @@ async function main() { return { canceled: false as const, project }; }); registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => { - const storylines = await projectStore.getProjectStorylines(projectId, labels); - return { storylines }; + return projectStore.getProjectStorylines(projectId, labels); }); registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => { const { canceled, filePaths } = await dialog.showOpenDialog({ @@ -1159,7 +1156,7 @@ async function main() { throw e; } }); - registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => { + registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, npcIds, labels }) => { const list = await projectStore.listProjects(); const entry = list.find((p) => p.id === projectId); if (!entry) { @@ -1183,6 +1180,7 @@ async function main() { await projectStore.exportStorylinesZipToPath( projectId, storylineSelections, + npcIds ?? [], dest, labels, (p) => { diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 2627070..ddc1650 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -39,7 +39,6 @@ import type { MaterialLegend, MediaAsset, MediaAssetType, - NpcBinding, Project, ProjectId, ProjectNpc, @@ -66,12 +65,6 @@ import { asNpcRelationId, asProjectId, } from '../../shared/types/ids'; -import { - clearNpcBindingsForDeletedScene, - clearNpcBindingsForRemovedStoryline, - noneBinding, - normalizeNpcBinding, -} from '../../shared/npcs/npcBinding'; import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor, @@ -712,25 +705,9 @@ export class ZipProjectStore { 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, @@ -792,25 +769,9 @@ export class ZipProjectStore { 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) { @@ -841,19 +802,8 @@ export class ZipProjectStore { 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) { @@ -916,23 +866,7 @@ export class ZipProjectStore { 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) }; + return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) }; }); const latest = this.getOpenProject(); if (!latest) throw new Error('No open project'); @@ -1309,7 +1243,6 @@ export class ZipProjectStore { description?: string; filePath?: string; groupId?: NpcGroupId | null; - binding?: NpcBinding; }, onProgress?: (p: { percent: number; stage: string; detail?: string }) => void, ): Promise { @@ -1384,7 +1317,6 @@ export class ZipProjectStore { 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'); @@ -1397,7 +1329,6 @@ export class ZipProjectStore { 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 }; @@ -1415,7 +1346,6 @@ export class ZipProjectStore { name?: string; description?: string; groupId?: NpcGroupId | null; - binding?: NpcBinding; }, ): Promise { const open = this.openProject; @@ -1447,7 +1377,6 @@ export class ZipProjectStore { ...(name !== undefined ? { name } : {}), ...(typeof patch.description === 'string' ? { description: patch.description } : {}), groupId, - ...(patch.binding !== undefined ? { binding: patch.binding } : {}), }; }); return { ...p, npcs }; @@ -2000,10 +1929,13 @@ export class ZipProjectStore { async getProjectStorylines( projectId: ProjectId, labels: StorylineLabels, - ): Promise { + ): Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }> { const snap = await this.loadProjectSnapshot(projectId); try { - return listExportableStorylines(snap.project, labels); + return { + storylines: listExportableStorylines(snap.project, labels), + npcs: (snap.project.npcs ?? []).map((n) => ({ id: n.id, name: n.name })), + }; } finally { if (snap.ownsCache) { await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined); @@ -2014,6 +1946,7 @@ export class ZipProjectStore { async exportStorylinesZipToPath( projectId: ProjectId, selections: StorylineSelection[], + npcIds: string[], destinationPath: string, labels: StorylineLabels, onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void, @@ -2030,6 +1963,7 @@ export class ZipProjectStore { newProjectId: newExportBundleProjectId(), exportTitle: entry?.name ?? snap.project.meta.name, labels, + npcIds, }); await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true }); const assetIds = Object.keys(partial.assets) as AssetId[]; @@ -2483,11 +2417,6 @@ function normalizeProject(p: Project): Project { ); 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) => { @@ -2500,7 +2429,6 @@ function normalizeProject(p: Project): Project { 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(); @@ -2516,11 +2444,6 @@ function normalizeProject(p: Project): Project { x, y, groupId: resolveNpcGroupId(obj.groupId, groupIdSet), - binding: normalizeNpcBinding(obj.binding, { - sceneIds: sceneIdSet, - sideStartIds, - hasMainStart, - }), }; }) .filter((x): x is ProjectNpc => Boolean(x)); diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 01c8f9d..8f8c825 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -562,11 +562,7 @@ export function EditorApp() { const continueImportAfterScenes = useCallback( (selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => { if (!importPeek || !state.project) return; - const npcConflicts = computeNpcImportConflicts( - state.project, - importPeek.sourceProject, - selections, - ); + const npcConflicts = computeNpcImportConflicts(state.project, importPeek.sourceProject); setPendingImportSelections(selections); setPendingSceneResolutions(sceneResolutions); setImportConflictsOpen(false); @@ -578,7 +574,6 @@ export function EditorApp() { const npcResolutions = buildNpcResolutionsForImport( state.project, importPeek.sourceProject, - selections, [], [], ); @@ -1459,8 +1454,8 @@ export function EditorApp() { storylineLabels={storylineLabels} loadStorylines={loadProjectStorylines} onClose={() => setExportModalOpen(false)} - onExport={async (projectId, selections) => { - await actions.exportProject(projectId, selections, storylineLabels); + onExport={async (projectId, selections, npcIds) => { + await actions.exportProject(projectId, selections, npcIds, storylineLabels); }} /> Promise; + loadStorylines: ( + projectId: ProjectId, + ) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>; onClose: () => void; - onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise; + onExport: ( + projectId: ProjectId, + selections: StorylineSelection[], + npcIds: string[], + ) => Promise; }; export function ExportProjectModal({ open, projects, initialProjectId, - storylineLabels, + storylineLabels: _storylineLabels, loadStorylines, onClose, onExport, }: ExportProjectModalProps) { const { t } = useEditorI18n(); + const [step, setStep] = useState<'storylines' | 'npcs'>('storylines'); const [projectId, setProjectId] = useState(initialProjectId); const [storylines, setStorylines] = useState([]); + const [npcs, setNpcs] = useState([]); const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [selectedNpcIds, setSelectedNpcIds] = useState>(new Set()); const [loadingStorylines, setLoadingStorylines] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -55,23 +66,38 @@ export function ExportProjectModal({ setSaving(false); setError(null); setSelectedKeys(new Set()); + setSelectedNpcIds(new Set()); + setStep('storylines'); + setNpcs([]); }, [initialProjectId, open]); useEffect(() => { if (!open || !projectId) { setStorylines([]); + setNpcs([]); return; } let cancelled = false; setLoadingStorylines(true); void (async () => { try { - const list = await loadStorylines(projectId); + const res = await loadStorylines(projectId); if (cancelled) return; + const list = Array.isArray(res?.storylines) ? res.storylines : []; + const npcList = Array.isArray(res?.npcs) ? res.npcs : []; setStorylines(list); + setNpcs(npcList); setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection)))); + setSelectedNpcIds(new Set(npcList.map((n) => n.id))); + setStep('storylines'); } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + if (!cancelled) { + setStorylines([]); + setNpcs([]); + setSelectedKeys(new Set()); + setSelectedNpcIds(new Set()); + setError(e instanceof Error ? e.message : String(e)); + } } finally { if (!cancelled) setLoadingStorylines(false); } @@ -84,15 +110,18 @@ export function ExportProjectModal({ useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === 'Escape') { + if (step === 'npcs') setStep('storylines'); + else onClose(); + } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [onClose, open]); + }, [onClose, open, step]); if (!open) return null; - const canExport = + const canContinueStorylines = projectId !== null && projects.some((p) => p.id === projectId) && selectedKeys.size > 0 && @@ -108,10 +137,44 @@ export function ExportProjectModal({ }); }; + const toggleNpc = (id: string) => { + setSelectedNpcIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + const selectedSelections = storylines .filter((item) => selectedKeys.has(storylineSelectionKey(item.selection))) .map((item) => item.selection); + const runExport = (npcIds: string[]) => { + if (!projectId || !canContinueStorylines) return; + void (async () => { + setSaving(true); + setError(null); + try { + await onExport(projectId, selectedSelections, npcIds); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + })(); + }; + + const goNextFromStorylines = () => { + if (!canContinueStorylines) return; + if (npcs.length === 0) { + runExport([]); + return; + } + setStep('npcs'); + }; + return createPortal( <> + + )} {error ?
{error}
: null}
- - + {step === 'npcs' ? ( + + ) : ( + + )} + {step === 'storylines' ? ( + + ) : ( + + )}
, @@ -831,11 +923,10 @@ export function computeImportConflicts( export function buildNpcResolutionsForImport( _targetProject: Project, sourceProject: Project, - selections: StorylineSelection[], conflicts: NpcNameConflict[], userResolutions: NpcImportResolution[], ): NpcImportResolution[] { - const exported = filterNpcsForStorylineExport(sourceProject, selections); + const exported = listExportedNpcsFromBundle(sourceProject); const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId)); const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r])); const out: NpcImportResolution[] = []; @@ -853,9 +944,8 @@ export function buildNpcResolutionsForImport( export function computeNpcImportConflicts( targetProject: Project, sourceProject: Project, - selections: StorylineSelection[], ): NpcNameConflict[] { - const exported = filterNpcsForStorylineExport(sourceProject, selections); + const exported = listExportedNpcsFromBundle(sourceProject); return findNpcNameConflicts( targetProject, sourceProject, diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 821e1d0..a6d4245 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -298,7 +298,13 @@ export const EDITOR_MESSAGES: Record> = { 'export.title': 'Экспорт проекта', 'export.project': 'ПРОЕКТ', 'export.hint': - 'Выберите сюжетные линии для экспорта. В архив попадут только выбранные линии, их сцены и материалы. Далее откроется окно сохранения .ttrpg.zip.', + 'Выберите сюжетные линии для экспорта. В архив попадут выбранные линии, их сцены и материалы. Если в проекте есть НПС, на следующем шаге можно отметить, кого включить.', + 'export.npcsTitle': 'Экспорт НПС', + 'export.npcsHint': + 'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.', + 'export.selectAllNpcs': 'Отметить всех', + 'export.next': 'Далее', + 'export.back': 'Назад', 'export.exporting': 'Экспорт…', 'export.saveAs': 'Сохранить как…', @@ -475,12 +481,6 @@ export const EDITOR_MESSAGES: Record> = { 'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.', 'npcs.addSubgroup': 'Добавить подгруппу', 'npcs.group': 'ГРУППА', - 'npcs.bindingEnable': 'Привязать…', - 'npcs.bindingKind': 'ТИП ПРИВЯЗКИ', - 'npcs.bindingStoryline': 'Сюжетная линия', - 'npcs.bindingScene': 'Сцена', - 'npcs.bindingMain': 'Основная линия', - 'npcs.bindingSelect': 'ОБЪЕКТ', 'npcs.graphFilterAll': 'Все', 'npcs.graphFilterUngrouped': 'Без группы', 'npcs.graphFilter': 'Фильтр графа', @@ -866,7 +866,13 @@ export const EDITOR_MESSAGES: Record> = { 'export.title': 'Export project', 'export.project': 'PROJECT', 'export.hint': - 'Select storylines to export. The archive will include only the chosen lines, their scenes, and assets. Then choose where to save the .ttrpg.zip file.', + 'Select storylines to export. The archive will include the chosen lines, their scenes, and assets. If the project has NPCs, the next step lets you choose which ones to include.', + 'export.npcsTitle': 'Export NPCs', + 'export.npcsHint': + 'Select NPCs to export. Relations between selected NPCs and their groups are included.', + 'export.selectAllNpcs': 'Select all', + 'export.next': 'Next', + 'export.back': 'Back', 'export.exporting': 'Exporting…', 'export.saveAs': 'Save as…', @@ -1044,12 +1050,6 @@ export const EDITOR_MESSAGES: Record> = { 'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.', 'npcs.addSubgroup': 'Add subgroup', 'npcs.group': 'GROUP', - 'npcs.bindingEnable': 'Bind…', - 'npcs.bindingKind': 'BINDING TYPE', - 'npcs.bindingStoryline': 'Storyline', - 'npcs.bindingScene': 'Scene', - 'npcs.bindingMain': 'Main storyline', - 'npcs.bindingSelect': 'TARGET', 'npcs.graphFilterAll': 'All', 'npcs.graphFilterUngrouped': 'Ungrouped', 'npcs.graphFilter': 'Graph filter', diff --git a/app/renderer/editor/main.tsx b/app/renderer/editor/main.tsx index 1d27a55..3b145ba 100644 --- a/app/renderer/editor/main.tsx +++ b/app/renderer/editor/main.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import '../shared/ui/globals.css'; +import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary'; import { EditorApp } from './EditorApp'; import { EditorI18nProvider } from './i18n/EditorI18nContext'; @@ -12,8 +13,10 @@ if (!rootEl) { createRoot(rootEl).render( - - - + + + + + , ); diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index b6f23ff..ec14f27 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -148,10 +148,14 @@ type Actions = { npcResolutions?: NpcImportResolution[], ) => Promise<{ project: Project; report: StorylineImportMergeReport }>; importProjectFromPath: (filePath: string) => Promise; - getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise; + getProjectStorylines: ( + projectId: ProjectId, + labels: StorylineLabels, + ) => Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }>; exportProject: ( projectId: ProjectId, storylineSelections: StorylineSelection[], + npcIds: string[], labels: StorylineLabels, ) => Promise; deleteProject: (projectId: ProjectId) => Promise; @@ -869,18 +873,23 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => { const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels }); - return res.storylines; + return { + storylines: Array.isArray(res?.storylines) ? res.storylines : [], + npcs: Array.isArray(res?.npcs) ? res.npcs : [], + }; }; const exportProject = async ( projectId: ProjectId, storylineSelections: StorylineSelection[], + npcIds: string[], labels: StorylineLabels, ) => { try { const res = await api.invoke(ipcChannels.project.exportZip, { projectId, storylineSelections, + npcIds, labels, }); if (res.canceled) return; diff --git a/app/renderer/npcs/NpcBindingFields.tsx b/app/renderer/npcs/NpcBindingFields.tsx deleted file mode 100644 index e8f9199..0000000 --- a/app/renderer/npcs/NpcBindingFields.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React, { useMemo } from 'react'; - -import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '../../shared/npcs/npcBinding'; -import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types'; -import editorStyles from '../editor/EditorApp.module.css'; -import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; -import { Select } from '../shared/ui/controls'; - -type NpcBindingFieldsProps = { - project: Project; - binding: NpcBinding; - onChange: (binding: NpcBinding) => void; -}; - -function defaultBinding(project: Project): NpcBinding { - const opts = listStorylineOptionsForBinding(project); - if (opts.main) return { kind: 'storyline', storyline: { kind: 'main' } }; - if (opts.sides[0]) { - return { - kind: 'storyline', - storyline: { kind: 'side', startGraphNodeId: opts.sides[0].startGraphNodeId }, - }; - } - const firstScene = Object.keys(project.scenes)[0] as SceneId | undefined; - if (firstScene) return { kind: 'scene', sceneId: firstScene }; - return noneBinding(); -} - -export function NpcBindingFields({ project, binding, onChange }: NpcBindingFieldsProps) { - const { t } = useEditorI18n(); - const enabled = !isNpcBindingNone(binding); - const storylineOpts = useMemo(() => listStorylineOptionsForBinding(project), [project]); - const sceneOptions = useMemo( - () => - Object.entries(project.scenes) - .map(([id, scene]) => ({ id: id as SceneId, title: scene.title.trim() || id })) - .sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })), - [project.scenes], - ); - - const kind = binding.kind === 'none' ? 'storyline' : binding.kind; - - const bindingTargetValue = useMemo(() => { - if (binding.kind === 'scene') return binding.sceneId; - if (binding.kind === 'storyline') { - if (binding.storyline.kind === 'main') return 'main'; - return `side:${binding.storyline.startGraphNodeId}`; - } - return ''; - }, [binding]); - - return ( -
- - - {enabled ? ( - <> -
{t('npcs.bindingKind')}
- ({ - value: `side:${s.startGraphNodeId}`, - label: s.label, - })), - ] - : sceneOptions.map((s) => ({ value: s.id, label: s.title })) - } - onChange={(v) => { - if (kind === 'scene') { - onChange({ kind: 'scene', sceneId: v as SceneId }); - return; - } - if (v === 'main') { - onChange({ kind: 'storyline', storyline: { kind: 'main' } }); - return; - } - if (v.startsWith('side:')) { - onChange({ - kind: 'storyline', - storyline: { - kind: 'side', - startGraphNodeId: v.slice(5) as GraphNodeId, - }, - }); - } - }} - /> - - ) : null} -
- ); -} diff --git a/app/renderer/npcs/NpcEditModal.tsx b/app/renderer/npcs/NpcEditModal.tsx index b04d313..dfb3cd0 100644 --- a/app/renderer/npcs/NpcEditModal.tsx +++ b/app/renderer/npcs/NpcEditModal.tsx @@ -2,9 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { createPortal, flushSync } from 'react-dom'; import { ipcChannels } from '../../shared/ipc/contracts'; -import { noneBinding } from '../../shared/npcs/npcBinding'; import { buildNpcGroupForest } from '../../shared/npcs/npcGroups'; -import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types'; +import type { NpcGroupId, ProjectNpc, ProjectNpcGroup } from '../../shared/types'; import editorStyles from '../editor/EditorApp.module.css'; import { filterMaterialImagePaths, @@ -18,8 +17,6 @@ import { getDndApi } from '../shared/dndApi'; import { Button, Input, Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; -import { NpcBindingFields } from './NpcBindingFields'; - function normalizeName(input: string): string { return input.trim().toLowerCase(); } @@ -41,7 +38,6 @@ type NpcEditModalProps = { open: boolean; initial: ProjectNpc | null; existingNames: string[]; - project: Project | null; npcGroups: ProjectNpcGroup[]; onClose: () => void; onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>; @@ -49,7 +45,6 @@ type NpcEditModalProps = { name: string; filePath?: string; groupId?: NpcGroupId | null; - binding?: NpcBinding; }) => Promise; }; @@ -57,7 +52,6 @@ export function NpcEditModal({ open, initial, existingNames, - project, npcGroups, onClose, onPickImage, @@ -69,7 +63,6 @@ export function NpcEditModal({ const [filePath, setFilePath] = useState(null); const [localPreviewUrl, setLocalPreviewUrl] = useState(null); const [groupId, setGroupId] = useState(''); - const [binding, setBinding] = useState(noneBinding()); const [saving, setSaving] = useState(false); const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null); const [error, setError] = useState(null); @@ -86,7 +79,6 @@ export function NpcEditModal({ setFilePath(null); setLocalPreviewUrl(null); setGroupId(initial?.groupId ?? ''); - setBinding(initial?.binding ?? noneBinding()); setSaving(false); setSaveProgress(null); setError(null); @@ -261,10 +253,6 @@ export function NpcEditModal({ {!hasImage ?
{t('npcs.avatarRequired')}
: null} - {project && !initial ? ( - - ) : null} - {error ?
{error}
: null}
@@ -286,7 +274,7 @@ export function NpcEditModal({ await onSave({ name: trimmed, ...(filePath ? { filePath } : {}), - ...(!initial ? { groupId: groupId || null, binding } : {}), + ...(!initial ? { groupId: groupId || null } : {}), }); onClose(); } catch (e) { diff --git a/app/renderer/npcs/NpcsEditorApp.tsx b/app/renderer/npcs/NpcsEditorApp.tsx index b3964f1..9f27e57 100644 --- a/app/renderer/npcs/NpcsEditorApp.tsx +++ b/app/renderer/npcs/NpcsEditorApp.tsx @@ -4,7 +4,6 @@ import { createPortal } from 'react-dom'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups'; import type { - NpcBinding, NpcGroupId, NpcId, NpcRelationId, @@ -18,7 +17,6 @@ import { getDndApi } from '../shared/dndApi'; import { Button, Input, Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; -import { NpcBindingFields } from './NpcBindingFields'; import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcEditModal } from './NpcEditModal'; import type { GraphGroupFilter } from './NpcGraph'; @@ -822,20 +820,6 @@ export function NpcsEditorApp() { />
-
-
{t('npcs.bindingEnable')}
- { - void api.invoke(ipcChannels.project.updateNpcFields, { - npcId: selected.id, - binding, - }); - }} - /> -
- {relationsForSelected.length > 0 ? (
{t('npcs.relations')}
@@ -860,7 +844,6 @@ export function NpcsEditorApp() { open={editOpen} initial={editInitial} existingNames={npcs.map((n) => n.name)} - project={project} npcGroups={npcGroups} onClose={() => setEditOpen(false)} onPickImage={pickAvatar} @@ -870,7 +853,6 @@ export function NpcsEditorApp() { name: input.name, ...(input.filePath ? { filePath: input.filePath } : {}), ...(input.groupId !== undefined ? { groupId: input.groupId } : {}), - ...(input.binding !== undefined ? { binding: input.binding } : {}), }); const created = res.project.npcs.find((n) => n.name === input.name.trim()); if (created) setSelectedId(created.id); diff --git a/app/renderer/shared/ui/secondaryWindows.stability.test.ts b/app/renderer/shared/ui/secondaryWindows.stability.test.ts index d254f92..580e4a9 100644 --- a/app/renderer/shared/ui/secondaryWindows.stability.test.ts +++ b/app/renderer/shared/ui/secondaryWindows.stability.test.ts @@ -8,6 +8,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const rendererRoot = path.resolve(here, '../..'); const SECONDARY_WINDOW_MAINS = [ + 'editor/main.tsx', 'npcs/npcsEditorMain.tsx', 'npcs/npcsMain.tsx', 'materials/main.tsx', @@ -45,6 +46,24 @@ void test('NpcsEditorApp: no undefined controlStyles (inspector crash)', () => { assert.doesNotMatch(src, /controlStyles/); }); +void test('NPC binding removed from editor UI', () => { + const editor = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8'); + const modal = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcEditModal.tsx'), 'utf8'); + assert.doesNotMatch(editor, /NpcBindingFields|binding/); + assert.doesNotMatch(modal, /NpcBindingFields|binding|noneBinding/); + assert.ok(!fs.existsSync(path.join(rendererRoot, 'npcs/NpcBindingFields.tsx'))); + assert.ok(!fs.existsSync(path.join(rendererRoot, '../shared/npcs/npcBinding.ts'))); +}); + +void test('storyline export modal: NPC selection step', () => { + const src = fs.readFileSync(path.join(rendererRoot, 'editor/StorylineTransferModals.tsx'), 'utf8'); + assert.match(src, /step === 'npcs'/); + assert.match(src, /export\.selectAllNpcs/); + assert.match(src, /npcIds/); + assert.match(src, /Array\.isArray\(res\?\.npcs\)/); + assert.doesNotMatch(src, /filterNpcsForStorylineExport/); +}); + void test('WindowErrorBoundary component exists and catches errors', () => { const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8'); assert.ok(src.includes('getDerivedStateFromError')); diff --git a/app/shared/graph/storylineExportImport.test.ts b/app/shared/graph/storylineExportImport.test.ts index 7240ead..2bd4a4f 100644 --- a/app/shared/graph/storylineExportImport.test.ts +++ b/app/shared/graph/storylineExportImport.test.ts @@ -1,17 +1,28 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { Project, Scene, SceneGraphEdge, SceneGraphNode } from '../types'; -import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids'; +import type { Project, ProjectNpc, ProjectNpcGroup, ProjectNpcRelation, Scene, SceneGraphEdge, SceneGraphNode } from '../types'; +import { + asAssetId, + asGraphNodeId, + asNpcGroupId, + asNpcId, + asNpcRelationId, + asProjectId, + asSceneId, +} from '../types/ids'; +import { PROJECT_SCHEMA_VERSION } from '../types'; import { buildPartialExportProject, collectStorylineGraphNodeIds, computeGraphImportOffsetX, findSceneTitleConflicts, + listExportedNpcsFromBundle, listImportableStorylines, mergeStorylinesIntoProject, newExportBundleProjectId, + selectNpcsByIds, } from './storylineExportImport'; const LABELS = { main: 'Основная линия', untitled: 'Без названия' }; @@ -64,7 +75,7 @@ function minimalProject(overrides: Partial = {}): Project { updatedAt: '2020-01-01T00:00:00.000Z', createdWithAppVersion: '1', appVersion: '1', - schemaVersion: 9, + schemaVersion: PROJECT_SCHEMA_VERSION, }, scenes: {}, sceneListOrder: [], @@ -210,6 +221,147 @@ void test('mergeStorylinesIntoProject: offset X, create scene, rename side title assert.equal(report.edgesAdded, 1); }); +void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations, ancestor groups', () => { + const gRoot = asNpcGroupId('g_root'); + const gChild = asNpcGroupId('g_child'); + const n1 = asNpcId('npc1'); + const n2 = asNpcId('npc2'); + const n3 = asNpcId('npc3'); + const avatar = asAssetId('a1'); + const groups: ProjectNpcGroup[] = [ + { id: gRoot, name: 'Root', color: '#ffffff', parentId: null }, + { id: gChild, name: 'Child', color: '#ff0000', parentId: gRoot }, + { id: asNpcGroupId('g_other'), name: 'Other', color: '#00ff00', parentId: null }, + ]; + const npcs: ProjectNpc[] = [ + { + id: n1, + name: 'A', + avatarAssetId: avatar, + description: '', + x: 0, + y: 0, + groupId: gChild, + }, + { + id: n2, + name: 'B', + avatarAssetId: avatar, + description: '', + x: 10, + y: 0, + groupId: gChild, + }, + { + id: n3, + name: 'C', + avatarAssetId: avatar, + description: '', + x: 20, + y: 0, + groupId: asNpcGroupId('g_other'), + }, + ]; + const relations: ProjectNpcRelation[] = [ + { id: asNpcRelationId('r1'), sourceNpcId: n1, targetNpcId: n2, label: 'friends' }, + { id: asNpcRelationId('r2'), sourceNpcId: n1, targetNpcId: n3, label: 'rivals' }, + ]; + const source = minimalProject({ + scenes: { [asSceneId('s1')]: scene('s1', 'Start') }, + sceneGraphNodes: [node('main', 's1', { isStartScene: true })], + assets: { + [avatar]: { + id: avatar, + type: 'image', + mime: 'image/png', + originalName: 'a.png', + relPath: 'assets/a.png', + sha256: 'abc', + sizeBytes: 1, + createdAt: '2020-01-01T00:00:00.000Z', + }, + }, + npcs, + npcGroups: groups, + npcRelations: relations, + }); + + assert.deepEqual( + selectNpcsByIds(source, [n1, n2]).map((n) => n.id), + [n1, n2], + ); + + const partial = buildPartialExportProject(source, [{ kind: 'main' }], { + newProjectId: newExportBundleProjectId(), + exportTitle: 'Export', + labels: LABELS, + npcIds: [n1, n2], + }); + assert.equal(partial.npcs.length, 2); + assert.ok(partial.npcs.every((n) => n.id === n1 || n.id === n2)); + assert.equal(partial.npcRelations.length, 1); + assert.equal(partial.npcRelations[0]!.label, 'friends'); + assert.deepEqual( + [...partial.npcGroups.map((g) => g.id)].sort(), + [gRoot, gChild].sort(), + ); + assert.ok(!partial.npcGroups.some((g) => g.id === asNpcGroupId('g_other'))); +}); + +void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', () => { + const avatar = asAssetId('a1'); + const sourceNpcId = asNpcId('src_npc'); + const source = minimalProject({ + scenes: { [asSceneId('s1')]: scene('s1', 'Side') }, + sceneGraphNodes: [node('side', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Side', x: 0 })], + assets: { + [avatar]: { + id: avatar, + type: 'image', + mime: 'image/png', + originalName: 'a.png', + relPath: 'assets/a.png', + sha256: 'npcsha', + sizeBytes: 1, + createdAt: '2020-01-01T00:00:00.000Z', + }, + }, + npcs: [ + { + id: sourceNpcId, + name: 'Hero', + avatarAssetId: avatar, + description: '', + x: 0, + y: 0, + groupId: null, + }, + ], + }); + const target = minimalProject({ + scenes: { [asSceneId('t1')]: scene('t1', 'Existing') }, + sceneGraphNodes: [node('tgn', 't1', { x: 0 })], + }); + + assert.equal(listExportedNpcsFromBundle(source).length, 1); + + const { project: merged, report } = mergeStorylinesIntoProject( + target, + source, + [{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }], + [{ sourceSceneId: asSceneId('s1'), mode: 'create' }], + { + graphOffsetX: 200, + npcResolutions: [{ sourceNpcId, mode: 'create' }], + }, + ); + + assert.equal(report.npcsCreated, 1); + assert.equal(merged.npcs.length, 1); + assert.equal(merged.npcs[0]!.name, 'Hero'); + assert.ok(!('binding' in (merged.npcs[0] as object))); +}); + void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () => { const targetSceneId = asSceneId('t1'); const target = minimalProject({ diff --git a/app/shared/graph/storylineExportImport.ts b/app/shared/graph/storylineExportImport.ts index b6dddcf..cfd4138 100644 --- a/app/shared/graph/storylineExportImport.ts +++ b/app/shared/graph/storylineExportImport.ts @@ -1,8 +1,6 @@ -import { noneBinding } from '../npcs/npcBinding'; import type { ExportedStorylineRef, GraphNodeId, - NpcBinding, Project, ProjectNpc, ProjectNpcGroup, @@ -241,6 +239,8 @@ export function buildPartialExportProject( newProjectId: ProjectId; exportTitle: string; labels: StorylineLabels; + /** Явный список НПС; пустой — без НПС в архиве. */ + npcIds?: readonly string[]; }, ): Project { const nodeIds = collectSelectionsGraphNodeIds(source, selections); @@ -278,7 +278,7 @@ export function buildPartialExportProject( const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges); draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) }; - const exportedNpcs = filterNpcsForStorylineExport(source, selections); + const exportedNpcs = selectNpcsByIds(source, opts.npcIds ?? []); const exportedNpcIds = new Set(exportedNpcs.map((n) => n.id)); const exportedRelations = (source.npcRelations ?? []).filter( (r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId), @@ -312,33 +312,16 @@ export function buildPartialExportProject( }; } -/** НПС для экспорта выбранных линий (main + unbound; side только свои). */ -export function filterNpcsForStorylineExport( - project: Project, - selections: StorylineSelection[], -): ProjectNpc[] { - const npcs = project.npcs ?? []; - if (selections.length === 0) return []; +/** НПС по явному списку id (порядок — как в проекте). */ +export function selectNpcsByIds(project: Project, npcIds: readonly string[]): ProjectNpc[] { + if (npcIds.length === 0) return []; + const wanted = new Set(npcIds); + return (project.npcs ?? []).filter((n) => wanted.has(n.id)); +} - const includeUnbound = selections.some((s) => s.kind === 'main'); - const hasMain = selections.some((s) => s.kind === 'main'); - const sideRefs = new Set( - selections - .filter((s): s is { kind: 'side'; startGraphNodeId: GraphNodeId } => s.kind === 'side') - .map((s) => s.startGraphNodeId), - ); - const sceneIdsInExport = new Set(collectSceneIdsForSelections(project, selections)); - - return npcs.filter((npc) => { - const b: NpcBinding = npc.binding ?? noneBinding(); - if (b.kind === 'none') return includeUnbound; - if (b.kind === 'storyline') { - if (b.storyline.kind === 'main') return hasMain; - return sideRefs.has(b.storyline.startGraphNodeId); - } - if (b.kind === 'scene') return sceneIdsInExport.has(b.sceneId); - return false; - }); +/** НПС из экспортного пакета — импортируем всех, кто в нём лежит. */ +export function listExportedNpcsFromBundle(source: Project): ProjectNpc[] { + return [...(source.npcs ?? [])]; } function collectNpcGroupIdsForNpcs(groups: ProjectNpcGroup[], npcs: ProjectNpc[]): Set { @@ -493,7 +476,7 @@ export function mergeStorylinesIntoProject( for (const au of source.campaignAudios) neededAssetIds.add(au.assetId); for (const m of source.materials ?? []) neededAssetIds.add(m.assetId); - const exportedNpcs = filterNpcsForStorylineExport(source, selections); + const exportedNpcs = listExportedNpcsFromBundle(source); for (const n of exportedNpcs) { const res = npcResolutionBySource.get(n.id); if (res?.mode === 'use') continue; @@ -695,7 +678,6 @@ export function mergeStorylinesIntoProject( const mappedGroupId = n.groupId ? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId) : null; - const binding = remapNpcBinding(n.binding ?? noneBinding(), sceneIdMap, graphNodeIdMap); npcs.push({ id: newId, name, @@ -704,7 +686,6 @@ export function mergeStorylinesIntoProject( x: n.x, y: n.y, groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null, - binding, }); npcNameKeys.add(name.toLowerCase()); npcsCreated += 1; @@ -791,25 +772,6 @@ function topologicalNpcGroups(groups: ProjectNpcGroup[]): ProjectNpcGroup[] { return out; } -function remapNpcBinding( - binding: NpcBinding, - sceneIdMap: Map, - graphNodeIdMap: Map, -): NpcBinding { - if (binding.kind === 'none') return noneBinding(); - if (binding.kind === 'scene') { - const mapped = sceneIdMap.get(binding.sceneId); - return mapped ? { kind: 'scene', sceneId: mapped } : noneBinding(); - } - if (binding.storyline.kind === 'main') { - return { kind: 'storyline', storyline: { kind: 'main' } }; - } - const mappedGn = graphNodeIdMap.get(binding.storyline.startGraphNodeId); - return mappedGn - ? { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: mappedGn } } - : noneBinding(); -} - export function newExportBundleProjectId(): ProjectId { return asProjectId(`p_${generateId()}`); } diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 7990070..ce539db 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -12,7 +12,6 @@ import type { NpcId, NpcRelationId, NpcGroupId, - NpcBinding, NpcsOverlayEvent, NpcsOverlayState, Project, @@ -369,7 +368,6 @@ export type IpcInvokeMap = { description?: string; filePath?: string; groupId?: NpcGroupId | null; - binding?: NpcBinding; }; res: { project: Project }; }; @@ -379,7 +377,6 @@ export type IpcInvokeMap = { name?: string; description?: string; groupId?: NpcGroupId | null; - binding?: NpcBinding; }; res: { project: Project }; }; @@ -493,7 +490,10 @@ export type IpcInvokeMap = { }; [ipcChannels.project.getProjectStorylines]: { req: { projectId: ProjectId; labels: StorylineLabels }; - res: { storylines: StorylineListItem[] }; + res: { + storylines: StorylineListItem[]; + npcs: { id: string; name: string }[]; + }; }; [ipcChannels.project.peekImportZip]: { req: { labels: StorylineLabels; targetHasMainStart: boolean }; @@ -560,7 +560,12 @@ export type IpcInvokeMap = { res: { project: Project }; }; [ipcChannels.project.exportZip]: { - req: { projectId: ProjectId; storylineSelections: StorylineSelection[]; labels: StorylineLabels }; + req: { + projectId: ProjectId; + storylineSelections: StorylineSelection[]; + npcIds: string[]; + labels: StorylineLabels; + }; res: { canceled: true } | { canceled: false }; }; [ipcChannels.project.deleteProject]: { diff --git a/app/shared/npcs/npcBinding.ts b/app/shared/npcs/npcBinding.ts deleted file mode 100644 index 5fcafef..0000000 --- a/app/shared/npcs/npcBinding.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { listSideStoryStarts } from '../graph/sceneGraphLineage'; -import type { GraphNodeId, NpcBinding, NpcStorylineRef, Project, ProjectNpc, SceneId } from '../types'; - -export function noneBinding(): NpcBinding { - return { kind: 'none' }; -} - -export function isNpcBindingNone(b: NpcBinding | undefined | null): boolean { - return !b || b.kind === 'none'; -} - -export function storylineRefKey(ref: NpcStorylineRef): string { - return ref.kind === 'main' ? 'main' : `side:${ref.startGraphNodeId}`; -} - -export function normalizeNpcBinding( - raw: unknown, - ctx: { sceneIds: Set; sideStartIds: Set; hasMainStart: boolean }, -): NpcBinding { - if (!raw || typeof raw !== 'object') return noneBinding(); - const obj = raw as { - kind?: string; - sceneId?: string; - storyline?: { kind?: string; startGraphNodeId?: string }; - }; - if (obj.kind === 'scene' && typeof obj.sceneId === 'string') { - const sceneId = obj.sceneId as SceneId; - if (ctx.sceneIds.has(sceneId)) return { kind: 'scene', sceneId }; - return noneBinding(); - } - if (obj.kind === 'storyline' && obj.storyline && typeof obj.storyline === 'object') { - if (obj.storyline.kind === 'main') { - return ctx.hasMainStart ? { kind: 'storyline', storyline: { kind: 'main' } } : noneBinding(); - } - if (obj.storyline.kind === 'side' && typeof obj.storyline.startGraphNodeId === 'string') { - const id = obj.storyline.startGraphNodeId as GraphNodeId; - if (ctx.sideStartIds.has(id)) { - return { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: id } }; - } - } - } - return noneBinding(); -} - -export function clearNpcBindingsForDeletedScene(npcs: ProjectNpc[], sceneId: SceneId): ProjectNpc[] { - return npcs.map((n) => { - if (n.binding?.kind === 'scene' && n.binding.sceneId === sceneId) { - return { ...n, binding: noneBinding() }; - } - return n; - }); -} - -export function clearNpcBindingsForRemovedStoryline( - npcs: ProjectNpc[], - removed: NpcStorylineRef, -): ProjectNpc[] { - return npcs.map((n) => { - if (n.binding?.kind !== 'storyline') return n; - const ref = n.binding.storyline; - if (removed.kind === 'main' && ref.kind === 'main') return { ...n, binding: noneBinding() }; - if (removed.kind === 'side' && ref.kind === 'side' && ref.startGraphNodeId === removed.startGraphNodeId) { - return { ...n, binding: noneBinding() }; - } - return n; - }); -} - -export function listStorylineOptionsForBinding(project: Project): { - main: boolean; - sides: { startGraphNodeId: GraphNodeId; label: string }[]; -} { - const hasMain = project.sceneGraphNodes.some((n) => n.isStartScene); - const sides = listSideStoryStarts(project.sceneGraphNodes).map((n) => { - const scene = project.scenes[n.sceneId]; - const label = n.sideStoryLineTitle.trim() || scene?.title.trim() || String(n.id); - return { startGraphNodeId: n.id, label }; - }); - return { main: hasMain, sides }; -} diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index 7881c0c..8de1bec 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -34,17 +34,6 @@ export type ProjectNpcGroup = { parentId: NpcGroupId | null; }; -/** Привязка НПС к сюжетной линии. */ -export type NpcStorylineRef = - | { kind: 'main' } - | { kind: 'side'; startGraphNodeId: GraphNodeId }; - -/** Привязка НПС к линии или сцене; `none` — без привязки. */ -export type NpcBinding = - | { kind: 'none' } - | { kind: 'storyline'; storyline: NpcStorylineRef } - | { kind: 'scene'; sceneId: SceneId }; - /** НПС кампании: персонаж с аватаром, описанием и связями на графе. */ export type ProjectNpc = { id: NpcId; @@ -57,7 +46,6 @@ export type ProjectNpc = { y: number; /** `null` — системная секция «Без группы». */ groupId: NpcGroupId | null; - binding: NpcBinding; }; /** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */