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
+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 [];
/** НПС по явному списку 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<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`; подпись на линии. */