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(); 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 | 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(); 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 { const out = new Set([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); }