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>
210 lines
7.4 KiB
TypeScript
210 lines
7.4 KiB
TypeScript
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;
|
|
}
|