Files
DndGamePlayer/app/main/foundry/foundryDetect.ts
T
Ivan Fontosh f4c0ac1438 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>
2026-07-20 13:19:22 +08:00

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;
}