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:
+299
-24
@@ -20,6 +20,7 @@ import {
|
||||
listImportableStorylines,
|
||||
mergeStorylinesIntoProject,
|
||||
newExportBundleProjectId,
|
||||
type NpcImportResolution,
|
||||
type SceneImportResolution,
|
||||
type StorylineImportMergeReport,
|
||||
type StorylineLabels,
|
||||
@@ -35,9 +36,11 @@ import {
|
||||
import type {
|
||||
MediaAsset,
|
||||
MediaAssetType,
|
||||
NpcBinding,
|
||||
Project,
|
||||
ProjectId,
|
||||
ProjectNpc,
|
||||
ProjectNpcGroup,
|
||||
ProjectNpcRelation,
|
||||
Scene,
|
||||
SceneGraphEdge,
|
||||
@@ -45,15 +48,34 @@ import type {
|
||||
SceneId,
|
||||
} from '../../shared/types';
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
asMaterialId,
|
||||
asNpcGroupId,
|
||||
asNpcId,
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
} from '../../shared/types/ids';
|
||||
import {
|
||||
clearNpcBindingsForDeletedScene,
|
||||
clearNpcBindingsForRemovedStoryline,
|
||||
noneBinding,
|
||||
normalizeNpcBinding,
|
||||
} from '../../shared/npcs/npcBinding';
|
||||
import {
|
||||
DEFAULT_NPC_GROUP_COLOR,
|
||||
normalizeHexColor,
|
||||
normalizeNpcGroups,
|
||||
resolveNpcGroupId,
|
||||
wouldCreateGroupCycle,
|
||||
} from '../../shared/npcs/npcGroups';
|
||||
import {
|
||||
buildProjectFromFoundryDocuments,
|
||||
loadFoundryDocumentsForImport,
|
||||
type FoundryImportProgress,
|
||||
} from '../foundry/foundryImport';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { reconcileAssetFiles } from './assetPrune';
|
||||
@@ -236,6 +258,7 @@ export class ZipProjectStore {
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcGroups: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
@@ -660,9 +683,25 @@ export class ZipProjectStore {
|
||||
currentSceneId = ids[0] ?? null;
|
||||
}
|
||||
|
||||
const removedSideStarts = p.sceneGraphNodes.filter(
|
||||
(n) => n.sceneId === sceneId && n.isSideStoryStart,
|
||||
);
|
||||
let npcs = clearNpcBindingsForDeletedScene(p.npcs ?? [], sceneId);
|
||||
for (const side of removedSideStarts) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
|
||||
kind: 'side',
|
||||
startGraphNodeId: side.id,
|
||||
});
|
||||
}
|
||||
const hadMainOnScene = p.sceneGraphNodes.some((n) => n.sceneId === sceneId && n.isStartScene);
|
||||
if (hadMainOnScene) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
|
||||
}
|
||||
|
||||
return {
|
||||
...withGraph,
|
||||
scenes: nextScenes,
|
||||
npcs,
|
||||
sceneListOrder: removeFromSceneListOrder(
|
||||
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
|
||||
sceneId,
|
||||
@@ -724,16 +763,34 @@ export class ZipProjectStore {
|
||||
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
|
||||
throw new Error('Graph node not found');
|
||||
}
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
const isMain = graphNodeId !== null && n.id === graphNodeId;
|
||||
if (isMain) {
|
||||
return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' };
|
||||
}
|
||||
return { ...n, isStartScene: false };
|
||||
}),
|
||||
}));
|
||||
const prevMain = open.project.sceneGraphNodes.find((n) => n.isStartScene);
|
||||
const clearingMain = graphNodeId === null || (prevMain && prevMain.id !== graphNodeId);
|
||||
await this.updateProject((p) => {
|
||||
let npcs = p.npcs ?? [];
|
||||
if (clearingMain && prevMain) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
|
||||
}
|
||||
const demotedSides = p.sceneGraphNodes.filter(
|
||||
(n) => n.isSideStoryStart && graphNodeId !== null && n.id === graphNodeId,
|
||||
);
|
||||
for (const side of demotedSides) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
|
||||
kind: 'side',
|
||||
startGraphNodeId: side.id,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
npcs,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
const isMain = graphNodeId !== null && n.id === graphNodeId;
|
||||
if (isMain) {
|
||||
return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' };
|
||||
}
|
||||
return { ...n, isStartScene: false };
|
||||
}),
|
||||
};
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -754,16 +811,29 @@ export class ZipProjectStore {
|
||||
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
|
||||
return open.project;
|
||||
}
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
if (n.id !== graphNodeId) return n;
|
||||
if (enabling) {
|
||||
return { ...n, isSideStoryStart: true, isStartScene: false };
|
||||
}
|
||||
return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
|
||||
}),
|
||||
}));
|
||||
await this.updateProject((p) => {
|
||||
let npcs = p.npcs ?? [];
|
||||
if (!enabling) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
|
||||
kind: 'side',
|
||||
startGraphNodeId: graphNodeId,
|
||||
});
|
||||
}
|
||||
if (enabling && node.isStartScene) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
npcs,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
if (n.id !== graphNodeId) return n;
|
||||
if (enabling) {
|
||||
return { ...n, isSideStoryStart: true, isStartScene: false };
|
||||
}
|
||||
return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
|
||||
}),
|
||||
};
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -817,7 +887,23 @@ export class ZipProjectStore {
|
||||
await this.updateProject((p) => {
|
||||
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
|
||||
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
|
||||
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
|
||||
const npcs = (p.npcs ?? []).map((n) => {
|
||||
if (
|
||||
n.binding?.kind === 'storyline' &&
|
||||
n.binding.storyline.kind === 'side' &&
|
||||
n.binding.storyline.startGraphNodeId === nodeId
|
||||
) {
|
||||
return {
|
||||
...n,
|
||||
binding: {
|
||||
kind: 'storyline' as const,
|
||||
storyline: { kind: 'side' as const, startGraphNodeId: newStartId },
|
||||
},
|
||||
};
|
||||
}
|
||||
return n;
|
||||
});
|
||||
return { ...withGraph, npcs, scenes: applyConnectionSets(withGraph.scenes, out) };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
@@ -1154,6 +1240,8 @@ export class ZipProjectStore {
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
}): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
@@ -1199,6 +1287,13 @@ export class ZipProjectStore {
|
||||
const assets = { ...p.assets };
|
||||
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
|
||||
|
||||
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
|
||||
const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => {
|
||||
if (raw === undefined) return prev;
|
||||
if (raw === null) return null;
|
||||
return groupIds.has(raw) ? raw : null;
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
const idx = npcs.findIndex((n) => n.id === editingId);
|
||||
if (idx < 0) throw new Error('NPC not found');
|
||||
@@ -1209,6 +1304,8 @@ export class ZipProjectStore {
|
||||
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
|
||||
description:
|
||||
typeof input.description === 'string' ? input.description : prev.description,
|
||||
groupId: resolveGroup(input.groupId, prev.groupId),
|
||||
binding: input.binding !== undefined ? input.binding : prev.binding,
|
||||
};
|
||||
} else {
|
||||
if (!nextAssetId) throw new Error('NPC avatar is required');
|
||||
@@ -1220,6 +1317,8 @@ export class ZipProjectStore {
|
||||
description: typeof input.description === 'string' ? input.description : '',
|
||||
x: 80 + (count % 4) * 220,
|
||||
y: 80 + Math.floor(count / 4) * 200,
|
||||
groupId: resolveGroup(input.groupId, null),
|
||||
binding: input.binding ?? noneBinding(),
|
||||
});
|
||||
}
|
||||
return { ...p, assets, npcs };
|
||||
@@ -1232,7 +1331,12 @@ export class ZipProjectStore {
|
||||
|
||||
async updateNpcFields(
|
||||
npcId: NpcId,
|
||||
patch: { name?: string; description?: string },
|
||||
patch: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
},
|
||||
): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
@@ -1250,12 +1354,20 @@ export class ZipProjectStore {
|
||||
}
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
|
||||
const npcs = (p.npcs ?? []).map((n) => {
|
||||
if (n.id !== npcId) return n;
|
||||
let groupId = n.groupId;
|
||||
if (patch.groupId !== undefined) {
|
||||
groupId =
|
||||
patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
|
||||
}
|
||||
return {
|
||||
...n,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||
groupId,
|
||||
...(patch.binding !== undefined ? { binding: patch.binding } : {}),
|
||||
};
|
||||
});
|
||||
return { ...p, npcs };
|
||||
@@ -1313,6 +1425,101 @@ export class ZipProjectStore {
|
||||
return latest;
|
||||
}
|
||||
|
||||
async upsertNpcGroup(input: {
|
||||
groupId?: NpcGroupId;
|
||||
name: string;
|
||||
color?: string;
|
||||
parentId?: NpcGroupId | null;
|
||||
}): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name = input.name.trim();
|
||||
if (name.length < 1) throw new Error('Group name is required');
|
||||
const color = normalizeHexColor(input.color, DEFAULT_NPC_GROUP_COLOR);
|
||||
await this.updateProject((p) => {
|
||||
const groups = [...(p.npcGroups ?? [])];
|
||||
const editingId = input.groupId ?? null;
|
||||
const parentId =
|
||||
input.parentId === undefined
|
||||
? editingId
|
||||
? (groups.find((g) => g.id === editingId)?.parentId ?? null)
|
||||
: null
|
||||
: input.parentId;
|
||||
if (parentId && !groups.some((g) => g.id === parentId)) {
|
||||
throw new Error('Parent group not found');
|
||||
}
|
||||
if (editingId && parentId && wouldCreateGroupCycle(groups, editingId, parentId)) {
|
||||
throw new Error('Invalid group parent');
|
||||
}
|
||||
const nameKey = name.toLowerCase();
|
||||
const siblingConflict = groups.some(
|
||||
(g) =>
|
||||
g.id !== editingId &&
|
||||
g.parentId === parentId &&
|
||||
g.name.trim().toLowerCase() === nameKey,
|
||||
);
|
||||
if (siblingConflict) throw new Error('Group name already exists');
|
||||
|
||||
if (editingId) {
|
||||
const idx = groups.findIndex((g) => g.id === editingId);
|
||||
if (idx < 0) throw new Error('Group not found');
|
||||
groups[idx] = { ...groups[idx]!, name, color, parentId };
|
||||
} else {
|
||||
groups.push({
|
||||
id: asNpcGroupId(`ng_${this.randomId()}`),
|
||||
name,
|
||||
color,
|
||||
parentId,
|
||||
});
|
||||
}
|
||||
return { ...p, npcGroups: groups };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async deleteNpcGroup(groupId: NpcGroupId): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => {
|
||||
const groups = p.npcGroups ?? [];
|
||||
if (!groups.some((g) => g.id === groupId)) throw new Error('Group not found');
|
||||
const parentOfDeleted = groups.find((g) => g.id === groupId)?.parentId ?? null;
|
||||
const nextGroups = groups
|
||||
.filter((g) => g.id !== groupId)
|
||||
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
|
||||
const npcs = (p.npcs ?? []).map((n) =>
|
||||
n.groupId === groupId ? { ...n, groupId: null } : n,
|
||||
);
|
||||
return { ...p, npcGroups: nextGroups, npcs };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async setNpcGroupsOrder(groupIds: NpcGroupId[]): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => {
|
||||
const byId = new Map((p.npcGroups ?? []).map((g) => [g.id, g]));
|
||||
const next: ProjectNpcGroup[] = [];
|
||||
for (const id of groupIds) {
|
||||
const g = byId.get(id);
|
||||
if (g) {
|
||||
next.push(g);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const g of byId.values()) next.push(g);
|
||||
return { ...p, npcGroups: next };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async upsertNpcRelation(input: {
|
||||
relationId?: NpcRelationId;
|
||||
sourceNpcId: NpcId;
|
||||
@@ -1583,6 +1790,50 @@ export class ZipProjectStore {
|
||||
return opened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт мира/модуля Foundry VTT (папка или .zip/.fvtt) в новый проект.
|
||||
* Создаёт `.ttrpg.zip`, открывает проект и возвращает его.
|
||||
*/
|
||||
async importProjectFromFoundry(
|
||||
sourcePath: string,
|
||||
onProgress?: (p: FoundryImportProgress) => void,
|
||||
): Promise<Project> {
|
||||
await this.ensureRoots();
|
||||
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
|
||||
try {
|
||||
this.projectSession += 1;
|
||||
const projectId = asProjectId(this.randomId());
|
||||
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
|
||||
await fs.rm(cacheDir, { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
|
||||
|
||||
const { project } = await buildProjectFromFoundryDocuments(
|
||||
loaded.manifest,
|
||||
loaded.docs,
|
||||
cacheDir,
|
||||
onProgress,
|
||||
{ projectId },
|
||||
);
|
||||
|
||||
const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(project.meta.fileBaseName));
|
||||
const projectPath = path.join(cacheDir, 'project.json');
|
||||
this.openProject = {
|
||||
id: project.id,
|
||||
zipPath,
|
||||
cacheDir,
|
||||
projectPath,
|
||||
project,
|
||||
};
|
||||
await this.writeCacheProject(cacheDir, project);
|
||||
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
|
||||
await this.enqueuePack(cacheDir, zipPath);
|
||||
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
|
||||
return this.openProject.project;
|
||||
} finally {
|
||||
await loaded.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
|
||||
async exportProjectZipToPath(
|
||||
projectId: ProjectId,
|
||||
@@ -1809,6 +2060,7 @@ export class ZipProjectStore {
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
if (!this.openProject) throw new Error('Нет открытого проекта');
|
||||
const offsetX = computeGraphImportOffsetX(this.openProject.project);
|
||||
@@ -1817,7 +2069,10 @@ export class ZipProjectStore {
|
||||
source,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
{ graphOffsetX: offsetX },
|
||||
{
|
||||
graphOffsetX: offsetX,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
const targetCache = this.openProject.cacheDir;
|
||||
@@ -1851,6 +2106,7 @@ export class ZipProjectStore {
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
this.assertStorylineMergeAllowed(selections);
|
||||
const snap = await this.loadProjectSnapshot(sourceProjectId);
|
||||
@@ -1861,6 +2117,7 @@ export class ZipProjectStore {
|
||||
selections,
|
||||
sceneResolutions,
|
||||
onProgress,
|
||||
npcResolutions,
|
||||
);
|
||||
} finally {
|
||||
if (snap.ownsCache) {
|
||||
@@ -1874,6 +2131,7 @@ export class ZipProjectStore {
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
this.assertStorylineMergeAllowed(selections);
|
||||
const source = await this.readExternalProjectForImport(sourcePath);
|
||||
@@ -1887,6 +2145,7 @@ export class ZipProjectStore {
|
||||
selections,
|
||||
sceneResolutions,
|
||||
onProgress,
|
||||
npcResolutions,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -2095,6 +2354,13 @@ function normalizeProject(p: Project): Project {
|
||||
(x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } =>
|
||||
Boolean(x),
|
||||
);
|
||||
const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
|
||||
const groupIdSet = new Set(npcGroups.map((g) => g.id));
|
||||
const sceneIdSet = new Set(Object.keys(scenes) as SceneId[]);
|
||||
const sideStartIds = new Set(
|
||||
sceneGraphNodes.filter((n) => n.isSideStoryStart).map((n) => n.id),
|
||||
);
|
||||
const hasMainStart = sceneGraphNodes.some((n) => n.isStartScene);
|
||||
const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
|
||||
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
|
||||
.map((n, index) => {
|
||||
@@ -2106,6 +2372,8 @@ function normalizeProject(p: Project): Project {
|
||||
description?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
groupId?: string | null;
|
||||
binding?: unknown;
|
||||
};
|
||||
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
@@ -2120,6 +2388,12 @@ function normalizeProject(p: Project): Project {
|
||||
description: typeof obj.description === 'string' ? obj.description : '',
|
||||
x,
|
||||
y,
|
||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||
binding: normalizeNpcBinding(obj.binding, {
|
||||
sceneIds: sceneIdSet,
|
||||
sideStartIds,
|
||||
hasMainStart,
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((x): x is ProjectNpc => Boolean(x));
|
||||
@@ -2171,6 +2445,7 @@ function normalizeProject(p: Project): Project {
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
npcGroups,
|
||||
npcRelations,
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
|
||||
Reference in New Issue
Block a user