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
+134 -55
View File
@@ -625,42 +625,52 @@ async function main() {
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.project.upsertNpc, async ({ npcId, name, description, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('NPC avatar is required');
registerHandler(
ipcChannels.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => {
let filePath = pathFromDrop;
if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('NPC avatar is required');
}
filePath = filePaths[0];
}
filePath = filePaths[0];
}
const project = await projectStore.upsertNpc({
...(npcId ? { npcId } : {}),
name,
...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}),
});
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.updateNpcFields, async ({ npcId, name, description }) => {
const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}),
});
emitSessionState();
return { project };
});
const project = await projectStore.upsertNpc({
...(npcId ? { npcId } : {}),
name,
...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(binding !== undefined ? { binding } : {}),
});
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
},
);
registerHandler(
ipcChannels.project.updateNpcFields,
async ({ npcId, name, description, groupId, binding }) => {
const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(binding !== undefined ? { binding } : {}),
});
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => {
const project = await projectStore.updateNpcPosition(npcId, x, y);
emitSessionState();
@@ -715,6 +725,31 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(
ipcChannels.project.upsertNpcGroup,
async ({ groupId, name, color, parentId }) => {
const project = await projectStore.upsertNpcGroup({
...(groupId ? { groupId } : {}),
name,
...(typeof color === 'string' ? { color } : {}),
...(parentId !== undefined ? { parentId } : {}),
});
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => {
const project = await projectStore.deleteNpcGroup(groupId);
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setNpcGroupsOrder, async ({ groupIds }) => {
const project = await projectStore.setNpcGroupsOrder(groupIds);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath) {
@@ -880,28 +915,32 @@ async function main() {
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
});
registerHandler(ipcChannels.project.mergeImportZip, async ({ filePath, storylineSelections, sceneResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
filePath,
storylineSelections,
sceneResolutions,
(p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project, report };
});
registerHandler(
ipcChannels.project.mergeImportZip,
async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
filePath,
storylineSelections,
sceneResolutions,
(p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
npcResolutions,
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project, report };
},
);
registerHandler(
ipcChannels.project.mergeImportFromProject,
async ({ sourceProjectId, storylineSelections, sceneResolutions }) => {
async ({ sourceProjectId, storylineSelections, sceneResolutions, npcResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromProjectId(
sourceProjectId,
@@ -915,6 +954,7 @@ async function main() {
...(p.detail ? { detail: p.detail } : null),
});
},
npcResolutions,
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
@@ -935,6 +975,45 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.pickFoundrySource, async ({ mode }) => {
if (mode === 'folder') {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openDirectory'],
title: 'Папка мира или модуля Foundry VTT',
});
if (canceled || !filePaths[0]) return { canceled: true as const };
return { canceled: false as const, sourcePath: filePaths[0] };
}
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
title: 'Архив мира или модуля Foundry VTT',
filters: [
{ name: 'Foundry package', extensions: ['zip', 'fvtt'] },
{ name: 'All files', extensions: ['*'] },
],
});
if (canceled || !filePaths[0]) return { canceled: true as const };
return { canceled: false as const, sourcePath: filePaths[0] };
});
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
try {
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project };
} catch (e) {
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Ошибка' });
throw e;
}
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);