feat(npcs): drop binding; select NPCs on storyline export
Remove storyline/scene NPC binding and export chosen NPCs with relations/groups. Harden export modal so a missing npcs payload no longer blacks out the editor. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,7 +18,6 @@ import type {
|
||||
FoundryPlaylistDoc,
|
||||
FoundrySceneDoc,
|
||||
} from '../../shared/foundry/foundryTypes';
|
||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
||||
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
|
||||
import type {
|
||||
MediaAsset,
|
||||
@@ -447,7 +446,6 @@ export async function buildProjectFromFoundryDocuments(
|
||||
x: 80 + (npcIndex % 4) * 220,
|
||||
y: 80 + Math.floor(npcIndex / 4) * 200,
|
||||
groupId,
|
||||
binding: noneBinding(),
|
||||
});
|
||||
npcIndex += 1;
|
||||
}
|
||||
|
||||
+5
-7
@@ -757,7 +757,7 @@ async function main() {
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.project.upsertNpc,
|
||||
async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => {
|
||||
async ({ npcId, name, description, filePath: pathFromDrop, groupId }) => {
|
||||
let filePath = pathFromDrop;
|
||||
if (!filePath && !npcId) {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
@@ -781,7 +781,6 @@ async function main() {
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
},
|
||||
(p) => emitNpcUpsertProgress(p),
|
||||
);
|
||||
@@ -793,12 +792,11 @@ async function main() {
|
||||
);
|
||||
registerHandler(
|
||||
ipcChannels.project.updateNpcFields,
|
||||
async ({ npcId, name, description, groupId, binding }) => {
|
||||
async ({ npcId, name, description, groupId }) => {
|
||||
const project = await projectStore.updateNpcFields(npcId, {
|
||||
...(typeof name === 'string' ? { name } : {}),
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
@@ -1017,8 +1015,7 @@ async function main() {
|
||||
return { canceled: false as const, project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => {
|
||||
const storylines = await projectStore.getProjectStorylines(projectId, labels);
|
||||
return { storylines };
|
||||
return projectStore.getProjectStorylines(projectId, labels);
|
||||
});
|
||||
registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
@@ -1159,7 +1156,7 @@ async function main() {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => {
|
||||
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, npcIds, labels }) => {
|
||||
const list = await projectStore.listProjects();
|
||||
const entry = list.find((p) => p.id === projectId);
|
||||
if (!entry) {
|
||||
@@ -1183,6 +1180,7 @@ async function main() {
|
||||
await projectStore.exportStorylinesZipToPath(
|
||||
projectId,
|
||||
storylineSelections,
|
||||
npcIds ?? [],
|
||||
dest,
|
||||
labels,
|
||||
(p) => {
|
||||
|
||||
@@ -39,7 +39,6 @@ import type {
|
||||
MaterialLegend,
|
||||
MediaAsset,
|
||||
MediaAssetType,
|
||||
NpcBinding,
|
||||
Project,
|
||||
ProjectId,
|
||||
ProjectNpc,
|
||||
@@ -66,12 +65,6 @@ import {
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
} from '../../shared/types/ids';
|
||||
import {
|
||||
clearNpcBindingsForDeletedScene,
|
||||
clearNpcBindingsForRemovedStoryline,
|
||||
noneBinding,
|
||||
normalizeNpcBinding,
|
||||
} from '../../shared/npcs/npcBinding';
|
||||
import {
|
||||
DEFAULT_NPC_GROUP_COLOR,
|
||||
normalizeHexColor,
|
||||
@@ -712,25 +705,9 @@ 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,
|
||||
@@ -792,25 +769,9 @@ export class ZipProjectStore {
|
||||
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
|
||||
throw new Error('Graph node not found');
|
||||
}
|
||||
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) {
|
||||
@@ -841,19 +802,8 @@ export class ZipProjectStore {
|
||||
return open.project;
|
||||
}
|
||||
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) {
|
||||
@@ -916,23 +866,7 @@ export class ZipProjectStore {
|
||||
await this.updateProject((p) => {
|
||||
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
|
||||
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
|
||||
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) };
|
||||
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
@@ -1309,7 +1243,6 @@ export class ZipProjectStore {
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
},
|
||||
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
|
||||
): Promise<Project> {
|
||||
@@ -1384,7 +1317,6 @@ export class ZipProjectStore {
|
||||
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');
|
||||
@@ -1397,7 +1329,6 @@ export class ZipProjectStore {
|
||||
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 };
|
||||
@@ -1415,7 +1346,6 @@ export class ZipProjectStore {
|
||||
name?: string;
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
},
|
||||
): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
@@ -1447,7 +1377,6 @@ export class ZipProjectStore {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||
groupId,
|
||||
...(patch.binding !== undefined ? { binding: patch.binding } : {}),
|
||||
};
|
||||
});
|
||||
return { ...p, npcs };
|
||||
@@ -2000,10 +1929,13 @@ export class ZipProjectStore {
|
||||
async getProjectStorylines(
|
||||
projectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
): Promise<StorylineListItem[]> {
|
||||
): Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }> {
|
||||
const snap = await this.loadProjectSnapshot(projectId);
|
||||
try {
|
||||
return listExportableStorylines(snap.project, labels);
|
||||
return {
|
||||
storylines: listExportableStorylines(snap.project, labels),
|
||||
npcs: (snap.project.npcs ?? []).map((n) => ({ id: n.id, name: n.name })),
|
||||
};
|
||||
} finally {
|
||||
if (snap.ownsCache) {
|
||||
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -2014,6 +1946,7 @@ export class ZipProjectStore {
|
||||
async exportStorylinesZipToPath(
|
||||
projectId: ProjectId,
|
||||
selections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
destinationPath: string,
|
||||
labels: StorylineLabels,
|
||||
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
||||
@@ -2030,6 +1963,7 @@ export class ZipProjectStore {
|
||||
newProjectId: newExportBundleProjectId(),
|
||||
exportTitle: entry?.name ?? snap.project.meta.name,
|
||||
labels,
|
||||
npcIds,
|
||||
});
|
||||
await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
|
||||
const assetIds = Object.keys(partial.assets) as AssetId[];
|
||||
@@ -2483,11 +2417,6 @@ function normalizeProject(p: Project): Project {
|
||||
);
|
||||
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) => {
|
||||
@@ -2500,7 +2429,6 @@ function normalizeProject(p: Project): Project {
|
||||
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();
|
||||
@@ -2516,11 +2444,6 @@ function normalizeProject(p: Project): Project {
|
||||
x,
|
||||
y,
|
||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||
binding: normalizeNpcBinding(obj.binding, {
|
||||
sceneIds: sceneIdSet,
|
||||
sideStartIds,
|
||||
hasMainStart,
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((x): x is ProjectNpc => Boolean(x));
|
||||
|
||||
Reference in New Issue
Block a user