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:
@@ -61,7 +61,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
|
||||
updatedAt: '2020-01-01T00:00:00.000Z',
|
||||
createdWithAppVersion: '1',
|
||||
appVersion: '1',
|
||||
schemaVersion: 8,
|
||||
schemaVersion: 9,
|
||||
},
|
||||
scenes: {},
|
||||
sceneListOrder: [],
|
||||
@@ -69,6 +69,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcGroups: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user