feat(npcs): add groups, storyline bindings, and Foundry import

Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-20 13:19:22 +08:00
parent 37ba855faf
commit f4c0ac1438
41 changed files with 5210 additions and 418 deletions
+240 -44
View File
@@ -1,35 +1,39 @@
import {
getConnectedComponent,
getSideStoryComponentNodeIds,
listSideStoryStarts,
} from './sceneGraphLineage';
import { reconcileSceneListOrder } from './sceneListOrder';
import { noneBinding } from '../npcs/npcBinding';
import type {
ExportedStorylineRef,
GraphNodeId,
NpcBinding,
Project,
ProjectNpc,
ProjectNpcGroup,
Scene,
SceneGraphEdge,
SceneGraphNode,
SceneId,
} from '../types';
import type { AssetId, ProjectId } from '../types/ids';
import type { AssetId, NpcGroupId, ProjectId } from '../types/ids';
import {
asAssetId,
asGraphNodeId,
asMaterialId,
asNpcGroupId,
asNpcId,
asNpcRelationId,
asProjectId,
asSceneId,
} from '../types/ids';
import {
getConnectedComponent,
getSideStoryComponentNodeIds,
listSideStoryStarts,
} from './sceneGraphLineage';
import { reconcileSceneListOrder } from './sceneListOrder';
export type StorylineKind = 'main' | 'side';
/** Выбор сюжетной линии для экспорта/импорта. */
export type StorylineSelection =
| { kind: 'main' }
| { kind: 'side'; startGraphNodeId: GraphNodeId };
export type StorylineSelection = { kind: 'main' } | { kind: 'side'; startGraphNodeId: GraphNodeId };
export type StorylineListItem = {
selection: StorylineSelection;
@@ -48,6 +52,16 @@ export type SceneTitleConflict = {
matches: { sceneId: SceneId; title: string }[];
};
export type NpcImportResolution =
| { sourceNpcId: string; mode: 'create' }
| { sourceNpcId: string; mode: 'use'; targetNpcId: string };
export type NpcNameConflict = {
sourceNpcId: string;
sourceName: string;
matches: { npcId: string; name: string }[];
};
export type StorylineImportMergeReport = {
storylinesImported: number;
scenesCreated: number;
@@ -56,6 +70,8 @@ export type StorylineImportMergeReport = {
edgesAdded: number;
assetsCopied: number;
assetsReused: number;
npcsCreated: number;
npcsReused: number;
renamedSideTitles: string[];
};
@@ -161,10 +177,7 @@ export function listImportableStorylines(
});
}
function recomputeOutgoing(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
): Map<SceneId, Set<SceneId>> {
function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map<SceneId, Set<SceneId>> {
const gnMap = new Map(nodes.map((n) => [n.id, n]));
const outgoing = new Map<SceneId, Set<SceneId>>();
for (const e of edges) {
@@ -181,7 +194,10 @@ function recomputeOutgoing(
return outgoing;
}
function applyConnectionSets(scenes: Record<SceneId, Scene>, outgoing: Map<SceneId, Set<SceneId>>): Record<SceneId, Scene> {
function applyConnectionSets(
scenes: Record<SceneId, Scene>,
outgoing: Map<SceneId, Set<SceneId>>,
): Record<SceneId, Scene> {
const next: Record<SceneId, Scene> = { ...scenes };
for (const sid of Object.keys(next) as SceneId[]) {
const prev = next[sid];
@@ -204,16 +220,11 @@ function selectionToExportedRef(
return {
kind: 'side',
startGraphNodeId: selection.startGraphNodeId,
label: start
? sideStoryDisplayLabel(start, project.scenes, labels.untitled)
: labels.untitled,
label: start ? sideStoryDisplayLabel(start, project.scenes, labels.untitled) : labels.untitled,
};
}
export function collectSceneIdsForSelections(
project: Project,
selections: StorylineSelection[],
): SceneId[] {
export function collectSceneIdsForSelections(project: Project, selections: StorylineSelection[]): SceneId[] {
const nodeIds = collectSelectionsGraphNodeIds(project, selections);
const sceneIds = new Set<SceneId>();
for (const gn of project.sceneGraphNodes) {
@@ -234,9 +245,7 @@ export function buildPartialExportProject(
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
const sceneIds = new Set(collectSceneIdsForSelections(source, selections));
const sceneGraphNodes = source.sceneGraphNodes
.filter((n) => nodeIds.has(n.id))
.map((n) => ({ ...n }));
const sceneGraphNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id)).map((n) => ({ ...n }));
const sceneGraphEdges = source.sceneGraphEdges.filter(
(e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
@@ -268,6 +277,21 @@ export function buildPartialExportProject(
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };
const exportedNpcs = filterNpcsForStorylineExport(source, selections);
const exportedNpcIds = new Set(exportedNpcs.map((n) => n.id));
const exportedRelations = (source.npcRelations ?? []).filter(
(r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId),
);
const exportedGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], exportedNpcs);
const exportedGroups = (source.npcGroups ?? []).filter((g) => exportedGroupIds.has(g.id));
draft = {
...draft,
npcs: exportedNpcs.map((n) => ({ ...n })),
npcGroups: exportedGroups.map((g) => ({ ...g })),
npcRelations: exportedRelations.map((r) => ({ ...r })),
};
const assetIds = collectReferencedAssetIdsForProject(draft);
const assets: Project['assets'] = {} as Project['assets'];
for (const id of assetIds) {
@@ -282,13 +306,88 @@ export function buildPartialExportProject(
assets,
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
materials: (source.materials ?? []).map((m) => ({ ...m })),
npcs: (source.npcs ?? []).map((n) => ({ ...n })),
npcRelations: (source.npcRelations ?? []).map((r) => ({ ...r })),
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
};
}
/** НПС для экспорта выбранных линий (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);
}
if (b.kind === 'scene') return sceneIdsInExport.has(b.sceneId);
return false;
});
}
function collectNpcGroupIdsForNpcs(groups: ProjectNpcGroup[], npcs: ProjectNpc[]): Set<NpcGroupId> {
const needed = new Set<NpcGroupId>();
for (const n of npcs) {
if (n.groupId) needed.add(n.groupId);
}
let changed = true;
while (changed) {
changed = false;
for (const g of groups) {
if (needed.has(g.id) && g.parentId && !needed.has(g.parentId)) {
needed.add(g.parentId);
changed = true;
}
}
}
return needed;
}
export function findNpcNameConflicts(
target: Project,
source: Project,
sourceNpcIds: string[],
): NpcNameConflict[] {
const targetByName = new Map<string, { npcId: string; name: string }[]>();
for (const n of target.npcs ?? []) {
const key = n.name.trim().toLowerCase();
if (!key) continue;
const list = targetByName.get(key) ?? [];
list.push({ npcId: n.id, name: n.name });
targetByName.set(key, list);
}
const conflicts: NpcNameConflict[] = [];
const seen = new Set<string>();
for (const id of sourceNpcIds) {
if (seen.has(id)) continue;
seen.add(id);
const src = (source.npcs ?? []).find((n) => n.id === id);
if (!src) continue;
const key = src.name.trim().toLowerCase();
if (!key) continue;
const matches = targetByName.get(key);
if (matches && matches.length > 0) {
conflicts.push({ sourceNpcId: src.id, sourceName: src.name, matches });
}
}
return conflicts;
}
function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
const refs = new Set<AssetId>();
for (const sc of Object.values(p.scenes)) {
@@ -368,9 +467,14 @@ export function mergeStorylinesIntoProject(
source: Project,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
opts: { graphOffsetX: number },
): { project: Project; report: StorylineImportMergeReport; assetCopies: { fromId: AssetId; toId: AssetId }[] } {
opts: { graphOffsetX: number; npcResolutions?: NpcImportResolution[] },
): {
project: Project;
report: StorylineImportMergeReport;
assetCopies: { fromId: AssetId; toId: AssetId }[];
} {
const resolutionBySource = new Map(sceneResolutions.map((r) => [r.sourceSceneId, r]));
const npcResolutionBySource = new Map((opts.npcResolutions ?? []).map((r) => [r.sourceNpcId, r]));
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
const sourceSceneIds = collectSceneIdsForSelections(source, selections);
@@ -387,7 +491,13 @@ export function mergeStorylinesIntoProject(
}
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
for (const n of source.npcs ?? []) neededAssetIds.add(n.avatarAssetId);
const exportedNpcs = filterNpcsForStorylineExport(source, selections);
for (const n of exportedNpcs) {
const res = npcResolutionBySource.get(n.id);
if (res?.mode === 'use') continue;
neededAssetIds.add(n.avatarAssetId);
}
const assetMap = new Map<AssetId, AssetId>();
const targetSha = new Map<string, AssetId>();
@@ -521,17 +631,57 @@ export function mergeStorylinesIntoProject(
materialAssetIds.add(mapped);
}
const npcs = [...(target.npcs ?? [])];
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
const npcAssetIds = new Set(npcs.map((n) => n.avatarAssetId));
const npcIdMap = new Map<string, string>();
for (const n of source.npcs ?? []) {
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
const existingByAsset = npcs.find((x) => x.avatarAssetId === mappedAvatar);
if (existingByAsset || npcAssetIds.has(mappedAvatar)) {
if (existingByAsset) npcIdMap.set(n.id, existingByAsset.id);
// Группы только для создаваемых НПС выбранных линий.
const npcsToCreate = exportedNpcs.filter((n) => npcResolutionBySource.get(n.id)?.mode !== 'use');
const neededGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], npcsToCreate);
const npcGroups = [...(target.npcGroups ?? [])];
const groupIdMap = new Map<string, string>();
const orderedSourceGroups = topologicalNpcGroups(source.npcGroups ?? []).filter((g) =>
neededGroupIds.has(g.id),
);
for (const g of orderedSourceGroups) {
const mappedParent = g.parentId
? (asNpcGroupId(groupIdMap.get(g.parentId) ?? g.parentId) as NpcGroupId | null)
: null;
const parentKey = mappedParent;
const siblings = npcGroups.filter((x) => x.parentId === parentKey);
const nameKey = g.name.trim().toLowerCase();
const existing = siblings.find((x) => x.name.trim().toLowerCase() === nameKey);
if (existing) {
groupIdMap.set(g.id, existing.id);
continue;
}
let name = g.name.trim();
const siblingKeys = new Set(siblings.map((x) => x.name.trim().toLowerCase()));
if (siblingKeys.has(name.toLowerCase())) {
let i = 2;
while (siblingKeys.has(`${name.toLowerCase()} (${String(i)})`)) i += 1;
name = `${name} (${String(i)})`;
}
const newId = asNpcGroupId(`ng_${generateId()}`);
groupIdMap.set(g.id, newId);
npcGroups.push({
id: newId,
name,
color: g.color,
parentId: mappedParent,
});
}
const npcs = [...(target.npcs ?? [])];
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
const npcIdMap = new Map<string, string>();
let npcsCreated = 0;
let npcsReused = 0;
for (const n of exportedNpcs) {
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
const res = npcResolutionBySource.get(n.id);
if (res?.mode === 'use') {
npcIdMap.set(n.id, res.targetNpcId);
npcsReused += 1;
continue;
}
// default create (also when no resolution entry)
let name = n.name.trim();
const baseKey = name.toLowerCase();
if (npcNameKeys.has(baseKey)) {
@@ -541,6 +691,10 @@ export function mergeStorylinesIntoProject(
}
const newId = asNpcId(`npc_${generateId()}`);
npcIdMap.set(n.id, newId);
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,
@@ -548,21 +702,25 @@ export function mergeStorylinesIntoProject(
description: n.description ?? '',
x: n.x,
y: n.y,
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
binding,
});
npcNameKeys.add(name.toLowerCase());
npcAssetIds.add(mappedAvatar);
npcsCreated += 1;
}
const npcRelations = [...(target.npcRelations ?? [])];
const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id));
for (const r of source.npcRelations ?? []) {
const sourceId = npcIdMap.get(r.sourceNpcId) ?? r.sourceNpcId;
const targetId = npcIdMap.get(r.targetNpcId) ?? r.targetNpcId;
if (!exportedNpcIdSet.has(r.sourceNpcId) || !exportedNpcIdSet.has(r.targetNpcId)) continue;
const sourceId = npcIdMap.get(r.sourceNpcId);
const targetId = npcIdMap.get(r.targetNpcId);
if (!sourceId || !targetId) continue;
if (!npcs.some((n) => n.id === sourceId) || !npcs.some((n) => n.id === targetId)) continue;
const label = r.label.trim();
if (!label) continue;
const dup = npcRelations.some(
(x) =>
x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
(x) => x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
);
if (dup) continue;
npcRelations.push({
@@ -580,6 +738,7 @@ export function mergeStorylinesIntoProject(
campaignAudios,
materials,
npcs,
npcGroups,
npcRelations,
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
@@ -602,6 +761,8 @@ export function mergeStorylinesIntoProject(
edgesAdded: newEdges.length,
assetsCopied,
assetsReused,
npcsCreated,
npcsReused,
renamedSideTitles,
},
assetCopies,
@@ -613,6 +774,41 @@ export function computeGraphImportOffsetX(target: Project, padding = 120): numbe
return maxX + padding;
}
function topologicalNpcGroups(groups: ProjectNpcGroup[]): ProjectNpcGroup[] {
const byId = new Map(groups.map((g) => [g.id, g]));
const out: ProjectNpcGroup[] = [];
const seen = new Set<string>();
const visit = (id: string): void => {
if (seen.has(id)) return;
seen.add(id);
const g = byId.get(asNpcGroupId(id));
if (!g) return;
if (g.parentId && byId.has(g.parentId)) visit(g.parentId);
out.push(g);
};
for (const g of groups) visit(g.id);
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()}`);
}