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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user