f4c0ac1438
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>
81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
import { listSideStoryStarts } from '../graph/sceneGraphLineage';
|
|
import type { GraphNodeId, NpcBinding, NpcStorylineRef, Project, ProjectNpc, SceneId } from '../types';
|
|
|
|
export function noneBinding(): NpcBinding {
|
|
return { kind: 'none' };
|
|
}
|
|
|
|
export function isNpcBindingNone(b: NpcBinding | undefined | null): boolean {
|
|
return !b || b.kind === 'none';
|
|
}
|
|
|
|
export function storylineRefKey(ref: NpcStorylineRef): string {
|
|
return ref.kind === 'main' ? 'main' : `side:${ref.startGraphNodeId}`;
|
|
}
|
|
|
|
export function normalizeNpcBinding(
|
|
raw: unknown,
|
|
ctx: { sceneIds: Set<SceneId>; sideStartIds: Set<GraphNodeId>; hasMainStart: boolean },
|
|
): NpcBinding {
|
|
if (!raw || typeof raw !== 'object') return noneBinding();
|
|
const obj = raw as {
|
|
kind?: string;
|
|
sceneId?: string;
|
|
storyline?: { kind?: string; startGraphNodeId?: string };
|
|
};
|
|
if (obj.kind === 'scene' && typeof obj.sceneId === 'string') {
|
|
const sceneId = obj.sceneId as SceneId;
|
|
if (ctx.sceneIds.has(sceneId)) return { kind: 'scene', sceneId };
|
|
return noneBinding();
|
|
}
|
|
if (obj.kind === 'storyline' && obj.storyline && typeof obj.storyline === 'object') {
|
|
if (obj.storyline.kind === 'main') {
|
|
return ctx.hasMainStart ? { kind: 'storyline', storyline: { kind: 'main' } } : noneBinding();
|
|
}
|
|
if (obj.storyline.kind === 'side' && typeof obj.storyline.startGraphNodeId === 'string') {
|
|
const id = obj.storyline.startGraphNodeId as GraphNodeId;
|
|
if (ctx.sideStartIds.has(id)) {
|
|
return { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: id } };
|
|
}
|
|
}
|
|
}
|
|
return noneBinding();
|
|
}
|
|
|
|
export function clearNpcBindingsForDeletedScene(npcs: ProjectNpc[], sceneId: SceneId): ProjectNpc[] {
|
|
return npcs.map((n) => {
|
|
if (n.binding?.kind === 'scene' && n.binding.sceneId === sceneId) {
|
|
return { ...n, binding: noneBinding() };
|
|
}
|
|
return n;
|
|
});
|
|
}
|
|
|
|
export function clearNpcBindingsForRemovedStoryline(
|
|
npcs: ProjectNpc[],
|
|
removed: NpcStorylineRef,
|
|
): ProjectNpc[] {
|
|
return npcs.map((n) => {
|
|
if (n.binding?.kind !== 'storyline') return n;
|
|
const ref = n.binding.storyline;
|
|
if (removed.kind === 'main' && ref.kind === 'main') return { ...n, binding: noneBinding() };
|
|
if (removed.kind === 'side' && ref.kind === 'side' && ref.startGraphNodeId === removed.startGraphNodeId) {
|
|
return { ...n, binding: noneBinding() };
|
|
}
|
|
return n;
|
|
});
|
|
}
|
|
|
|
export function listStorylineOptionsForBinding(project: Project): {
|
|
main: boolean;
|
|
sides: { startGraphNodeId: GraphNodeId; label: string }[];
|
|
} {
|
|
const hasMain = project.sceneGraphNodes.some((n) => n.isStartScene);
|
|
const sides = listSideStoryStarts(project.sceneGraphNodes).map((n) => {
|
|
const scene = project.scenes[n.sceneId];
|
|
const label = n.sideStoryLineTitle.trim() || scene?.title.trim() || String(n.id);
|
|
return { startGraphNodeId: n.id, label };
|
|
});
|
|
return { main: hasMain, sides };
|
|
}
|