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:
@@ -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({
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user