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 <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 10:05:09 +08:00
parent 04c75cd725
commit e08f5ef550
17 changed files with 418 additions and 522 deletions
-2
View File
@@ -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;
}
+5 -7
View File
@@ -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) => {
+8 -85
View File
@@ -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<Project> {
@@ -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<Project> {
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<StorylineListItem[]> {
): 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));
+3 -9
View File
@@ -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);
}}
/>
<ImportSourceModal
@@ -1535,7 +1530,6 @@ export function EditorApp() {
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
pendingImportSelections,
importNpcConflicts,
userNpcResolutions,
);
+122 -32
View File
@@ -3,9 +3,9 @@ import { createPortal } from 'react-dom';
import {
collectSceneIdsForSelections,
filterNpcsForStorylineExport,
findNpcNameConflicts,
findSceneTitleConflicts,
listExportedNpcsFromBundle,
storylineSelectionKey,
type NpcImportResolution,
type NpcNameConflict,
@@ -22,29 +22,40 @@ import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
export type ExportNpcOption = { id: string; name: string };
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
storylineLabels: StorylineLabels;
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>;
loadStorylines: (
projectId: ProjectId,
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
onClose: () => void;
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>;
onExport: (
projectId: ProjectId,
selections: StorylineSelection[],
npcIds: string[],
) => Promise<void>;
};
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<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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(
<>
<button
@@ -122,7 +185,9 @@ export function ExportProjectModal({
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('export.title')}</div>
<div className={styles.modalTitle}>
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
</div>
<button
type="button"
aria-label={t('common.close')}
@@ -133,12 +198,13 @@ export function ExportProjectModal({
</button>
</div>
{step === 'storylines' ? (
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div>
<Select
value={projectId ?? ''}
onChange={(next) => setProjectId((next as ProjectId) || null)}
disabled={projects.length === 0}
disabled={projects.length === 0 || saving}
ariaLabel={t('export.project')}
options={projects.map((p) => ({
value: p.id,
@@ -165,7 +231,7 @@ export function ExportProjectModal({
<input
type="checkbox"
checked={checked}
disabled={disabled}
disabled={disabled || saving}
onChange={() => toggleKey(key, disabled)}
/>
<span>{item.label}</span>
@@ -180,34 +246,60 @@ export function ExportProjectModal({
<div className={styles.muted}>{t('export.hint')}</div>
</div>
) : (
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}>
{npcs.map((n) => (
<label key={n.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedNpcIds.has(n.id)}
disabled={saving}
onChange={() => toggleNpc(n.id)}
/>
<span>{n.name}</span>
</label>
))}
</div>
<Button
disabled={saving || npcs.length === 0}
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
>
{t('export.selectAllNpcs')}
</Button>
</div>
)}
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
{step === 'npcs' ? (
<Button onClick={() => setStep('storylines')} disabled={saving}>
{t('export.back')}
</Button>
) : (
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
{t('common.cancel')}
</Button>
)}
{step === 'storylines' ? (
<Button
variant="primary"
disabled={!canExport || saving}
onClick={() => {
if (!projectId || !canExport) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onExport(projectId, selectedSelections);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
disabled={!canContinueStorylines || saving}
onClick={goNextFromStorylines}
>
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
</Button>
) : (
<Button
variant="primary"
disabled={saving}
onClick={() => runExport([...selectedNpcIds])}
>
{t('export.saveAs')}
</Button>
)}
</div>
</div>
</>,
@@ -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,
+14 -14
View File
@@ -298,7 +298,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'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<EditorLocale, Record<string, string>> = {
'Удалить группу «{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<EditorLocale, Record<string, string>> = {
'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<EditorLocale, Record<string, string>> = {
'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',
+3
View File
@@ -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(
<React.StrictMode>
<WindowErrorBoundary title="Редактор">
<EditorI18nProvider>
<EditorApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+11 -2
View File
@@ -148,10 +148,14 @@ type Actions = {
npcResolutions?: NpcImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
importProjectFromPath: (filePath: string) => Promise<void>;
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
getProjectStorylines: (
projectId: ProjectId,
labels: StorylineLabels,
) => Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }>;
exportProject: (
projectId: ProjectId,
storylineSelections: StorylineSelection[],
npcIds: string[],
labels: StorylineLabels,
) => Promise<void>;
deleteProject: (projectId: ProjectId) => Promise<void>;
@@ -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;
-135
View File
@@ -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 (
<div className={editorStyles.fieldGrid}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
onChange(e.target.checked ? defaultBinding(project) : noneBinding());
}}
/>
<span>{t('npcs.bindingEnable')}</span>
</label>
{enabled ? (
<>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
<Select
value={kind}
ariaLabel={t('npcs.bindingKind')}
options={[
{ value: 'storyline', label: t('npcs.bindingStoryline') },
{ value: 'scene', label: t('npcs.bindingScene') },
]}
onChange={(nextKind) => {
if (nextKind === 'scene') {
const first = sceneOptions[0];
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
return;
}
if (storylineOpts.main) {
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
} else if (storylineOpts.sides[0]) {
onChange({
kind: 'storyline',
storyline: { kind: 'side', startGraphNodeId: storylineOpts.sides[0].startGraphNodeId },
});
} else {
onChange(noneBinding());
}
}}
/>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
<Select
value={bindingTargetValue}
ariaLabel={t('npcs.bindingSelect')}
options={
kind === 'storyline'
? [
...(storylineOpts.main
? [{ value: 'main', label: t('npcs.bindingMain') }]
: []),
...storylineOpts.sides.map((s) => ({
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}
</div>
);
}
+2 -14
View File
@@ -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<void>;
};
@@ -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<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
const [saving, setSaving] = useState(false);
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
const [error, setError] = useState<string | null>(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 ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
</div>
{project && !initial ? (
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
) : null}
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
@@ -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) {
-18
View File
@@ -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() {
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.bindingEnable')}</div>
<NpcBindingFields
project={project}
binding={selected.binding}
onChange={(binding: NpcBinding) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
binding,
});
}}
/>
</div>
{relationsForSelected.length > 0 ? (
<div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
@@ -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);
@@ -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'));
+155 -3
View File
@@ -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> = {}): 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({
+13 -51
View File
@@ -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 [];
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);
/** НПС по явному списку 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));
}
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<NpcGroupId> {
@@ -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<SceneId, SceneId>,
graphNodeIdMap: Map<GraphNodeId, GraphNodeId>,
): 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()}`);
}
+10 -5
View File
@@ -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]: {
-80
View File
@@ -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<SceneId>; sideStartIds: Set<GraphNodeId>; 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 };
}
-12
View File
@@ -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`; подпись на линии. */