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
+242
View File
@@ -0,0 +1,242 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { ClassicLevel } from 'classic-level';
import type {
FoundryActorDoc,
FoundryAdventureDoc,
FoundryFolderDoc,
FoundryJournalDoc,
FoundryLoadedDocuments,
FoundryPlaylistDoc,
FoundrySceneDoc,
} from '../../shared/foundry/foundryTypes';
function isRecord(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
}
function asDocArray<T>(docs: unknown[]): T[] {
return docs.filter((d) => isRecord(d) && typeof d._id === 'string' && typeof d.name === 'string') as T[];
}
/** NeDB: по строке JSON на линию (игнор пустых / битых). */
export async function readNedbDocuments(filePath: string): Promise<Record<string, unknown>[]> {
const raw = await fs.readFile(filePath, 'utf8');
const out: Record<string, unknown>[] = [];
for (const line of raw.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed: unknown = JSON.parse(trimmed);
if (isRecord(parsed) && typeof parsed._id === 'string') out.push(parsed);
} catch {
// skip broken line
}
}
return out;
}
function isLevelDbDir(entries: { name: string; isFile(): boolean; isDirectory(): boolean }[]): boolean {
const names = new Set(entries.map((e) => e.name));
return (
names.has('CURRENT') ||
names.has('LOG') ||
[...names].some((n) => n.endsWith('.ldb') || n.startsWith('MANIFEST-'))
);
}
/** Читает primary documents из LevelDB-папки Foundry (ключи `!collection!id`). */
export async function readLevelDbDocuments(dirPath: string): Promise<Record<string, unknown>[]> {
const db = new ClassicLevel(dirPath, {
keyEncoding: 'utf8',
valueEncoding: 'json',
createIfMissing: false,
});
try {
const out: Record<string, unknown>[] = [];
for await (const [key, value] of db.iterator()) {
if (typeof key !== 'string') continue;
const parts = key.split('!');
// "", collection, id
if (parts.length < 3) continue;
const collection = parts[1] ?? '';
if (!collection || collection.includes('.')) continue; // embedded
if (!isRecord(value)) continue;
if (typeof value._id !== 'string') continue;
out.push(value);
}
return out;
} finally {
await db.close().catch(() => undefined);
}
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function readCollectionDocs(baseDir: string, collection: string): Promise<Record<string, unknown>[]> {
const candidates = [
path.join(baseDir, collection),
path.join(baseDir, `${collection}.db`),
path.join(baseDir, 'data', collection),
path.join(baseDir, 'data', `${collection}.db`),
];
for (const candidate of candidates) {
if (!(await pathExists(candidate))) continue;
const st = await fs.stat(candidate);
if (st.isFile() && candidate.endsWith('.db')) {
return readNedbDocuments(candidate);
}
if (st.isDirectory()) {
const entries = await fs.readdir(candidate, { withFileTypes: true });
if (isLevelDbDir(entries)) {
return readLevelDbDocuments(candidate);
}
}
}
return [];
}
export async function readPackDocuments(
packAbsPath: string,
documentType: string,
): Promise<Record<string, unknown>[]> {
if (!(await pathExists(packAbsPath))) return [];
const st = await fs.stat(packAbsPath);
if (st.isFile() && packAbsPath.endsWith('.db')) {
return readNedbDocuments(packAbsPath);
}
if (st.isDirectory()) {
const entries = await fs.readdir(packAbsPath, { withFileTypes: true });
if (isLevelDbDir(entries)) {
return readLevelDbDocuments(packAbsPath);
}
// Иногда path указывает на папку с .db внутри.
const nestedDb = path.join(packAbsPath, `${path.basename(packAbsPath)}.db`);
if (await pathExists(nestedDb)) return readNedbDocuments(nestedDb);
}
void documentType;
return [];
}
function mergeById<T extends { _id: string }>(lists: T[][]): T[] {
const map = new Map<string, T>();
for (const list of lists) {
for (const doc of list) {
if (!map.has(doc._id)) map.set(doc._id, doc);
}
}
return [...map.values()];
}
function flattenAdventureDocs(adventures: FoundryAdventureDoc[]): {
scenes: FoundrySceneDoc[];
actors: FoundryActorDoc[];
playlists: FoundryPlaylistDoc[];
journals: FoundryJournalDoc[];
folders: FoundryFolderDoc[];
} {
const scenes: FoundrySceneDoc[] = [];
const actors: FoundryActorDoc[] = [];
const playlists: FoundryPlaylistDoc[] = [];
const journals: FoundryJournalDoc[] = [];
const folders: FoundryFolderDoc[] = [];
for (const adv of adventures) {
if (Array.isArray(adv.scenes)) scenes.push(...asDocArray<FoundrySceneDoc>(adv.scenes));
if (Array.isArray(adv.actors)) actors.push(...asDocArray<FoundryActorDoc>(adv.actors));
if (Array.isArray(adv.playlists)) playlists.push(...asDocArray<FoundryPlaylistDoc>(adv.playlists));
if (Array.isArray(adv.journal)) journals.push(...asDocArray<FoundryJournalDoc>(adv.journal));
if (Array.isArray(adv.folders)) {
for (const f of adv.folders) {
if (f && typeof f === 'object' && typeof (f as FoundryFolderDoc)._id === 'string') {
folders.push(f as FoundryFolderDoc);
}
}
}
}
return { scenes, actors, playlists, journals, folders };
}
function asFolderArray(docs: unknown[]): FoundryFolderDoc[] {
return docs.filter(
(d): d is FoundryFolderDoc =>
Boolean(d) &&
typeof d === 'object' &&
typeof (d as FoundryFolderDoc)._id === 'string' &&
typeof (d as FoundryFolderDoc).name === 'string',
);
}
export async function loadWorldDocuments(rootDir: string): Promise<FoundryLoadedDocuments> {
const [scenesRaw, actorsRaw, playlistsRaw, journalsRaw, adventuresRaw, foldersRaw] =
await Promise.all([
readCollectionDocs(rootDir, 'scenes'),
readCollectionDocs(rootDir, 'actors'),
readCollectionDocs(rootDir, 'playlists'),
readCollectionDocs(rootDir, 'journal'),
readCollectionDocs(rootDir, 'adventures'),
readCollectionDocs(rootDir, 'folders'),
]);
const adventures = asDocArray<FoundryAdventureDoc>(adventuresRaw);
const embedded = flattenAdventureDocs(adventures);
return {
scenes: mergeById([asDocArray<FoundrySceneDoc>(scenesRaw), embedded.scenes]),
actors: mergeById([asDocArray<FoundryActorDoc>(actorsRaw), embedded.actors]),
playlists: mergeById([asDocArray<FoundryPlaylistDoc>(playlistsRaw), embedded.playlists]),
journals: mergeById([asDocArray<FoundryJournalDoc>(journalsRaw), embedded.journals]),
adventures,
folders: mergeById([asFolderArray(foldersRaw), embedded.folders]),
};
}
export async function loadModuleDocuments(
rootDir: string,
packs: { path: string; type: string }[],
): Promise<FoundryLoadedDocuments> {
const scenes: FoundrySceneDoc[][] = [];
const actors: FoundryActorDoc[][] = [];
const playlists: FoundryPlaylistDoc[][] = [];
const journals: FoundryJournalDoc[][] = [];
const adventures: FoundryAdventureDoc[][] = [];
for (const pack of packs) {
const abs = path.isAbsolute(pack.path) ? pack.path : path.join(rootDir, pack.path);
// path в module.json иногда с `./` и иногда ещё со старым `.db`
const variants = [abs, abs.endsWith('.db') ? abs : `${abs}.db`, abs.replace(/\.db$/u, '')];
let docs: Record<string, unknown>[] = [];
for (const v of variants) {
docs = await readPackDocuments(v, pack.type);
if (docs.length > 0) break;
}
const type = pack.type;
if (type === 'Scene') scenes.push(asDocArray<FoundrySceneDoc>(docs));
else if (type === 'Actor') actors.push(asDocArray<FoundryActorDoc>(docs));
else if (type === 'Playlist') playlists.push(asDocArray<FoundryPlaylistDoc>(docs));
else if (type === 'JournalEntry') journals.push(asDocArray<FoundryJournalDoc>(docs));
else if (type === 'Adventure') adventures.push(asDocArray<FoundryAdventureDoc>(docs));
}
const advMerged = mergeById(adventures);
const embedded = flattenAdventureDocs(advMerged);
return {
scenes: mergeById([...scenes, embedded.scenes]),
actors: mergeById([...actors, embedded.actors]),
playlists: mergeById([...playlists, embedded.playlists]),
journals: mergeById([...journals, embedded.journals]),
adventures: advMerged,
folders: mergeById([embedded.folders]),
};
}
+209
View File
@@ -0,0 +1,209 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { decodeFoundryAssetPath } from '../../shared/foundry/foundryPaths';
import type { FoundryPackageManifest, FoundryPackRef } from '../../shared/foundry/foundryTypes';
import { isSupportedFoundryPackage } from '../../shared/foundry/foundryVersion';
function isRecord(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
}
async function readJsonFile(filePath: string): Promise<unknown> {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw) as unknown;
}
function parsePacks(raw: unknown): FoundryPackRef[] {
if (!Array.isArray(raw)) return [];
const out: FoundryPackRef[] = [];
for (const item of raw) {
if (!isRecord(item)) continue;
const name = typeof item.name === 'string' ? item.name : '';
const label = typeof item.label === 'string' ? item.label : name;
const packPath = typeof item.path === 'string' ? item.path.replace(/^\.\//u, '') : '';
const type =
typeof item.type === 'string' ? item.type : typeof item.entity === 'string' ? item.entity : '';
if (!packPath || !type) continue;
out.push({ name: name || path.basename(packPath), label: label || name, path: packPath, type });
}
return out;
}
function titleFromManifest(data: Record<string, unknown>, fallbackId: string): string {
if (typeof data.title === 'string' && data.title.trim()) return data.title.trim();
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
return fallbackId;
}
function idFromManifest(data: Record<string, unknown>, dirName: string): string {
if (typeof data.id === 'string' && data.id.trim()) return data.id.trim();
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
return dirName;
}
async function tryParseManifest(dir: string): Promise<FoundryPackageManifest | null> {
const worldPath = path.join(dir, 'world.json');
const modulePath = path.join(dir, 'module.json');
try {
await fs.access(worldPath);
const data = await readJsonFile(worldPath);
if (!isRecord(data)) return null;
const id = idFromManifest(data, path.basename(dir));
const manifest: FoundryPackageManifest = {
kind: 'world',
id,
title: titleFromManifest(data, id),
rootDir: dir,
packs: [],
};
const compatibility = parseCompatibility(data.compatibility);
if (compatibility) manifest.compatibility = compatibility;
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
return manifest;
} catch {
// not a world
}
try {
await fs.access(modulePath);
const data = await readJsonFile(modulePath);
if (!isRecord(data)) return null;
const id = idFromManifest(data, path.basename(dir));
const manifest: FoundryPackageManifest = {
kind: 'module',
id,
title: titleFromManifest(data, id),
rootDir: dir,
packs: parsePacks(data.packs),
};
const compatibility = parseCompatibility(data.compatibility);
if (compatibility) manifest.compatibility = compatibility;
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
return manifest;
} catch {
return null;
}
}
function parseCompatibility(raw: unknown): FoundryPackageManifest['compatibility'] | null {
if (!isRecord(raw)) return null;
const out: NonNullable<FoundryPackageManifest['compatibility']> = {};
if (typeof raw.minimum === 'string') out.minimum = raw.minimum;
if (typeof raw.verified === 'string') out.verified = raw.verified;
if (typeof raw.maximum === 'string') out.maximum = raw.maximum;
return out;
}
/** Ищет world.json / module.json в папке и на 1–2 уровня глубже. */
export async function detectFoundryPackage(rootPath: string): Promise<FoundryPackageManifest> {
const abs = path.resolve(rootPath);
const direct = await tryParseManifest(abs);
if (direct) {
const ver = isSupportedFoundryPackage(direct);
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
return direct;
}
// Архив мог содержать один корневой каталог.
const entries = await fs.readdir(abs, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(abs, e.name));
for (const dir of dirs) {
const found = await tryParseManifest(dir);
if (found) {
const ver = isSupportedFoundryPackage(found);
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
return found;
}
}
// Data/worlds/<id> или Data/modules/<id>
for (const mid of ['worlds', 'modules']) {
const midDir = path.join(abs, mid);
try {
const st = await fs.stat(midDir);
if (!st.isDirectory()) continue;
const children = await fs.readdir(midDir, { withFileTypes: true });
for (const child of children.filter((c) => c.isDirectory())) {
const found = await tryParseManifest(path.join(midDir, child.name));
if (found) {
const ver = isSupportedFoundryPackage(found);
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
return found;
}
}
} catch {
// ignore
}
}
// Data как корень выбранной папки
const dataDir = path.join(abs, 'Data');
try {
const st = await fs.stat(dataDir);
if (st.isDirectory()) {
return await detectFoundryPackage(dataDir);
}
} catch {
// ignore
}
throw new Error(
'Не похоже на пакет Foundry VTT: не найдены world.json или module.json. Выберите папку мира/модуля или архив с ними.',
);
}
/**
* Резолвит путь ассета Foundry относительно корня пакета / Data.
* foundryPath — как в документах: `worlds/id/...`, `modules/id/...` или относительный.
* Поддерживает URL-encoding (`The%20Withered%20Grove%20(day).webp`).
*/
export async function resolveFoundryAssetPath(
manifest: FoundryPackageManifest,
foundryPath: string,
): Promise<string | null> {
const cleaned = decodeFoundryAssetPath(foundryPath);
if (!cleaned) return null;
if (/^https?:\/\//iu.test(cleaned)) return null;
const packagePrefix = manifest.kind === 'world' ? `worlds/${manifest.id}/` : `modules/${manifest.id}/`;
const rawNormalized = foundryPath.trim().replace(/\\/gu, '/');
const pathVariants = cleaned === rawNormalized ? [cleaned] : [cleaned, rawNormalized];
const candidates: string[] = [];
for (const variant of pathVariants) {
const v = variant.replace(/^\/+/u, '');
if (!v) continue;
if (v.toLowerCase().startsWith(packagePrefix.toLowerCase())) {
candidates.push(path.join(manifest.rootDir, v.slice(packagePrefix.length)));
}
candidates.push(path.join(manifest.rootDir, v));
const dataRootGuesses = [
path.resolve(manifest.rootDir, '..', '..'),
path.resolve(manifest.rootDir, '..'),
];
for (const dataRoot of dataRootGuesses) {
candidates.push(path.join(dataRoot, v));
}
const base = path.basename(v);
if (base && base !== v) {
candidates.push(path.join(manifest.rootDir, base));
candidates.push(path.join(manifest.rootDir, 'assets', base));
}
}
for (const c of candidates) {
try {
const st = await fs.stat(c);
if (st.isFile()) return c;
} catch {
// next
}
}
return null;
}
+654
View File
@@ -0,0 +1,654 @@
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;
}
}