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
+299 -24
View File
@@ -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,