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:
@@ -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]),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+134
-55
@@ -625,42 +625,52 @@ async function main() {
|
||||
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||
return { canceled: false as const, filePath, previewDataUrl };
|
||||
});
|
||||
registerHandler(ipcChannels.project.upsertNpc, async ({ npcId, name, description, filePath: pathFromDrop }) => {
|
||||
let filePath = pathFromDrop;
|
||||
if (!filePath && !npcId) {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{
|
||||
name: openDialogFilterLabel('images', app.getLocale()),
|
||||
extensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (canceled || filePaths.length === 0) {
|
||||
throw new Error('NPC avatar is required');
|
||||
registerHandler(
|
||||
ipcChannels.project.upsertNpc,
|
||||
async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => {
|
||||
let filePath = pathFromDrop;
|
||||
if (!filePath && !npcId) {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{
|
||||
name: openDialogFilterLabel('images', app.getLocale()),
|
||||
extensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (canceled || filePaths.length === 0) {
|
||||
throw new Error('NPC avatar is required');
|
||||
}
|
||||
filePath = filePaths[0];
|
||||
}
|
||||
filePath = filePaths[0];
|
||||
}
|
||||
const project = await projectStore.upsertNpc({
|
||||
...(npcId ? { npcId } : {}),
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
});
|
||||
syncNpcsOverlayWithProject(project);
|
||||
emitNpcsOverlayState();
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.updateNpcFields, async ({ npcId, name, description }) => {
|
||||
const project = await projectStore.updateNpcFields(npcId, {
|
||||
...(typeof name === 'string' ? { name } : {}),
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
const project = await projectStore.upsertNpc({
|
||||
...(npcId ? { npcId } : {}),
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
});
|
||||
syncNpcsOverlayWithProject(project);
|
||||
emitNpcsOverlayState();
|
||||
emitSessionState();
|
||||
return { project };
|
||||
},
|
||||
);
|
||||
registerHandler(
|
||||
ipcChannels.project.updateNpcFields,
|
||||
async ({ npcId, name, description, groupId, binding }) => {
|
||||
const project = await projectStore.updateNpcFields(npcId, {
|
||||
...(typeof name === 'string' ? { name } : {}),
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
},
|
||||
);
|
||||
registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => {
|
||||
const project = await projectStore.updateNpcPosition(npcId, x, y);
|
||||
emitSessionState();
|
||||
@@ -715,6 +725,31 @@ async function main() {
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.project.upsertNpcGroup,
|
||||
async ({ groupId, name, color, parentId }) => {
|
||||
const project = await projectStore.upsertNpcGroup({
|
||||
...(groupId ? { groupId } : {}),
|
||||
name,
|
||||
...(typeof color === 'string' ? { color } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
},
|
||||
);
|
||||
registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => {
|
||||
const project = await projectStore.deleteNpcGroup(groupId);
|
||||
syncNpcsOverlayWithProject(project);
|
||||
emitNpcsOverlayState();
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.setNpcGroupsOrder, async ({ groupIds }) => {
|
||||
const project = await projectStore.setNpcGroupsOrder(groupIds);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
|
||||
let filePath = pathFromDrop;
|
||||
if (!filePath) {
|
||||
@@ -880,28 +915,32 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => {
|
||||
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
|
||||
});
|
||||
registerHandler(ipcChannels.project.mergeImportZip, async ({ filePath, storylineSelections, sceneResolutions }) => {
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
|
||||
const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
|
||||
filePath,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
(p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage: p.stage,
|
||||
percent: p.percent,
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
},
|
||||
);
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
return { project, report };
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.project.mergeImportZip,
|
||||
async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => {
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
|
||||
const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
|
||||
filePath,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
(p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage: p.stage,
|
||||
percent: p.percent,
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
},
|
||||
npcResolutions,
|
||||
);
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
return { project, report };
|
||||
},
|
||||
);
|
||||
registerHandler(
|
||||
ipcChannels.project.mergeImportFromProject,
|
||||
async ({ sourceProjectId, storylineSelections, sceneResolutions }) => {
|
||||
async ({ sourceProjectId, storylineSelections, sceneResolutions, npcResolutions }) => {
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
|
||||
const { project, report } = await projectStore.mergeStorylinesFromProjectId(
|
||||
sourceProjectId,
|
||||
@@ -915,6 +954,7 @@ async function main() {
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
},
|
||||
npcResolutions,
|
||||
);
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
@@ -935,6 +975,45 @@ async function main() {
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.pickFoundrySource, async ({ mode }) => {
|
||||
if (mode === 'folder') {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
title: 'Папка мира или модуля Foundry VTT',
|
||||
});
|
||||
if (canceled || !filePaths[0]) return { canceled: true as const };
|
||||
return { canceled: false as const, sourcePath: filePaths[0] };
|
||||
}
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
title: 'Архив мира или модуля Foundry VTT',
|
||||
filters: [
|
||||
{ name: 'Foundry package', extensions: ['zip', 'fvtt'] },
|
||||
{ name: 'All files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (canceled || !filePaths[0]) return { canceled: true as const };
|
||||
return { canceled: false as const, sourcePath: filePaths[0] };
|
||||
});
|
||||
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
|
||||
try {
|
||||
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage: p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
|
||||
percent: p.percent,
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
});
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
return { project };
|
||||
} catch (e) {
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Ошибка' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => {
|
||||
const list = await projectStore.listProjects();
|
||||
const entry = list.find((p) => p.id === projectId);
|
||||
|
||||
+299
-24
@@ -20,6 +20,7 @@ import {
|
||||
listImportableStorylines,
|
||||
mergeStorylinesIntoProject,
|
||||
newExportBundleProjectId,
|
||||
type NpcImportResolution,
|
||||
type SceneImportResolution,
|
||||
type StorylineImportMergeReport,
|
||||
type StorylineLabels,
|
||||
@@ -35,9 +36,11 @@ import {
|
||||
import type {
|
||||
MediaAsset,
|
||||
MediaAssetType,
|
||||
NpcBinding,
|
||||
Project,
|
||||
ProjectId,
|
||||
ProjectNpc,
|
||||
ProjectNpcGroup,
|
||||
ProjectNpcRelation,
|
||||
Scene,
|
||||
SceneGraphEdge,
|
||||
@@ -45,15 +48,34 @@ import type {
|
||||
SceneId,
|
||||
} from '../../shared/types';
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
asMaterialId,
|
||||
asNpcGroupId,
|
||||
asNpcId,
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
} from '../../shared/types/ids';
|
||||
import {
|
||||
clearNpcBindingsForDeletedScene,
|
||||
clearNpcBindingsForRemovedStoryline,
|
||||
noneBinding,
|
||||
normalizeNpcBinding,
|
||||
} from '../../shared/npcs/npcBinding';
|
||||
import {
|
||||
DEFAULT_NPC_GROUP_COLOR,
|
||||
normalizeHexColor,
|
||||
normalizeNpcGroups,
|
||||
resolveNpcGroupId,
|
||||
wouldCreateGroupCycle,
|
||||
} from '../../shared/npcs/npcGroups';
|
||||
import {
|
||||
buildProjectFromFoundryDocuments,
|
||||
loadFoundryDocumentsForImport,
|
||||
type FoundryImportProgress,
|
||||
} from '../foundry/foundryImport';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { reconcileAssetFiles } from './assetPrune';
|
||||
@@ -236,6 +258,7 @@ export class ZipProjectStore {
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcGroups: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
@@ -660,9 +683,25 @@ export class ZipProjectStore {
|
||||
currentSceneId = ids[0] ?? null;
|
||||
}
|
||||
|
||||
const removedSideStarts = p.sceneGraphNodes.filter(
|
||||
(n) => n.sceneId === sceneId && n.isSideStoryStart,
|
||||
);
|
||||
let npcs = clearNpcBindingsForDeletedScene(p.npcs ?? [], sceneId);
|
||||
for (const side of removedSideStarts) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
|
||||
kind: 'side',
|
||||
startGraphNodeId: side.id,
|
||||
});
|
||||
}
|
||||
const hadMainOnScene = p.sceneGraphNodes.some((n) => n.sceneId === sceneId && n.isStartScene);
|
||||
if (hadMainOnScene) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
|
||||
}
|
||||
|
||||
return {
|
||||
...withGraph,
|
||||
scenes: nextScenes,
|
||||
npcs,
|
||||
sceneListOrder: removeFromSceneListOrder(
|
||||
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
|
||||
sceneId,
|
||||
@@ -724,16 +763,34 @@ export class ZipProjectStore {
|
||||
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
|
||||
throw new Error('Graph node not found');
|
||||
}
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
const isMain = graphNodeId !== null && n.id === graphNodeId;
|
||||
if (isMain) {
|
||||
return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' };
|
||||
}
|
||||
return { ...n, isStartScene: false };
|
||||
}),
|
||||
}));
|
||||
const prevMain = open.project.sceneGraphNodes.find((n) => n.isStartScene);
|
||||
const clearingMain = graphNodeId === null || (prevMain && prevMain.id !== graphNodeId);
|
||||
await this.updateProject((p) => {
|
||||
let npcs = p.npcs ?? [];
|
||||
if (clearingMain && prevMain) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
|
||||
}
|
||||
const demotedSides = p.sceneGraphNodes.filter(
|
||||
(n) => n.isSideStoryStart && graphNodeId !== null && n.id === graphNodeId,
|
||||
);
|
||||
for (const side of demotedSides) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
|
||||
kind: 'side',
|
||||
startGraphNodeId: side.id,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
npcs,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
const isMain = graphNodeId !== null && n.id === graphNodeId;
|
||||
if (isMain) {
|
||||
return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' };
|
||||
}
|
||||
return { ...n, isStartScene: false };
|
||||
}),
|
||||
};
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -754,16 +811,29 @@ export class ZipProjectStore {
|
||||
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
|
||||
return open.project;
|
||||
}
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
if (n.id !== graphNodeId) return n;
|
||||
if (enabling) {
|
||||
return { ...n, isSideStoryStart: true, isStartScene: false };
|
||||
}
|
||||
return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
|
||||
}),
|
||||
}));
|
||||
await this.updateProject((p) => {
|
||||
let npcs = p.npcs ?? [];
|
||||
if (!enabling) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
|
||||
kind: 'side',
|
||||
startGraphNodeId: graphNodeId,
|
||||
});
|
||||
}
|
||||
if (enabling && node.isStartScene) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
npcs,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
if (n.id !== graphNodeId) return n;
|
||||
if (enabling) {
|
||||
return { ...n, isSideStoryStart: true, isStartScene: false };
|
||||
}
|
||||
return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
|
||||
}),
|
||||
};
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -817,7 +887,23 @@ export class ZipProjectStore {
|
||||
await this.updateProject((p) => {
|
||||
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
|
||||
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
|
||||
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
|
||||
const npcs = (p.npcs ?? []).map((n) => {
|
||||
if (
|
||||
n.binding?.kind === 'storyline' &&
|
||||
n.binding.storyline.kind === 'side' &&
|
||||
n.binding.storyline.startGraphNodeId === nodeId
|
||||
) {
|
||||
return {
|
||||
...n,
|
||||
binding: {
|
||||
kind: 'storyline' as const,
|
||||
storyline: { kind: 'side' as const, startGraphNodeId: newStartId },
|
||||
},
|
||||
};
|
||||
}
|
||||
return n;
|
||||
});
|
||||
return { ...withGraph, npcs, scenes: applyConnectionSets(withGraph.scenes, out) };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
@@ -1154,6 +1240,8 @@ export class ZipProjectStore {
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
}): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
@@ -1199,6 +1287,13 @@ export class ZipProjectStore {
|
||||
const assets = { ...p.assets };
|
||||
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
|
||||
|
||||
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
|
||||
const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => {
|
||||
if (raw === undefined) return prev;
|
||||
if (raw === null) return null;
|
||||
return groupIds.has(raw) ? raw : null;
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
const idx = npcs.findIndex((n) => n.id === editingId);
|
||||
if (idx < 0) throw new Error('NPC not found');
|
||||
@@ -1209,6 +1304,8 @@ export class ZipProjectStore {
|
||||
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
|
||||
description:
|
||||
typeof input.description === 'string' ? input.description : prev.description,
|
||||
groupId: resolveGroup(input.groupId, prev.groupId),
|
||||
binding: input.binding !== undefined ? input.binding : prev.binding,
|
||||
};
|
||||
} else {
|
||||
if (!nextAssetId) throw new Error('NPC avatar is required');
|
||||
@@ -1220,6 +1317,8 @@ export class ZipProjectStore {
|
||||
description: typeof input.description === 'string' ? input.description : '',
|
||||
x: 80 + (count % 4) * 220,
|
||||
y: 80 + Math.floor(count / 4) * 200,
|
||||
groupId: resolveGroup(input.groupId, null),
|
||||
binding: input.binding ?? noneBinding(),
|
||||
});
|
||||
}
|
||||
return { ...p, assets, npcs };
|
||||
@@ -1232,7 +1331,12 @@ export class ZipProjectStore {
|
||||
|
||||
async updateNpcFields(
|
||||
npcId: NpcId,
|
||||
patch: { name?: string; description?: string },
|
||||
patch: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
},
|
||||
): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
@@ -1250,12 +1354,20 @@ export class ZipProjectStore {
|
||||
}
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
|
||||
const npcs = (p.npcs ?? []).map((n) => {
|
||||
if (n.id !== npcId) return n;
|
||||
let groupId = n.groupId;
|
||||
if (patch.groupId !== undefined) {
|
||||
groupId =
|
||||
patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
|
||||
}
|
||||
return {
|
||||
...n,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||
groupId,
|
||||
...(patch.binding !== undefined ? { binding: patch.binding } : {}),
|
||||
};
|
||||
});
|
||||
return { ...p, npcs };
|
||||
@@ -1313,6 +1425,101 @@ export class ZipProjectStore {
|
||||
return latest;
|
||||
}
|
||||
|
||||
async upsertNpcGroup(input: {
|
||||
groupId?: NpcGroupId;
|
||||
name: string;
|
||||
color?: string;
|
||||
parentId?: NpcGroupId | null;
|
||||
}): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name = input.name.trim();
|
||||
if (name.length < 1) throw new Error('Group name is required');
|
||||
const color = normalizeHexColor(input.color, DEFAULT_NPC_GROUP_COLOR);
|
||||
await this.updateProject((p) => {
|
||||
const groups = [...(p.npcGroups ?? [])];
|
||||
const editingId = input.groupId ?? null;
|
||||
const parentId =
|
||||
input.parentId === undefined
|
||||
? editingId
|
||||
? (groups.find((g) => g.id === editingId)?.parentId ?? null)
|
||||
: null
|
||||
: input.parentId;
|
||||
if (parentId && !groups.some((g) => g.id === parentId)) {
|
||||
throw new Error('Parent group not found');
|
||||
}
|
||||
if (editingId && parentId && wouldCreateGroupCycle(groups, editingId, parentId)) {
|
||||
throw new Error('Invalid group parent');
|
||||
}
|
||||
const nameKey = name.toLowerCase();
|
||||
const siblingConflict = groups.some(
|
||||
(g) =>
|
||||
g.id !== editingId &&
|
||||
g.parentId === parentId &&
|
||||
g.name.trim().toLowerCase() === nameKey,
|
||||
);
|
||||
if (siblingConflict) throw new Error('Group name already exists');
|
||||
|
||||
if (editingId) {
|
||||
const idx = groups.findIndex((g) => g.id === editingId);
|
||||
if (idx < 0) throw new Error('Group not found');
|
||||
groups[idx] = { ...groups[idx]!, name, color, parentId };
|
||||
} else {
|
||||
groups.push({
|
||||
id: asNpcGroupId(`ng_${this.randomId()}`),
|
||||
name,
|
||||
color,
|
||||
parentId,
|
||||
});
|
||||
}
|
||||
return { ...p, npcGroups: groups };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async deleteNpcGroup(groupId: NpcGroupId): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => {
|
||||
const groups = p.npcGroups ?? [];
|
||||
if (!groups.some((g) => g.id === groupId)) throw new Error('Group not found');
|
||||
const parentOfDeleted = groups.find((g) => g.id === groupId)?.parentId ?? null;
|
||||
const nextGroups = groups
|
||||
.filter((g) => g.id !== groupId)
|
||||
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
|
||||
const npcs = (p.npcs ?? []).map((n) =>
|
||||
n.groupId === groupId ? { ...n, groupId: null } : n,
|
||||
);
|
||||
return { ...p, npcGroups: nextGroups, npcs };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async setNpcGroupsOrder(groupIds: NpcGroupId[]): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => {
|
||||
const byId = new Map((p.npcGroups ?? []).map((g) => [g.id, g]));
|
||||
const next: ProjectNpcGroup[] = [];
|
||||
for (const id of groupIds) {
|
||||
const g = byId.get(id);
|
||||
if (g) {
|
||||
next.push(g);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const g of byId.values()) next.push(g);
|
||||
return { ...p, npcGroups: next };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async upsertNpcRelation(input: {
|
||||
relationId?: NpcRelationId;
|
||||
sourceNpcId: NpcId;
|
||||
@@ -1583,6 +1790,50 @@ export class ZipProjectStore {
|
||||
return opened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт мира/модуля Foundry VTT (папка или .zip/.fvtt) в новый проект.
|
||||
* Создаёт `.ttrpg.zip`, открывает проект и возвращает его.
|
||||
*/
|
||||
async importProjectFromFoundry(
|
||||
sourcePath: string,
|
||||
onProgress?: (p: FoundryImportProgress) => void,
|
||||
): Promise<Project> {
|
||||
await this.ensureRoots();
|
||||
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
|
||||
try {
|
||||
this.projectSession += 1;
|
||||
const projectId = asProjectId(this.randomId());
|
||||
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
|
||||
await fs.rm(cacheDir, { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
|
||||
|
||||
const { project } = await buildProjectFromFoundryDocuments(
|
||||
loaded.manifest,
|
||||
loaded.docs,
|
||||
cacheDir,
|
||||
onProgress,
|
||||
{ projectId },
|
||||
);
|
||||
|
||||
const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(project.meta.fileBaseName));
|
||||
const projectPath = path.join(cacheDir, 'project.json');
|
||||
this.openProject = {
|
||||
id: project.id,
|
||||
zipPath,
|
||||
cacheDir,
|
||||
projectPath,
|
||||
project,
|
||||
};
|
||||
await this.writeCacheProject(cacheDir, project);
|
||||
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
|
||||
await this.enqueuePack(cacheDir, zipPath);
|
||||
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
|
||||
return this.openProject.project;
|
||||
} finally {
|
||||
await loaded.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
|
||||
async exportProjectZipToPath(
|
||||
projectId: ProjectId,
|
||||
@@ -1809,6 +2060,7 @@ export class ZipProjectStore {
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
if (!this.openProject) throw new Error('Нет открытого проекта');
|
||||
const offsetX = computeGraphImportOffsetX(this.openProject.project);
|
||||
@@ -1817,7 +2069,10 @@ export class ZipProjectStore {
|
||||
source,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
{ graphOffsetX: offsetX },
|
||||
{
|
||||
graphOffsetX: offsetX,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
const targetCache = this.openProject.cacheDir;
|
||||
@@ -1851,6 +2106,7 @@ export class ZipProjectStore {
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
this.assertStorylineMergeAllowed(selections);
|
||||
const snap = await this.loadProjectSnapshot(sourceProjectId);
|
||||
@@ -1861,6 +2117,7 @@ export class ZipProjectStore {
|
||||
selections,
|
||||
sceneResolutions,
|
||||
onProgress,
|
||||
npcResolutions,
|
||||
);
|
||||
} finally {
|
||||
if (snap.ownsCache) {
|
||||
@@ -1874,6 +2131,7 @@ export class ZipProjectStore {
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
this.assertStorylineMergeAllowed(selections);
|
||||
const source = await this.readExternalProjectForImport(sourcePath);
|
||||
@@ -1887,6 +2145,7 @@ export class ZipProjectStore {
|
||||
selections,
|
||||
sceneResolutions,
|
||||
onProgress,
|
||||
npcResolutions,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -2095,6 +2354,13 @@ function normalizeProject(p: Project): Project {
|
||||
(x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } =>
|
||||
Boolean(x),
|
||||
);
|
||||
const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
|
||||
const groupIdSet = new Set(npcGroups.map((g) => g.id));
|
||||
const sceneIdSet = new Set(Object.keys(scenes) as SceneId[]);
|
||||
const sideStartIds = new Set(
|
||||
sceneGraphNodes.filter((n) => n.isSideStoryStart).map((n) => n.id),
|
||||
);
|
||||
const hasMainStart = sceneGraphNodes.some((n) => n.isStartScene);
|
||||
const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
|
||||
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
|
||||
.map((n, index) => {
|
||||
@@ -2106,6 +2372,8 @@ function normalizeProject(p: Project): Project {
|
||||
description?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
groupId?: string | null;
|
||||
binding?: unknown;
|
||||
};
|
||||
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
@@ -2120,6 +2388,12 @@ function normalizeProject(p: Project): Project {
|
||||
description: typeof obj.description === 'string' ? obj.description : '',
|
||||
x,
|
||||
y,
|
||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||
binding: normalizeNpcBinding(obj.binding, {
|
||||
sceneIds: sceneIdSet,
|
||||
sideStartIds,
|
||||
hasMainStart,
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((x): x is ProjectNpc => Boolean(x));
|
||||
@@ -2171,6 +2445,7 @@ function normalizeProject(p: Project): Project {
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
npcGroups,
|
||||
npcRelations,
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
|
||||
Reference in New Issue
Block a user