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({