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>
655 lines
22 KiB
TypeScript
655 lines
22 KiB
TypeScript
import crypto from 'node:crypto';
|
||
import fs from 'node:fs/promises';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
|
||
import {
|
||
actorPortraitSrc,
|
||
extractActorDescriptionHtml,
|
||
filterScenesForImport,
|
||
journalDescriptionHtml,
|
||
planFoundrySceneGraph,
|
||
sceneBackgroundSrc,
|
||
} from '../../shared/foundry/foundryGraph';
|
||
import type {
|
||
FoundryFolderDoc,
|
||
FoundryLoadedDocuments,
|
||
FoundryPackageManifest,
|
||
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,
|
||
Project,
|
||
ProjectNpc,
|
||
ProjectNpcGroup,
|
||
Scene,
|
||
SceneGraphEdge,
|
||
SceneGraphNode,
|
||
} from '../../shared/types';
|
||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||
import type { AssetId, GraphNodeId, NpcGroupId, SceneId } from '../../shared/types/ids';
|
||
import {
|
||
asAssetId,
|
||
asGraphNodeId,
|
||
asNpcGroupId,
|
||
asNpcId,
|
||
asProjectId,
|
||
asSceneId,
|
||
} from '../../shared/types/ids';
|
||
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
|
||
import { generateScenePreviewThumbnailBytes } from '../project/scenePreviewThumbnail';
|
||
import { unzipToDir } from '../project/yauzlProjectZip';
|
||
import { getAppSemanticVersion } from '../versionInfo';
|
||
|
||
import { loadModuleDocuments, loadWorldDocuments } from './foundryDb';
|
||
import { detectFoundryPackage, resolveFoundryAssetPath } from './foundryDetect';
|
||
|
||
export type FoundryImportProgress = {
|
||
stage: 'copy' | 'unzip' | 'zip' | 'done';
|
||
percent: number;
|
||
detail?: string;
|
||
};
|
||
|
||
/** Минимальный 1×1 PNG (серый), если у актёра нет портрета на диске. */
|
||
const PLACEHOLDER_PNG = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||
'base64',
|
||
);
|
||
|
||
type MediaKind = { type: 'image' | 'video' | 'audio'; mime: string };
|
||
|
||
function classifyMediaPath(filePath: string): MediaKind | null {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
switch (ext) {
|
||
case '.png':
|
||
return { type: 'image', mime: 'image/png' };
|
||
case '.jpg':
|
||
case '.jpeg':
|
||
return { type: 'image', mime: 'image/jpeg' };
|
||
case '.webp':
|
||
return { type: 'image', mime: 'image/webp' };
|
||
case '.gif':
|
||
return { type: 'image', mime: 'image/gif' };
|
||
case '.bmp':
|
||
return { type: 'image', mime: 'image/bmp' };
|
||
case '.mp4':
|
||
return { type: 'video', mime: 'video/mp4' };
|
||
case '.webm':
|
||
return { type: 'video', mime: 'video/webm' };
|
||
case '.mov':
|
||
return { type: 'video', mime: 'video/quicktime' };
|
||
case '.mp3':
|
||
return { type: 'audio', mime: 'audio/mpeg' };
|
||
case '.wav':
|
||
return { type: 'audio', mime: 'audio/wav' };
|
||
case '.ogg':
|
||
return { type: 'audio', mime: 'audio/ogg' };
|
||
case '.m4a':
|
||
return { type: 'audio', mime: 'audio/mp4' };
|
||
case '.aac':
|
||
return { type: 'audio', mime: 'audio/aac' };
|
||
case '.flac':
|
||
return { type: 'audio', mime: 'audio/flac' };
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function sanitizeFileName(name: string): string {
|
||
const cleaned = name
|
||
.split('')
|
||
.map((ch) => {
|
||
const code = ch.charCodeAt(0);
|
||
if (code < 32 || '<>:"/\\|?*'.includes(ch)) return '_';
|
||
return ch;
|
||
})
|
||
.join('')
|
||
.trim();
|
||
return cleaned.length > 0 ? cleaned.slice(0, 180) : 'file';
|
||
}
|
||
|
||
function randomId(): string {
|
||
return crypto.randomBytes(16).toString('hex');
|
||
}
|
||
|
||
function buildMediaAsset(
|
||
id: AssetId,
|
||
kind: MediaKind,
|
||
originalName: string,
|
||
relPath: string,
|
||
sha256: string,
|
||
sizeBytes: number,
|
||
): MediaAsset {
|
||
const createdAt = new Date().toISOString();
|
||
const base = { id, mime: kind.mime, originalName, relPath, sha256, sizeBytes, createdAt };
|
||
if (kind.type === 'image') return { ...base, type: 'image' };
|
||
if (kind.type === 'video') return { ...base, type: 'video' };
|
||
return { ...base, type: 'audio' };
|
||
}
|
||
|
||
async function isZipFile(filePath: string): Promise<boolean> {
|
||
try {
|
||
const st = await fs.stat(filePath);
|
||
if (!st.isFile()) return false;
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
return ext === '.zip' || ext === '.fvtt';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function prepareSourceDir(
|
||
sourcePath: string,
|
||
onProgress?: (p: FoundryImportProgress) => void,
|
||
): Promise<{ workDir: string; cleanup: () => Promise<void> }> {
|
||
const abs = path.resolve(sourcePath);
|
||
if (await isZipFile(abs)) {
|
||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ttrpg-foundry-'));
|
||
onProgress?.({ stage: 'unzip', percent: 5, detail: 'Распаковка архива Foundry…' });
|
||
await unzipToDir(abs, workDir, (done, total) => {
|
||
const pct = total > 0 ? 5 + Math.round((done / total) * 25) : 15;
|
||
onProgress?.({ stage: 'unzip', percent: Math.min(30, pct), detail: 'Распаковка архива Foundry…' });
|
||
});
|
||
return {
|
||
workDir,
|
||
cleanup: async () => {
|
||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
||
},
|
||
};
|
||
}
|
||
const st = await fs.stat(abs);
|
||
if (!st.isDirectory()) {
|
||
throw new Error('Выберите папку мира/модуля Foundry или архив (.zip / .fvtt).');
|
||
}
|
||
return {
|
||
workDir: abs,
|
||
cleanup: () => Promise.resolve(),
|
||
};
|
||
}
|
||
|
||
type AssetWriteCtx = {
|
||
cacheDir: string;
|
||
assets: Record<AssetId, MediaAsset>;
|
||
/** foundry path or abs path → assetId */
|
||
bySourceKey: Map<string, AssetId>;
|
||
};
|
||
|
||
async function importFileAsAsset(
|
||
ctx: AssetWriteCtx,
|
||
absPath: string,
|
||
opts?: { optimizeImage?: boolean; forceKind?: MediaKind },
|
||
): Promise<AssetId | null> {
|
||
const key = path.resolve(absPath).toLowerCase();
|
||
const existing = ctx.bySourceKey.get(key);
|
||
if (existing) return existing;
|
||
|
||
let kind = opts?.forceKind ?? classifyMediaPath(absPath);
|
||
if (!kind) return null;
|
||
|
||
let buf = await fs.readFile(absPath);
|
||
if (kind.type === 'image' && opts?.optimizeImage !== false) {
|
||
try {
|
||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||
if (!opt.passthrough && opt.buffer.length > 0) {
|
||
buf = Buffer.from(opt.buffer);
|
||
kind = { type: 'image', mime: opt.mime };
|
||
}
|
||
} catch {
|
||
// keep original
|
||
}
|
||
}
|
||
|
||
const id = asAssetId(randomId());
|
||
const orig = path.basename(absPath);
|
||
const safeOrig = sanitizeFileName(orig);
|
||
const relPath = `assets/${id}_${safeOrig}`;
|
||
const absOut = path.join(ctx.cacheDir, relPath);
|
||
await fs.mkdir(path.dirname(absOut), { recursive: true });
|
||
await fs.writeFile(absOut, buf);
|
||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||
ctx.assets[id] = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||
ctx.bySourceKey.set(key, id);
|
||
return id;
|
||
}
|
||
|
||
async function importPlaceholderAvatar(ctx: AssetWriteCtx, name: string): Promise<AssetId> {
|
||
const id = asAssetId(randomId());
|
||
const orig = `${sanitizeFileName(name)}_avatar.png`;
|
||
const relPath = `assets/${id}_${orig}`;
|
||
const absOut = path.join(ctx.cacheDir, relPath);
|
||
await fs.mkdir(path.dirname(absOut), { recursive: true });
|
||
await fs.writeFile(absOut, PLACEHOLDER_PNG);
|
||
const sha256 = crypto.createHash('sha256').update(PLACEHOLDER_PNG).digest('hex');
|
||
ctx.assets[id] = buildMediaAsset(
|
||
id,
|
||
{ type: 'image', mime: 'image/png' },
|
||
orig,
|
||
relPath,
|
||
sha256,
|
||
PLACEHOLDER_PNG.length,
|
||
);
|
||
return id;
|
||
}
|
||
|
||
async function resolveAndImport(
|
||
ctx: AssetWriteCtx,
|
||
manifest: FoundryPackageManifest,
|
||
foundryPath: string | null | undefined,
|
||
opts?: { optimizeImage?: boolean },
|
||
): Promise<AssetId | null> {
|
||
if (!foundryPath?.trim()) return null;
|
||
const abs = await resolveFoundryAssetPath(manifest, foundryPath);
|
||
if (!abs) return null;
|
||
return importFileAsAsset(ctx, abs, opts);
|
||
}
|
||
|
||
function uniqueNpcName(base: string, used: Set<string>): string {
|
||
const root = base.trim() || 'NPC';
|
||
if (!used.has(root.toLowerCase())) {
|
||
used.add(root.toLowerCase());
|
||
return root;
|
||
}
|
||
let i = 2;
|
||
while (used.has(`${root} (${String(i)})`.toLowerCase())) i += 1;
|
||
const name = `${root} (${String(i)})`;
|
||
used.add(name.toLowerCase());
|
||
return name;
|
||
}
|
||
|
||
function collectSceneAudioPaths(
|
||
scene: FoundrySceneDoc,
|
||
playlistsById: Map<string, FoundryPlaylistDoc>,
|
||
): string[] {
|
||
const paths: string[] = [];
|
||
const playlistId = typeof scene.playlist === 'string' ? scene.playlist : null;
|
||
if (!playlistId) return paths;
|
||
const playlist = playlistsById.get(playlistId);
|
||
if (!playlist?.sounds?.length) return paths;
|
||
|
||
const soundId = typeof scene.playlistSound === 'string' ? scene.playlistSound : null;
|
||
const sounds = soundId
|
||
? playlist.sounds.filter((s) => s._id === soundId)
|
||
: [...playlist.sounds].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||
|
||
for (const s of sounds) {
|
||
if (typeof s.path === 'string' && s.path.trim()) paths.push(s.path.trim());
|
||
}
|
||
return paths;
|
||
}
|
||
|
||
export type FoundryBuiltProject = {
|
||
project: Project;
|
||
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
|
||
previewSources: { sceneId: SceneId; absPath: string }[];
|
||
};
|
||
|
||
export async function buildProjectFromFoundryDocuments(
|
||
manifest: FoundryPackageManifest,
|
||
docs: FoundryLoadedDocuments,
|
||
cacheDir: string,
|
||
onProgress?: (p: FoundryImportProgress) => void,
|
||
options?: { projectId?: ReturnType<typeof asProjectId> },
|
||
): Promise<FoundryBuiltProject> {
|
||
const projectId = options?.projectId ?? asProjectId(randomId());
|
||
const now = new Date().toISOString();
|
||
const appVer = getAppSemanticVersion();
|
||
const name = manifest.title.trim() || manifest.id;
|
||
const fileBaseName = `${sanitizeFileName(name)}_${projectId}`;
|
||
|
||
const ctx: AssetWriteCtx = { cacheDir, assets: {}, bySourceKey: new Map() };
|
||
const journalsById = new Map(docs.journals.map((j) => [j._id, j]));
|
||
const playlistsById = new Map(docs.playlists.map((p) => [p._id, p]));
|
||
|
||
const importScenes = filterScenesForImport(docs.scenes);
|
||
const graphPlan = planFoundrySceneGraph(importScenes, docs.adventures, docs.journals);
|
||
const sceneOrderIds =
|
||
graphPlan.orderedSceneIds.length > 0 ? graphPlan.orderedSceneIds : importScenes.map((s) => s._id);
|
||
|
||
const scenesByFoundryId = new Map(importScenes.map((s) => [s._id, s]));
|
||
const foundryToSceneId = new Map<string, SceneId>();
|
||
const scenes: Record<SceneId, Scene> = {};
|
||
const sceneListOrder: SceneId[] = [];
|
||
const previewSources: { sceneId: SceneId; absPath: string }[] = [];
|
||
|
||
const totalSteps = Math.max(1, sceneOrderIds.length + docs.actors.length);
|
||
let step = 0;
|
||
|
||
for (const foundrySceneId of sceneOrderIds) {
|
||
const doc = scenesByFoundryId.get(foundrySceneId);
|
||
if (!doc) continue;
|
||
step += 1;
|
||
onProgress?.({
|
||
stage: 'copy',
|
||
percent: 30 + Math.round((step / totalSteps) * 50),
|
||
detail: `Сцена: ${doc.name}`,
|
||
});
|
||
|
||
const sceneId = asSceneId(`s_${randomId()}`);
|
||
foundryToSceneId.set(foundrySceneId, sceneId);
|
||
|
||
let previewAssetId: AssetId | null = null;
|
||
let previewAssetType: 'image' | 'video' | null = null;
|
||
let previewThumbAssetId: AssetId | null = null;
|
||
|
||
const bg = sceneBackgroundSrc(doc);
|
||
if (bg) {
|
||
const abs = await resolveFoundryAssetPath(manifest, bg);
|
||
if (abs) {
|
||
const kind = classifyMediaPath(abs);
|
||
if (kind?.type === 'image' || kind?.type === 'video') {
|
||
previewAssetId = await importFileAsAsset(ctx, abs, { optimizeImage: kind.type === 'image' });
|
||
previewAssetType = kind.type;
|
||
previewSources.push({ sceneId, absPath: abs });
|
||
if (previewAssetId && kind.type === 'image') {
|
||
try {
|
||
const thumbBytes = await generateScenePreviewThumbnailBytes(abs, 'image');
|
||
if (thumbBytes && thumbBytes.length > 0) {
|
||
const thumbId = asAssetId(randomId());
|
||
const thumbRel = `assets/${thumbId}_preview_thumb.webp`;
|
||
await fs.writeFile(path.join(cacheDir, thumbRel), thumbBytes);
|
||
ctx.assets[thumbId] = buildMediaAsset(
|
||
thumbId,
|
||
{ type: 'image', mime: 'image/webp' },
|
||
`${sanitizeFileName(doc.name)}_preview_thumb.webp`,
|
||
thumbRel,
|
||
crypto.createHash('sha256').update(thumbBytes).digest('hex'),
|
||
thumbBytes.length,
|
||
);
|
||
previewThumbAssetId = thumbId;
|
||
}
|
||
} catch {
|
||
// optional
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const audioRefs: Scene['media']['audios'] = [];
|
||
for (const audioPath of collectSceneAudioPaths(doc, playlistsById)) {
|
||
const assetId = await resolveAndImport(ctx, manifest, audioPath);
|
||
if (!assetId) continue;
|
||
if (ctx.assets[assetId]?.type !== 'audio') continue;
|
||
audioRefs.push({ assetId, autoplay: true, loop: true });
|
||
}
|
||
|
||
scenes[sceneId] = {
|
||
id: sceneId,
|
||
title: doc.name.trim() || 'Scene',
|
||
description: journalDescriptionHtml(doc, journalsById),
|
||
previewAssetId,
|
||
previewAssetType,
|
||
previewThumbAssetId,
|
||
previewVideoAutostart: previewAssetType === 'video',
|
||
previewRotationDeg: 0,
|
||
darkenScene: false,
|
||
media: { videos: [], audios: audioRefs },
|
||
settings: {
|
||
autoplayVideo: previewAssetType === 'video',
|
||
autoplayAudio: true,
|
||
loopVideo: true,
|
||
loopAudio: true,
|
||
},
|
||
connections: [],
|
||
layout: { x: 0, y: 0 },
|
||
};
|
||
sceneListOrder.push(sceneId);
|
||
}
|
||
|
||
const usedPlaylistIds = new Set(
|
||
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
|
||
);
|
||
const campaignAudios: Project['campaignAudios'] = [];
|
||
for (const pl of docs.playlists) {
|
||
if (usedPlaylistIds.has(pl._id)) continue;
|
||
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||
for (const s of sounds) {
|
||
if (typeof s.path !== 'string' || !s.path.trim()) continue;
|
||
const assetId = await resolveAndImport(ctx, manifest, s.path);
|
||
if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue;
|
||
campaignAudios.push({ assetId, autoplay: false, loop: true });
|
||
}
|
||
}
|
||
|
||
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
|
||
|
||
const npcs: ProjectNpc[] = [];
|
||
const usedNpcNames = new Set<string>();
|
||
let npcIndex = 0;
|
||
for (const actor of docs.actors) {
|
||
// Группы актёров Foundry (party/group) — не персонажи.
|
||
if (actor.type === 'group') continue;
|
||
step += 1;
|
||
onProgress?.({
|
||
stage: 'copy',
|
||
percent: 30 + Math.round((step / totalSteps) * 50),
|
||
detail: `НПС: ${actor.name}`,
|
||
});
|
||
const portrait = actorPortraitSrc(actor);
|
||
let avatarAssetId = portrait ? await resolveAndImport(ctx, manifest, portrait) : null;
|
||
if (!avatarAssetId || ctx.assets[avatarAssetId]?.type !== 'image') {
|
||
avatarAssetId = await importPlaceholderAvatar(ctx, actor.name);
|
||
}
|
||
const npcName = uniqueNpcName(actor.name, usedNpcNames);
|
||
const folderId = typeof actor.folder === 'string' ? actor.folder : null;
|
||
const groupId = folderId ? (foundryFolderToGroupId.get(folderId) ?? null) : null;
|
||
npcs.push({
|
||
id: asNpcId(`npc_${randomId()}`),
|
||
name: npcName,
|
||
avatarAssetId,
|
||
description: extractActorDescriptionHtml(actor),
|
||
x: 80 + (npcIndex % 4) * 220,
|
||
y: 80 + Math.floor(npcIndex / 4) * 200,
|
||
groupId,
|
||
binding: noneBinding(),
|
||
});
|
||
npcIndex += 1;
|
||
}
|
||
|
||
const sceneGraphNodes: SceneGraphNode[] = [];
|
||
const sceneGraphEdges: SceneGraphEdge[] = [];
|
||
const foundryToGraphNode = new Map<string, GraphNodeId>();
|
||
|
||
const startFoundryId = graphPlan.startSceneId;
|
||
const startSceneId =
|
||
(startFoundryId ? foundryToSceneId.get(startFoundryId) : null) ?? sceneListOrder[0] ?? null;
|
||
|
||
sceneListOrder.forEach((sceneId, index) => {
|
||
const foundryId = [...foundryToSceneId.entries()].find(([, sid]) => sid === sceneId)?.[0];
|
||
const col = index % 4;
|
||
const row = Math.floor(index / 4);
|
||
const gnId = asGraphNodeId(`gn_${randomId()}`);
|
||
sceneGraphNodes.push({
|
||
id: gnId,
|
||
sceneId,
|
||
x: 80 + col * 280,
|
||
y: 80 + row * 200,
|
||
isStartScene: startSceneId !== null && sceneId === startSceneId,
|
||
isSideStoryStart: false,
|
||
sideStoryLineTitle: '',
|
||
});
|
||
if (foundryId) foundryToGraphNode.set(foundryId, gnId);
|
||
});
|
||
|
||
for (const edge of graphPlan.edges) {
|
||
const source = foundryToGraphNode.get(edge.sourceId);
|
||
const target = foundryToGraphNode.get(edge.targetId);
|
||
if (!source || !target || source === target) continue;
|
||
sceneGraphEdges.push({
|
||
id: `e_${randomId()}`,
|
||
sourceGraphNodeId: source,
|
||
targetGraphNodeId: target,
|
||
});
|
||
}
|
||
|
||
// connections из рёбер графа
|
||
const outgoing = new Map<SceneId, Set<SceneId>>();
|
||
const gnMap = new Map(sceneGraphNodes.map((n) => [n.id, n]));
|
||
for (const e of sceneGraphEdges) {
|
||
const a = gnMap.get(e.sourceGraphNodeId);
|
||
const b = gnMap.get(e.targetGraphNodeId);
|
||
if (!a || !b || a.sceneId === b.sceneId) continue;
|
||
let set = outgoing.get(a.sceneId);
|
||
if (!set) {
|
||
set = new Set();
|
||
outgoing.set(a.sceneId, set);
|
||
}
|
||
set.add(b.sceneId);
|
||
}
|
||
for (const [sid, set] of outgoing) {
|
||
const sc = scenes[sid];
|
||
if (sc) sc.connections = [...set];
|
||
}
|
||
|
||
const startGraphNodeId = sceneGraphNodes.find((n) => n.isStartScene)?.id ?? null;
|
||
|
||
const project: Project = {
|
||
id: projectId,
|
||
meta: {
|
||
name,
|
||
fileBaseName,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
createdWithAppVersion: appVer,
|
||
appVersion: appVer,
|
||
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||
},
|
||
scenes,
|
||
sceneListOrder,
|
||
assets: ctx.assets,
|
||
campaignAudios,
|
||
materials: [],
|
||
npcs,
|
||
npcGroups,
|
||
npcRelations: [],
|
||
currentSceneId: startSceneId,
|
||
currentGraphNodeId: startGraphNodeId,
|
||
sceneGraphNodes,
|
||
sceneGraphEdges,
|
||
};
|
||
|
||
return { project, previewSources };
|
||
}
|
||
|
||
/** Actor folders Foundry → дерево групп НПС. */
|
||
function buildNpcGroupsFromFoundryFolders(folders: FoundryFolderDoc[]): {
|
||
npcGroups: ProjectNpcGroup[];
|
||
foundryFolderToGroupId: Map<string, NpcGroupId>;
|
||
} {
|
||
const actorFolders = folders
|
||
.filter((f) => !f.type || f.type === 'Actor')
|
||
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.name.localeCompare(b.name));
|
||
|
||
const foundryFolderToGroupId = new Map<string, NpcGroupId>();
|
||
for (const f of actorFolders) {
|
||
foundryFolderToGroupId.set(f._id, asNpcGroupId(`ng_${randomId()}`));
|
||
}
|
||
|
||
const nameKeysByParent = new Map<string | null, Set<string>>();
|
||
const npcGroups: ProjectNpcGroup[] = [];
|
||
|
||
// Parents first
|
||
const remaining = [...actorFolders];
|
||
const placed = new Set<string>();
|
||
while (remaining.length > 0) {
|
||
let progress = false;
|
||
for (let i = 0; i < remaining.length; i += 1) {
|
||
const f = remaining[i]!;
|
||
const parentFoundry = typeof f.folder === 'string' && f.folder.trim() ? f.folder.trim() : null;
|
||
if (parentFoundry && !foundryFolderToGroupId.has(parentFoundry)) {
|
||
// orphan parent ref → root
|
||
} else if (parentFoundry && !placed.has(parentFoundry) && foundryFolderToGroupId.has(parentFoundry)) {
|
||
continue;
|
||
}
|
||
const parentId = parentFoundry ? (foundryFolderToGroupId.get(parentFoundry) ?? null) : null;
|
||
const parentKey = parentId;
|
||
let keys = nameKeysByParent.get(parentKey);
|
||
if (!keys) {
|
||
keys = new Set();
|
||
nameKeysByParent.set(parentKey, keys);
|
||
}
|
||
let name = f.name.trim() || 'Group';
|
||
const base = name.toLowerCase();
|
||
if (keys.has(base)) {
|
||
let n = 2;
|
||
while (keys.has(`${base} (${String(n)})`)) n += 1;
|
||
name = `${name} (${String(n)})`;
|
||
}
|
||
keys.add(name.toLowerCase());
|
||
const id = foundryFolderToGroupId.get(f._id)!;
|
||
npcGroups.push({
|
||
id,
|
||
name,
|
||
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
|
||
parentId,
|
||
});
|
||
placed.add(f._id);
|
||
remaining.splice(i, 1);
|
||
progress = true;
|
||
break;
|
||
}
|
||
if (!progress) {
|
||
// cycle / leftover → force as roots
|
||
for (const f of remaining) {
|
||
const id = foundryFolderToGroupId.get(f._id)!;
|
||
let name = f.name.trim() || 'Group';
|
||
const keys = nameKeysByParent.get(null) ?? new Set();
|
||
nameKeysByParent.set(null, keys);
|
||
if (keys.has(name.toLowerCase())) {
|
||
let n = 2;
|
||
while (keys.has(`${name.toLowerCase()} (${String(n)})`)) n += 1;
|
||
name = `${name} (${String(n)})`;
|
||
}
|
||
keys.add(name.toLowerCase());
|
||
npcGroups.push({
|
||
id,
|
||
name,
|
||
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
|
||
parentId: null,
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
return { npcGroups, foundryFolderToGroupId };
|
||
}
|
||
|
||
export async function loadFoundryDocumentsForImport(
|
||
sourcePath: string,
|
||
onProgress?: (p: FoundryImportProgress) => void,
|
||
): Promise<{
|
||
manifest: FoundryPackageManifest;
|
||
docs: FoundryLoadedDocuments;
|
||
cleanup: () => Promise<void>;
|
||
}> {
|
||
const prepared = await prepareSourceDir(sourcePath, onProgress);
|
||
try {
|
||
onProgress?.({ stage: 'copy', percent: 32, detail: 'Определение пакета Foundry…' });
|
||
const manifest = await detectFoundryPackage(prepared.workDir);
|
||
onProgress?.({
|
||
stage: 'copy',
|
||
percent: 36,
|
||
detail: manifest.kind === 'world' ? 'Чтение мира…' : 'Чтение модуля…',
|
||
});
|
||
const docs =
|
||
manifest.kind === 'world'
|
||
? await loadWorldDocuments(manifest.rootDir)
|
||
: await loadModuleDocuments(manifest.rootDir, manifest.packs);
|
||
|
||
if (docs.scenes.length === 0 && docs.actors.length === 0) {
|
||
throw new Error(
|
||
'В пакете Foundry не найдено сцен и актёров. Проверьте, что выбран мир/модуль с данными (не пустой шаблон).',
|
||
);
|
||
}
|
||
|
||
return { manifest, docs, cleanup: prepared.cleanup };
|
||
} catch (e) {
|
||
await prepared.cleanup();
|
||
throw e;
|
||
}
|
||
}
|