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
+80
View File
@@ -0,0 +1,80 @@
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 };
}
+128
View File
@@ -0,0 +1,128 @@
import type { NpcGroupId, ProjectNpc, ProjectNpcGroup } from '../types';
import { asNpcGroupId } from '../types/ids';
export const DEFAULT_NPC_GROUP_COLOR = '#6b7280';
export function normalizeHexColor(raw: unknown, fallback = DEFAULT_NPC_GROUP_COLOR): string {
if (typeof raw !== 'string') return fallback;
const s = raw.trim();
if (/^#[0-9a-fA-F]{6}$/u.test(s)) return s.toLowerCase();
if (/^#[0-9a-fA-F]{3}$/u.test(s)) {
const r = s[1]!;
const g = s[2]!;
const b = s[3]!;
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
}
return fallback;
}
export function uniqueNpcGroupName(base: string, existing: string[], exceptId?: NpcGroupId | null): string {
const root = base.trim() || 'Group';
const used = new Set(
existing
.map((n, i) => ({ n: n.trim().toLowerCase(), i }))
.filter(() => true)
.map((x) => x.n),
);
// existing is just names; caller filters except
void exceptId;
if (!used.has(root.toLowerCase())) return root;
let i = 2;
while (used.has(`${root.toLowerCase()} (${String(i)})`)) i += 1;
return `${root} (${String(i)})`;
}
export function normalizeNpcGroups(raw: unknown): ProjectNpcGroup[] {
if (!Array.isArray(raw)) return [];
const parsed: ProjectNpcGroup[] = [];
const ids = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const obj = item as { id?: string; name?: string; color?: string; parentId?: string | null };
if (!obj.id || typeof obj.name !== 'string') continue;
const name = obj.name.trim();
if (!name) continue;
const id = String(obj.id);
if (ids.has(id)) continue;
ids.add(id);
parsed.push({
id: asNpcGroupId(id),
name,
color: normalizeHexColor(obj.color),
parentId:
typeof obj.parentId === 'string' && obj.parentId.trim() ? asNpcGroupId(obj.parentId.trim()) : null,
});
}
// Drop parent links to missing groups (flatten to root).
const idSet = new Set(parsed.map((g) => g.id));
return parsed.map((g) => ({
...g,
parentId: g.parentId && idSet.has(g.parentId) && g.parentId !== g.id ? g.parentId : null,
}));
}
/** Разрешить groupId НПС; неизвестный → null («Без группы»). */
export function resolveNpcGroupId(raw: unknown, groupIds: Set<NpcGroupId>): NpcGroupId | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
const id = asNpcGroupId(raw.trim());
return groupIds.has(id) ? id : null;
}
export type NpcGroupTreeNode = {
group: ProjectNpcGroup;
children: NpcGroupTreeNode[];
npcs: ProjectNpc[];
};
/** Дерево групп + НПС без группы отдельно. */
export function buildNpcGroupForest(
groups: ProjectNpcGroup[],
npcs: ProjectNpc[],
): { roots: NpcGroupTreeNode[]; ungrouped: ProjectNpc[] } {
const byParent = new Map<string | null, ProjectNpcGroup[]>();
for (const g of groups) {
const key = g.parentId;
const list = byParent.get(key) ?? [];
list.push(g);
byParent.set(key, list);
}
// Preserve array order among siblings (already ordered in groups array).
const build = (parentId: NpcGroupId | null): NpcGroupTreeNode[] => {
const siblings = groups.filter((g) => g.parentId === parentId);
return siblings.map((group) => ({
group,
children: build(group.id),
npcs: npcs.filter((n) => n.groupId === group.id),
}));
};
return {
roots: build(null),
ungrouped: npcs.filter((n) => n.groupId === null),
};
}
export function collectDescendantGroupIds(groups: ProjectNpcGroup[], rootId: NpcGroupId): Set<NpcGroupId> {
const out = new Set<NpcGroupId>([rootId]);
let changed = true;
while (changed) {
changed = false;
for (const g of groups) {
if (g.parentId && out.has(g.parentId) && !out.has(g.id)) {
out.add(g.id);
changed = true;
}
}
}
return out;
}
/** Нельзя сделать parentId потомком (цикл). */
export function wouldCreateGroupCycle(
groups: ProjectNpcGroup[],
groupId: NpcGroupId,
newParentId: NpcGroupId | null,
): boolean {
if (!newParentId) return false;
if (newParentId === groupId) return true;
return collectDescendantGroupIds(groups, groupId).has(newParentId);
}