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,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
extractMonksTeleportEdges,
|
||||
filterScenesForImport,
|
||||
pickFoundryStartSceneId,
|
||||
planFoundrySceneGraph,
|
||||
sceneBackgroundSrc,
|
||||
} from './foundryGraph';
|
||||
import { decodeFoundryAssetPath } from './foundryPaths';
|
||||
import type { FoundrySceneDoc } from './foundryTypes';
|
||||
import { isSupportedFoundryPackage } from './foundryVersion';
|
||||
|
||||
function scene(id: string, name: string, extra: Partial<FoundrySceneDoc> = {}): FoundrySceneDoc {
|
||||
return { _id: id, name, navigation: true, ...extra };
|
||||
}
|
||||
|
||||
function teleportTile(targetId: string) {
|
||||
return {
|
||||
flags: {
|
||||
'monks-active-tiles': {
|
||||
active: true,
|
||||
actions: [{ action: 'scene', data: { sceneid: { id: `Scene.${targetId}`, name: 'x' } } }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void test('filterScenesForImport keeps only navigation when present', () => {
|
||||
const scenes = [
|
||||
scene('aaaaaaaaaaaaaaaa', 'Nav', { navigation: true }),
|
||||
scene('bbbbbbbbbbbbbbbb', 'Hidden', { navigation: false }),
|
||||
];
|
||||
assert.equal(filterScenesForImport(scenes).length, 1);
|
||||
assert.equal(filterScenesForImport(scenes)[0]?._id, 'aaaaaaaaaaaaaaaa');
|
||||
});
|
||||
|
||||
void test('pickFoundryStartSceneId: min sort, then video, not name', () => {
|
||||
const scenes = [
|
||||
scene('aaaaaaaaaaaaaaaa', 'Тент', { sort: 0, tiles: [{}] }),
|
||||
scene('bbbbbbbbbbbbbbbb', 'Стартовая', {
|
||||
sort: 0,
|
||||
background: { src: 'modules/x/Map/Swamp_start.mp4' },
|
||||
tiles: [{}],
|
||||
}),
|
||||
scene('cccccccccccccccc', 'Болота', { sort: 100000 }),
|
||||
];
|
||||
assert.equal(pickFoundryStartSceneId(scenes), 'bbbbbbbbbbbbbbbb');
|
||||
});
|
||||
|
||||
void test('planFoundrySceneGraph: monks teleports create branches, not a line', () => {
|
||||
const hub = 'aaaaaaaaaaaaaaaa';
|
||||
const a = 'bbbbbbbbbbbbbbbb';
|
||||
const b = 'cccccccccccccccc';
|
||||
const start = 'dddddddddddddddd';
|
||||
const scenes = [
|
||||
scene(hub, 'Hub', {
|
||||
sort: 100000,
|
||||
tiles: [teleportTile(a), teleportTile(b)],
|
||||
}),
|
||||
scene(a, 'A', { sort: 200000, tiles: [teleportTile(hub)] }),
|
||||
scene(b, 'B', { sort: 300000, tiles: [teleportTile(hub)] }),
|
||||
scene(start, 'Intro', {
|
||||
sort: 0,
|
||||
background: { src: 'intro.mp4' },
|
||||
tiles: [teleportTile(hub)],
|
||||
}),
|
||||
];
|
||||
const plan = planFoundrySceneGraph(scenes, [], []);
|
||||
assert.equal(plan.heuristic.kind, 'monks-teleports');
|
||||
assert.equal(plan.startSceneId, start);
|
||||
assert.ok(plan.edges.some((e) => e.sourceId === hub && e.targetId === a));
|
||||
assert.ok(plan.edges.some((e) => e.sourceId === hub && e.targetId === b));
|
||||
assert.ok(plan.edges.some((e) => e.sourceId === start && e.targetId === hub));
|
||||
// Не линейная цепочка из 3 рёбер подряд по всем сценам.
|
||||
assert.ok(plan.edges.length >= 3);
|
||||
});
|
||||
|
||||
void test('extractMonksTeleportEdges ignores inactive tiles', () => {
|
||||
const scenes = [
|
||||
scene('aaaaaaaaaaaaaaaa', 'A', {
|
||||
tiles: [
|
||||
{
|
||||
flags: {
|
||||
'monks-active-tiles': {
|
||||
active: false,
|
||||
actions: [{ action: 'scene', data: { sceneid: 'Scene.bbbbbbbbbbbbbbbb' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
scene('bbbbbbbbbbbbbbbb', 'B'),
|
||||
];
|
||||
assert.equal(extractMonksTeleportEdges(scenes).length, 0);
|
||||
});
|
||||
|
||||
void test('planFoundrySceneGraph: sort-order fallback when no teleports', () => {
|
||||
const scenes = [
|
||||
scene('aaaaaaaaaaaaaaaa', 'Z', { sort: 3 }),
|
||||
scene('bbbbbbbbbbbbbbbb', 'A', { sort: 1 }),
|
||||
scene('cccccccccccccccc', 'M', { sort: 2 }),
|
||||
];
|
||||
const plan = planFoundrySceneGraph(scenes, [], []);
|
||||
assert.equal(plan.heuristic.kind, 'navigation');
|
||||
assert.equal(plan.startSceneId, 'bbbbbbbbbbbbbbbb');
|
||||
assert.deepEqual(plan.orderedSceneIds[0], 'bbbbbbbbbbbbbbbb');
|
||||
});
|
||||
|
||||
void test('sceneBackgroundSrc prefers background.src', () => {
|
||||
assert.equal(
|
||||
sceneBackgroundSrc({
|
||||
_id: 'aaaaaaaaaaaaaaaa',
|
||||
name: 'S',
|
||||
img: 'old.png',
|
||||
background: { src: 'new.webp' },
|
||||
}),
|
||||
'new.webp',
|
||||
);
|
||||
});
|
||||
|
||||
void test('isSupportedFoundryPackage rejects pre-v11 maximum', () => {
|
||||
assert.equal(
|
||||
isSupportedFoundryPackage({ compatibility: { maximum: '10' }, coreVersion: '10.291' }).ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSupportedFoundryPackage({ compatibility: { minimum: '11', verified: '12' }, coreVersion: '12.331' }).ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
void test('decodeFoundryAssetPath decodes %20', () => {
|
||||
assert.equal(
|
||||
decodeFoundryAssetPath('modules/x/Unwelcome_Spirits/Map/The%20Withered%20Grove%20(day).webp'),
|
||||
'modules/x/Unwelcome_Spirits/Map/The Withered Grove (day).webp',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import type {
|
||||
FoundryActorDoc,
|
||||
FoundryAdventureDoc,
|
||||
FoundryJournalDoc,
|
||||
FoundrySceneDoc,
|
||||
FoundrySceneEdgePlan,
|
||||
FoundrySceneLinkHeuristic,
|
||||
FoundrySceneTile,
|
||||
} from './foundryTypes';
|
||||
|
||||
/** UUID / @UUID[...] / @Scene[...] ссылки Foundry. */
|
||||
const SCENE_REF_RE =
|
||||
/(?:@UUID\[(?:(?:Scene|Compendium\.[^.\]]+\.Scene)\.)?([A-Za-z0-9]{16})\]|@Scene\[([A-Za-z0-9]{16})\])/giu;
|
||||
|
||||
function uniquePush(ids: string[], id: string): void {
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
}
|
||||
|
||||
function dedupeEdges(
|
||||
edges: { sourceId: string; targetId: string }[],
|
||||
): { sourceId: string; targetId: string }[] {
|
||||
const seen = new Set<string>();
|
||||
const out: { sourceId: string; targetId: string }[] = [];
|
||||
for (const e of edges) {
|
||||
if (e.sourceId === e.targetId) continue;
|
||||
const key = `${e.sourceId}->${e.targetId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function chainEdges(orderedIds: string[]): { sourceId: string; targetId: string }[] {
|
||||
const edges: { sourceId: string; targetId: string }[] = [];
|
||||
for (let i = 0; i < orderedIds.length - 1; i += 1) {
|
||||
const sourceId = orderedIds[i];
|
||||
const targetId = orderedIds[i + 1];
|
||||
if (!sourceId || !targetId) continue;
|
||||
if (sourceId !== targetId) edges.push({ sourceId, targetId });
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function sceneSortValue(scene: FoundrySceneDoc): number {
|
||||
return typeof scene.sort === 'number' ? scene.sort : 0;
|
||||
}
|
||||
|
||||
function tileCount(scene: FoundrySceneDoc): number {
|
||||
return Array.isArray(scene.tiles) ? scene.tiles.length : 0;
|
||||
}
|
||||
|
||||
function hasVideoBackground(scene: FoundrySceneDoc): boolean {
|
||||
const src = sceneBackgroundSrc(scene);
|
||||
if (!src) return false;
|
||||
const lower = src.toLowerCase();
|
||||
return lower.endsWith('.mp4') || lower.endsWith('.webm') || lower.endsWith('.mov');
|
||||
}
|
||||
|
||||
/** Порядок: navigation → navOrder → sort → имя. */
|
||||
export function sortScenesByNavThenSort(scenes: FoundrySceneDoc[]): FoundrySceneDoc[] {
|
||||
return [...scenes].sort((a, b) => {
|
||||
const navA = a.navigation === true ? 0 : 1;
|
||||
const navB = b.navigation === true ? 0 : 1;
|
||||
if (navA !== navB) return navA - navB;
|
||||
const navOrderA = typeof a.navOrder === 'number' ? a.navOrder : Number.MAX_SAFE_INTEGER;
|
||||
const navOrderB = typeof b.navOrder === 'number' ? b.navOrder : Number.MAX_SAFE_INTEGER;
|
||||
if (navOrderA !== navOrderB) return navOrderA - navOrderB;
|
||||
const sortA = sceneSortValue(a);
|
||||
const sortB = sceneSortValue(b);
|
||||
if (sortA !== sortB) return sortA - sortB;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор стартовой сцены без опоры на слово «старт» в названии:
|
||||
* 1) среди navigation (если есть),
|
||||
* 2) минимальный sort,
|
||||
* 3) видео-фон (часто intro),
|
||||
* 4) меньше плиток,
|
||||
* 5) имя.
|
||||
*/
|
||||
export function pickFoundryStartSceneId(scenes: FoundrySceneDoc[]): string | null {
|
||||
if (scenes.length === 0) return null;
|
||||
const nav = scenes.filter((s) => s.navigation === true);
|
||||
const pool = nav.length > 0 ? nav : scenes;
|
||||
const minSort = Math.min(...pool.map(sceneSortValue));
|
||||
const tied = pool.filter((s) => sceneSortValue(s) === minSort);
|
||||
tied.sort((a, b) => {
|
||||
const videoA = hasVideoBackground(a) ? 0 : 1;
|
||||
const videoB = hasVideoBackground(b) ? 0 : 1;
|
||||
if (videoA !== videoB) return videoA - videoB;
|
||||
const tilesA = tileCount(a);
|
||||
const tilesB = tileCount(b);
|
||||
if (tilesA !== tilesB) return tilesA - tilesB;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
return tied[0]?._id ?? null;
|
||||
}
|
||||
|
||||
function parseSceneIdRef(raw: unknown): string | null {
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
const s = raw.trim();
|
||||
const m = /(?:^|\.)([A-Za-z0-9]{16})$/u.exec(s);
|
||||
return m?.[1] ?? (s.length === 16 ? s : null);
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
const id = (raw as { id?: unknown }).id;
|
||||
return parseSceneIdRef(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Рёбра телепортов из Monks Active Tiles (`action: "scene"`). */
|
||||
export function extractMonksTeleportEdges(
|
||||
scenes: FoundrySceneDoc[],
|
||||
): { sourceId: string; targetId: string }[] {
|
||||
const known = new Set(scenes.map((s) => s._id));
|
||||
const edges: { sourceId: string; targetId: string }[] = [];
|
||||
for (const scene of scenes) {
|
||||
const tiles: FoundrySceneTile[] = Array.isArray(scene.tiles) ? scene.tiles : [];
|
||||
for (const tile of tiles) {
|
||||
const mat = tile.flags?.['monks-active-tiles'];
|
||||
if (!mat || mat.active === false) continue;
|
||||
const actions = Array.isArray(mat.actions) ? mat.actions : [];
|
||||
for (const action of actions) {
|
||||
if (action.action !== 'scene') continue;
|
||||
const targetId = parseSceneIdRef(action.data?.sceneid);
|
||||
if (!targetId || !known.has(targetId)) continue;
|
||||
edges.push({ sourceId: scene._id, targetId });
|
||||
}
|
||||
}
|
||||
}
|
||||
return dedupeEdges(edges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Если в Adventure есть сцены с navigation — оставляем только их
|
||||
* (отсекает дубликаты карт с remote URL и скрытые GM-копии без навигации).
|
||||
*/
|
||||
export function filterScenesForImport(scenes: FoundrySceneDoc[]): FoundrySceneDoc[] {
|
||||
const nav = scenes.filter((s) => s.navigation === true);
|
||||
return nav.length > 0 ? nav : scenes;
|
||||
}
|
||||
|
||||
function extractSceneIdsFromText(text: string, knownIds: Set<string>): string[] {
|
||||
const out: string[] = [];
|
||||
SCENE_REF_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = SCENE_REF_RE.exec(text)) !== null) {
|
||||
const id = m[1] ?? m[2];
|
||||
if (id && knownIds.has(id)) uniquePush(out, id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function journalText(journal: FoundryJournalDoc): string {
|
||||
const parts: string[] = [];
|
||||
if (typeof journal.content === 'string') parts.push(journal.content);
|
||||
const pages = Array.isArray(journal.pages) ? journal.pages : [];
|
||||
for (const page of [...pages].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))) {
|
||||
if (typeof page.text?.content === 'string') parts.push(page.text.content);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function orderFromStart(startId: string | null, scenes: FoundrySceneDoc[]): string[] {
|
||||
const bySort = sortScenesByNavThenSort(scenes).map((s) => s._id);
|
||||
if (!startId || !bySort.includes(startId)) return bySort;
|
||||
return [startId, ...bySort.filter((id) => id !== startId)];
|
||||
}
|
||||
|
||||
function buildPlan(
|
||||
scenes: FoundrySceneDoc[],
|
||||
edges: { sourceId: string; targetId: string }[],
|
||||
heuristic: FoundrySceneLinkHeuristic,
|
||||
): FoundrySceneEdgePlan {
|
||||
const startSceneId = pickFoundryStartSceneId(scenes);
|
||||
return {
|
||||
edges: dedupeEdges(edges),
|
||||
heuristic,
|
||||
orderedSceneIds: orderFromStart(startSceneId, scenes),
|
||||
startSceneId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит рёбра графа:
|
||||
* 1) телепорты Monks Active Tiles (ветвления),
|
||||
* 2) ссылки в журналах,
|
||||
* 3) цепочка по sort среди navigation,
|
||||
* 4) fallback по sort всех сцен.
|
||||
*
|
||||
* Порядок Adventure.scenes в JSON не используем — там часто произвольный порядок.
|
||||
*/
|
||||
export function planFoundrySceneGraph(
|
||||
scenes: FoundrySceneDoc[],
|
||||
_adventures: FoundryAdventureDoc[],
|
||||
journals: FoundryJournalDoc[],
|
||||
): FoundrySceneEdgePlan {
|
||||
const working = filterScenesForImport(scenes);
|
||||
const byId = new Map(working.map((s) => [s._id, s]));
|
||||
const knownIds = new Set(byId.keys());
|
||||
|
||||
const teleports = extractMonksTeleportEdges(working).filter(
|
||||
(e) => knownIds.has(e.sourceId) && knownIds.has(e.targetId),
|
||||
);
|
||||
if (teleports.length > 0) {
|
||||
return buildPlan(working, teleports, { kind: 'monks-teleports' });
|
||||
}
|
||||
|
||||
const fromJournals: string[] = [];
|
||||
for (const j of journals) {
|
||||
for (const id of extractSceneIdsFromText(journalText(j), knownIds)) {
|
||||
uniquePush(fromJournals, id);
|
||||
}
|
||||
}
|
||||
if (fromJournals.length >= 2) {
|
||||
return buildPlan(working, chainEdges(fromJournals), { kind: 'journal-refs' });
|
||||
}
|
||||
|
||||
const nav = working.filter((s) => s.navigation === true);
|
||||
if (nav.length >= 2) {
|
||||
const ordered = sortScenesByNavThenSort(nav).map((s) => s._id);
|
||||
return buildPlan(working, chainEdges(ordered), { kind: 'navigation' });
|
||||
}
|
||||
|
||||
const ordered = sortScenesByNavThenSort(working).map((s) => s._id);
|
||||
return buildPlan(working, chainEdges(ordered), { kind: 'sort-order' });
|
||||
}
|
||||
|
||||
export function sceneBackgroundSrc(scene: FoundrySceneDoc): string | null {
|
||||
const bg = scene.background?.src;
|
||||
if (typeof bg === 'string' && bg.trim()) return bg.trim();
|
||||
if (typeof scene.img === 'string' && scene.img.trim()) return scene.img.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function actorPortraitSrc(actor: FoundryActorDoc): string | null {
|
||||
const token = actor.prototypeToken?.texture?.src;
|
||||
if (typeof token === 'string' && token.trim()) return token.trim();
|
||||
if (typeof actor.img === 'string' && actor.img.trim()) return actor.img.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractActorDescriptionHtml(actor: FoundryActorDoc): string {
|
||||
const system = actor.system;
|
||||
if (!system || typeof system !== 'object') return '';
|
||||
const sys = system as Record<string, unknown>;
|
||||
const candidates: unknown[] = [
|
||||
(sys.details as { biography?: { value?: unknown } } | undefined)?.biography?.value,
|
||||
(sys.description as { value?: unknown } | undefined)?.value,
|
||||
(sys.details as { biography?: unknown } | undefined)?.biography,
|
||||
sys.biography,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (typeof c === 'string' && c.trim()) return c.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function journalDescriptionHtml(
|
||||
scene: FoundrySceneDoc,
|
||||
journalsById: Map<string, FoundryJournalDoc>,
|
||||
): string {
|
||||
if (typeof scene.description === 'string' && scene.description.trim()) {
|
||||
return scene.description.trim();
|
||||
}
|
||||
const journalId = typeof scene.journal === 'string' ? scene.journal : null;
|
||||
if (!journalId) return '';
|
||||
const journal = journalsById.get(journalId);
|
||||
if (!journal) return '';
|
||||
const text = journalText(journal).trim();
|
||||
if (text) return text;
|
||||
return journal.name ? `<p>${escapeHtml(journal.name)}</p>` : '';
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Декодирует %20 и т.п. в путях Foundry; безопасен для уже декодированных строк. */
|
||||
export function decodeFoundryAssetPath(foundryPath: string): string {
|
||||
const trimmed = foundryPath.trim().replace(/\\/gu, '/').replace(/^\/+/u, '');
|
||||
if (!trimmed) return '';
|
||||
if (/^https?:\/\//iu.test(trimmed)) return trimmed;
|
||||
try {
|
||||
return decodeURIComponent(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/** Документы Foundry VTT, нужные для импорта (v11+). */
|
||||
|
||||
export type FoundryPackageKind = 'world' | 'module';
|
||||
|
||||
export type FoundryCompatibility = {
|
||||
minimum?: string;
|
||||
verified?: string;
|
||||
maximum?: string;
|
||||
};
|
||||
|
||||
export type FoundryPackageManifest = {
|
||||
kind: FoundryPackageKind;
|
||||
/** id пакета (папка / manifest id). */
|
||||
id: string;
|
||||
/** Человекочитаемое название. */
|
||||
title: string;
|
||||
rootDir: string;
|
||||
compatibility?: FoundryCompatibility;
|
||||
coreVersion?: string;
|
||||
/** Пути компендиумов относительно rootDir (только для module). */
|
||||
packs: FoundryPackRef[];
|
||||
};
|
||||
|
||||
export type FoundryPackRef = {
|
||||
name: string;
|
||||
label: string;
|
||||
path: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
/** Плитка сцены (нужна для телепортов Monks Active Tiles). */
|
||||
export type FoundrySceneTile = {
|
||||
flags?: {
|
||||
'monks-active-tiles'?: {
|
||||
active?: boolean;
|
||||
actions?: {
|
||||
action?: string;
|
||||
data?: {
|
||||
sceneid?: string | { id?: string; name?: string };
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type FoundrySceneDoc = {
|
||||
_id: string;
|
||||
name: string;
|
||||
img?: string | null;
|
||||
background?: { src?: string | null } | null;
|
||||
journal?: string | null;
|
||||
playlist?: string | null;
|
||||
playlistSound?: string | null;
|
||||
navigation?: boolean;
|
||||
navOrder?: number;
|
||||
sort?: number;
|
||||
description?: string | null;
|
||||
tiles?: FoundrySceneTile[];
|
||||
};
|
||||
|
||||
export type FoundryActorDoc = {
|
||||
_id: string;
|
||||
name: string;
|
||||
img?: string | null;
|
||||
prototypeToken?: { texture?: { src?: string | null } | null } | null;
|
||||
system?: unknown;
|
||||
folder?: string | null;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type FoundryFolderDoc = {
|
||||
_id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
folder?: string | null;
|
||||
sort?: number;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type FoundryPlaylistSound = {
|
||||
_id?: string;
|
||||
name?: string;
|
||||
path?: string | null;
|
||||
sort?: number;
|
||||
};
|
||||
|
||||
export type FoundryPlaylistDoc = {
|
||||
_id: string;
|
||||
name: string;
|
||||
sounds?: FoundryPlaylistSound[];
|
||||
sort?: number;
|
||||
};
|
||||
|
||||
export type FoundryJournalPage = {
|
||||
_id?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
text?: { content?: string; format?: number } | null;
|
||||
sort?: number;
|
||||
};
|
||||
|
||||
export type FoundryJournalDoc = {
|
||||
_id: string;
|
||||
name: string;
|
||||
pages?: FoundryJournalPage[];
|
||||
content?: string;
|
||||
};
|
||||
|
||||
/** Adventure содержит вложенные документы. */
|
||||
export type FoundryAdventureDoc = {
|
||||
_id: string;
|
||||
name: string;
|
||||
scenes?: FoundrySceneDoc[];
|
||||
actors?: FoundryActorDoc[];
|
||||
playlists?: FoundryPlaylistDoc[];
|
||||
journal?: FoundryJournalDoc[];
|
||||
folders?: FoundryFolderDoc[];
|
||||
sort?: number;
|
||||
};
|
||||
|
||||
export type FoundryLoadedDocuments = {
|
||||
scenes: FoundrySceneDoc[];
|
||||
actors: FoundryActorDoc[];
|
||||
playlists: FoundryPlaylistDoc[];
|
||||
journals: FoundryJournalDoc[];
|
||||
adventures: FoundryAdventureDoc[];
|
||||
folders: FoundryFolderDoc[];
|
||||
};
|
||||
|
||||
export type FoundrySceneLinkHeuristic =
|
||||
| { kind: 'monks-teleports' }
|
||||
| { kind: 'journal-refs' }
|
||||
| { kind: 'navigation' }
|
||||
| { kind: 'sort-order' };
|
||||
|
||||
export type FoundrySceneEdgePlan = {
|
||||
/** Пары sourceSceneFoundryId → targetSceneFoundryId (могут быть ветвления). */
|
||||
edges: { sourceId: string; targetId: string }[];
|
||||
heuristic: FoundrySceneLinkHeuristic;
|
||||
/** Порядок сцен для списка / раскладки. */
|
||||
orderedSceneIds: string[];
|
||||
/** Foundry id сцены, которую стоит пометить START. */
|
||||
startSceneId: string | null;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { FoundryCompatibility, FoundryPackageManifest } from './foundryTypes';
|
||||
|
||||
/** Актуальные major-версии Foundry, которые поддерживает импортёр. */
|
||||
export const FOUNDRY_SUPPORTED_MAJORS = new Set([11, 12, 13]);
|
||||
|
||||
function parseMajor(version: string | undefined): number | null {
|
||||
if (!version || typeof version !== 'string') return null;
|
||||
const m = /^(\d+)/u.exec(version.trim());
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* true — пакет выглядит как актуальный (v11+).
|
||||
* Если версия не указана — разрешаем (попробуем прочитать данные).
|
||||
* Если явно только старая (<11) — отклоняем.
|
||||
*/
|
||||
export function isSupportedFoundryPackage(
|
||||
manifest: Pick<FoundryPackageManifest, 'compatibility' | 'coreVersion'>,
|
||||
): {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
} {
|
||||
const compat: FoundryCompatibility = manifest.compatibility ?? {};
|
||||
const majors = [compat.minimum, compat.verified, compat.maximum, manifest.coreVersion]
|
||||
.map(parseMajor)
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
if (majors.length === 0) return { ok: true };
|
||||
|
||||
const maxMajor = Math.max(...majors);
|
||||
const minMajor = Math.min(...majors);
|
||||
|
||||
// Явно только до v10 и ниже.
|
||||
if (typeof compat.maximum === 'string') {
|
||||
const max = parseMajor(compat.maximum);
|
||||
if (max !== null && max < 11) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Пакет Foundry слишком старый (maximum ${compat.maximum}). Нужна версия 11+.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (maxMajor < 11) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Пакет Foundry слишком старый (обнаружена версия ${String(maxMajor)}). Нужна версия 11+.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Если минимум уже далеко в будущем — всё равно пробуем, если пересекается с 11–13.
|
||||
const overlapsSupported = majors.some((m) => FOUNDRY_SUPPORTED_MAJORS.has(m)) || minMajor <= 13;
|
||||
if (!overlapsSupported) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Версия Foundry не поддерживается импортом (нужны 11–13).`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -61,7 +61,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
|
||||
updatedAt: '2020-01-01T00:00:00.000Z',
|
||||
createdWithAppVersion: '1',
|
||||
appVersion: '1',
|
||||
schemaVersion: 8,
|
||||
schemaVersion: 9,
|
||||
},
|
||||
scenes: {},
|
||||
sceneListOrder: [],
|
||||
@@ -69,6 +69,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcGroups: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
import {
|
||||
getConnectedComponent,
|
||||
getSideStoryComponentNodeIds,
|
||||
listSideStoryStarts,
|
||||
} from './sceneGraphLineage';
|
||||
import { reconcileSceneListOrder } from './sceneListOrder';
|
||||
import { noneBinding } from '../npcs/npcBinding';
|
||||
import type {
|
||||
ExportedStorylineRef,
|
||||
GraphNodeId,
|
||||
NpcBinding,
|
||||
Project,
|
||||
ProjectNpc,
|
||||
ProjectNpcGroup,
|
||||
Scene,
|
||||
SceneGraphEdge,
|
||||
SceneGraphNode,
|
||||
SceneId,
|
||||
} from '../types';
|
||||
import type { AssetId, ProjectId } from '../types/ids';
|
||||
import type { AssetId, NpcGroupId, ProjectId } from '../types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
asMaterialId,
|
||||
asNpcGroupId,
|
||||
asNpcId,
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
asSceneId,
|
||||
} from '../types/ids';
|
||||
|
||||
import {
|
||||
getConnectedComponent,
|
||||
getSideStoryComponentNodeIds,
|
||||
listSideStoryStarts,
|
||||
} from './sceneGraphLineage';
|
||||
import { reconcileSceneListOrder } from './sceneListOrder';
|
||||
|
||||
export type StorylineKind = 'main' | 'side';
|
||||
|
||||
/** Выбор сюжетной линии для экспорта/импорта. */
|
||||
export type StorylineSelection =
|
||||
| { kind: 'main' }
|
||||
| { kind: 'side'; startGraphNodeId: GraphNodeId };
|
||||
export type StorylineSelection = { kind: 'main' } | { kind: 'side'; startGraphNodeId: GraphNodeId };
|
||||
|
||||
export type StorylineListItem = {
|
||||
selection: StorylineSelection;
|
||||
@@ -48,6 +52,16 @@ export type SceneTitleConflict = {
|
||||
matches: { sceneId: SceneId; title: string }[];
|
||||
};
|
||||
|
||||
export type NpcImportResolution =
|
||||
| { sourceNpcId: string; mode: 'create' }
|
||||
| { sourceNpcId: string; mode: 'use'; targetNpcId: string };
|
||||
|
||||
export type NpcNameConflict = {
|
||||
sourceNpcId: string;
|
||||
sourceName: string;
|
||||
matches: { npcId: string; name: string }[];
|
||||
};
|
||||
|
||||
export type StorylineImportMergeReport = {
|
||||
storylinesImported: number;
|
||||
scenesCreated: number;
|
||||
@@ -56,6 +70,8 @@ export type StorylineImportMergeReport = {
|
||||
edgesAdded: number;
|
||||
assetsCopied: number;
|
||||
assetsReused: number;
|
||||
npcsCreated: number;
|
||||
npcsReused: number;
|
||||
renamedSideTitles: string[];
|
||||
};
|
||||
|
||||
@@ -161,10 +177,7 @@ export function listImportableStorylines(
|
||||
});
|
||||
}
|
||||
|
||||
function recomputeOutgoing(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
): Map<SceneId, Set<SceneId>> {
|
||||
function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map<SceneId, Set<SceneId>> {
|
||||
const gnMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
const outgoing = new Map<SceneId, Set<SceneId>>();
|
||||
for (const e of edges) {
|
||||
@@ -181,7 +194,10 @@ function recomputeOutgoing(
|
||||
return outgoing;
|
||||
}
|
||||
|
||||
function applyConnectionSets(scenes: Record<SceneId, Scene>, outgoing: Map<SceneId, Set<SceneId>>): Record<SceneId, Scene> {
|
||||
function applyConnectionSets(
|
||||
scenes: Record<SceneId, Scene>,
|
||||
outgoing: Map<SceneId, Set<SceneId>>,
|
||||
): Record<SceneId, Scene> {
|
||||
const next: Record<SceneId, Scene> = { ...scenes };
|
||||
for (const sid of Object.keys(next) as SceneId[]) {
|
||||
const prev = next[sid];
|
||||
@@ -204,16 +220,11 @@ function selectionToExportedRef(
|
||||
return {
|
||||
kind: 'side',
|
||||
startGraphNodeId: selection.startGraphNodeId,
|
||||
label: start
|
||||
? sideStoryDisplayLabel(start, project.scenes, labels.untitled)
|
||||
: labels.untitled,
|
||||
label: start ? sideStoryDisplayLabel(start, project.scenes, labels.untitled) : labels.untitled,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectSceneIdsForSelections(
|
||||
project: Project,
|
||||
selections: StorylineSelection[],
|
||||
): SceneId[] {
|
||||
export function collectSceneIdsForSelections(project: Project, selections: StorylineSelection[]): SceneId[] {
|
||||
const nodeIds = collectSelectionsGraphNodeIds(project, selections);
|
||||
const sceneIds = new Set<SceneId>();
|
||||
for (const gn of project.sceneGraphNodes) {
|
||||
@@ -234,9 +245,7 @@ export function buildPartialExportProject(
|
||||
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
|
||||
const sceneIds = new Set(collectSceneIdsForSelections(source, selections));
|
||||
|
||||
const sceneGraphNodes = source.sceneGraphNodes
|
||||
.filter((n) => nodeIds.has(n.id))
|
||||
.map((n) => ({ ...n }));
|
||||
const sceneGraphNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id)).map((n) => ({ ...n }));
|
||||
|
||||
const sceneGraphEdges = source.sceneGraphEdges.filter(
|
||||
(e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
|
||||
@@ -268,6 +277,21 @@ export function buildPartialExportProject(
|
||||
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
|
||||
draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };
|
||||
|
||||
const exportedNpcs = filterNpcsForStorylineExport(source, selections);
|
||||
const exportedNpcIds = new Set(exportedNpcs.map((n) => n.id));
|
||||
const exportedRelations = (source.npcRelations ?? []).filter(
|
||||
(r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId),
|
||||
);
|
||||
const exportedGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], exportedNpcs);
|
||||
const exportedGroups = (source.npcGroups ?? []).filter((g) => exportedGroupIds.has(g.id));
|
||||
|
||||
draft = {
|
||||
...draft,
|
||||
npcs: exportedNpcs.map((n) => ({ ...n })),
|
||||
npcGroups: exportedGroups.map((g) => ({ ...g })),
|
||||
npcRelations: exportedRelations.map((r) => ({ ...r })),
|
||||
};
|
||||
|
||||
const assetIds = collectReferencedAssetIdsForProject(draft);
|
||||
const assets: Project['assets'] = {} as Project['assets'];
|
||||
for (const id of assetIds) {
|
||||
@@ -282,13 +306,88 @@ export function buildPartialExportProject(
|
||||
assets,
|
||||
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
|
||||
materials: (source.materials ?? []).map((m) => ({ ...m })),
|
||||
npcs: (source.npcs ?? []).map((n) => ({ ...n })),
|
||||
npcRelations: (source.npcRelations ?? []).map((r) => ({ ...r })),
|
||||
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
|
||||
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** НПС для экспорта выбранных линий (main + unbound; side только свои). */
|
||||
export function filterNpcsForStorylineExport(
|
||||
project: Project,
|
||||
selections: StorylineSelection[],
|
||||
): ProjectNpc[] {
|
||||
const npcs = project.npcs ?? [];
|
||||
if (selections.length === 0) return [];
|
||||
|
||||
const includeUnbound = selections.some((s) => s.kind === 'main');
|
||||
const hasMain = selections.some((s) => s.kind === 'main');
|
||||
const sideRefs = new Set(
|
||||
selections
|
||||
.filter((s): s is { kind: 'side'; startGraphNodeId: GraphNodeId } => s.kind === 'side')
|
||||
.map((s) => s.startGraphNodeId),
|
||||
);
|
||||
const sceneIdsInExport = new Set(collectSceneIdsForSelections(project, selections));
|
||||
|
||||
return npcs.filter((npc) => {
|
||||
const b: NpcBinding = npc.binding ?? noneBinding();
|
||||
if (b.kind === 'none') return includeUnbound;
|
||||
if (b.kind === 'storyline') {
|
||||
if (b.storyline.kind === 'main') return hasMain;
|
||||
return sideRefs.has(b.storyline.startGraphNodeId);
|
||||
}
|
||||
if (b.kind === 'scene') return sceneIdsInExport.has(b.sceneId);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function collectNpcGroupIdsForNpcs(groups: ProjectNpcGroup[], npcs: ProjectNpc[]): Set<NpcGroupId> {
|
||||
const needed = new Set<NpcGroupId>();
|
||||
for (const n of npcs) {
|
||||
if (n.groupId) needed.add(n.groupId);
|
||||
}
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const g of groups) {
|
||||
if (needed.has(g.id) && g.parentId && !needed.has(g.parentId)) {
|
||||
needed.add(g.parentId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return needed;
|
||||
}
|
||||
|
||||
export function findNpcNameConflicts(
|
||||
target: Project,
|
||||
source: Project,
|
||||
sourceNpcIds: string[],
|
||||
): NpcNameConflict[] {
|
||||
const targetByName = new Map<string, { npcId: string; name: string }[]>();
|
||||
for (const n of target.npcs ?? []) {
|
||||
const key = n.name.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const list = targetByName.get(key) ?? [];
|
||||
list.push({ npcId: n.id, name: n.name });
|
||||
targetByName.set(key, list);
|
||||
}
|
||||
const conflicts: NpcNameConflict[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of sourceNpcIds) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const src = (source.npcs ?? []).find((n) => n.id === id);
|
||||
if (!src) continue;
|
||||
const key = src.name.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const matches = targetByName.get(key);
|
||||
if (matches && matches.length > 0) {
|
||||
conflicts.push({ sourceNpcId: src.id, sourceName: src.name, matches });
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
|
||||
const refs = new Set<AssetId>();
|
||||
for (const sc of Object.values(p.scenes)) {
|
||||
@@ -368,9 +467,14 @@ export function mergeStorylinesIntoProject(
|
||||
source: Project,
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
opts: { graphOffsetX: number },
|
||||
): { project: Project; report: StorylineImportMergeReport; assetCopies: { fromId: AssetId; toId: AssetId }[] } {
|
||||
opts: { graphOffsetX: number; npcResolutions?: NpcImportResolution[] },
|
||||
): {
|
||||
project: Project;
|
||||
report: StorylineImportMergeReport;
|
||||
assetCopies: { fromId: AssetId; toId: AssetId }[];
|
||||
} {
|
||||
const resolutionBySource = new Map(sceneResolutions.map((r) => [r.sourceSceneId, r]));
|
||||
const npcResolutionBySource = new Map((opts.npcResolutions ?? []).map((r) => [r.sourceNpcId, r]));
|
||||
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
|
||||
const sourceSceneIds = collectSceneIdsForSelections(source, selections);
|
||||
|
||||
@@ -387,7 +491,13 @@ export function mergeStorylinesIntoProject(
|
||||
}
|
||||
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
|
||||
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
|
||||
for (const n of source.npcs ?? []) neededAssetIds.add(n.avatarAssetId);
|
||||
|
||||
const exportedNpcs = filterNpcsForStorylineExport(source, selections);
|
||||
for (const n of exportedNpcs) {
|
||||
const res = npcResolutionBySource.get(n.id);
|
||||
if (res?.mode === 'use') continue;
|
||||
neededAssetIds.add(n.avatarAssetId);
|
||||
}
|
||||
|
||||
const assetMap = new Map<AssetId, AssetId>();
|
||||
const targetSha = new Map<string, AssetId>();
|
||||
@@ -521,17 +631,57 @@ export function mergeStorylinesIntoProject(
|
||||
materialAssetIds.add(mapped);
|
||||
}
|
||||
|
||||
const npcs = [...(target.npcs ?? [])];
|
||||
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
|
||||
const npcAssetIds = new Set(npcs.map((n) => n.avatarAssetId));
|
||||
const npcIdMap = new Map<string, string>();
|
||||
for (const n of source.npcs ?? []) {
|
||||
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
|
||||
const existingByAsset = npcs.find((x) => x.avatarAssetId === mappedAvatar);
|
||||
if (existingByAsset || npcAssetIds.has(mappedAvatar)) {
|
||||
if (existingByAsset) npcIdMap.set(n.id, existingByAsset.id);
|
||||
// Группы только для создаваемых НПС выбранных линий.
|
||||
const npcsToCreate = exportedNpcs.filter((n) => npcResolutionBySource.get(n.id)?.mode !== 'use');
|
||||
const neededGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], npcsToCreate);
|
||||
const npcGroups = [...(target.npcGroups ?? [])];
|
||||
const groupIdMap = new Map<string, string>();
|
||||
const orderedSourceGroups = topologicalNpcGroups(source.npcGroups ?? []).filter((g) =>
|
||||
neededGroupIds.has(g.id),
|
||||
);
|
||||
for (const g of orderedSourceGroups) {
|
||||
const mappedParent = g.parentId
|
||||
? (asNpcGroupId(groupIdMap.get(g.parentId) ?? g.parentId) as NpcGroupId | null)
|
||||
: null;
|
||||
const parentKey = mappedParent;
|
||||
const siblings = npcGroups.filter((x) => x.parentId === parentKey);
|
||||
const nameKey = g.name.trim().toLowerCase();
|
||||
const existing = siblings.find((x) => x.name.trim().toLowerCase() === nameKey);
|
||||
if (existing) {
|
||||
groupIdMap.set(g.id, existing.id);
|
||||
continue;
|
||||
}
|
||||
let name = g.name.trim();
|
||||
const siblingKeys = new Set(siblings.map((x) => x.name.trim().toLowerCase()));
|
||||
if (siblingKeys.has(name.toLowerCase())) {
|
||||
let i = 2;
|
||||
while (siblingKeys.has(`${name.toLowerCase()} (${String(i)})`)) i += 1;
|
||||
name = `${name} (${String(i)})`;
|
||||
}
|
||||
const newId = asNpcGroupId(`ng_${generateId()}`);
|
||||
groupIdMap.set(g.id, newId);
|
||||
npcGroups.push({
|
||||
id: newId,
|
||||
name,
|
||||
color: g.color,
|
||||
parentId: mappedParent,
|
||||
});
|
||||
}
|
||||
|
||||
const npcs = [...(target.npcs ?? [])];
|
||||
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
|
||||
const npcIdMap = new Map<string, string>();
|
||||
let npcsCreated = 0;
|
||||
let npcsReused = 0;
|
||||
for (const n of exportedNpcs) {
|
||||
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
|
||||
const res = npcResolutionBySource.get(n.id);
|
||||
if (res?.mode === 'use') {
|
||||
npcIdMap.set(n.id, res.targetNpcId);
|
||||
npcsReused += 1;
|
||||
continue;
|
||||
}
|
||||
// default create (also when no resolution entry)
|
||||
let name = n.name.trim();
|
||||
const baseKey = name.toLowerCase();
|
||||
if (npcNameKeys.has(baseKey)) {
|
||||
@@ -541,6 +691,10 @@ export function mergeStorylinesIntoProject(
|
||||
}
|
||||
const newId = asNpcId(`npc_${generateId()}`);
|
||||
npcIdMap.set(n.id, newId);
|
||||
const mappedGroupId = n.groupId
|
||||
? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId)
|
||||
: null;
|
||||
const binding = remapNpcBinding(n.binding ?? noneBinding(), sceneIdMap, graphNodeIdMap);
|
||||
npcs.push({
|
||||
id: newId,
|
||||
name,
|
||||
@@ -548,21 +702,25 @@ export function mergeStorylinesIntoProject(
|
||||
description: n.description ?? '',
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
|
||||
binding,
|
||||
});
|
||||
npcNameKeys.add(name.toLowerCase());
|
||||
npcAssetIds.add(mappedAvatar);
|
||||
npcsCreated += 1;
|
||||
}
|
||||
|
||||
const npcRelations = [...(target.npcRelations ?? [])];
|
||||
const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id));
|
||||
for (const r of source.npcRelations ?? []) {
|
||||
const sourceId = npcIdMap.get(r.sourceNpcId) ?? r.sourceNpcId;
|
||||
const targetId = npcIdMap.get(r.targetNpcId) ?? r.targetNpcId;
|
||||
if (!exportedNpcIdSet.has(r.sourceNpcId) || !exportedNpcIdSet.has(r.targetNpcId)) continue;
|
||||
const sourceId = npcIdMap.get(r.sourceNpcId);
|
||||
const targetId = npcIdMap.get(r.targetNpcId);
|
||||
if (!sourceId || !targetId) continue;
|
||||
if (!npcs.some((n) => n.id === sourceId) || !npcs.some((n) => n.id === targetId)) continue;
|
||||
const label = r.label.trim();
|
||||
if (!label) continue;
|
||||
const dup = npcRelations.some(
|
||||
(x) =>
|
||||
x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
|
||||
(x) => x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
|
||||
);
|
||||
if (dup) continue;
|
||||
npcRelations.push({
|
||||
@@ -580,6 +738,7 @@ export function mergeStorylinesIntoProject(
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
npcGroups,
|
||||
npcRelations,
|
||||
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
|
||||
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
|
||||
@@ -602,6 +761,8 @@ export function mergeStorylinesIntoProject(
|
||||
edgesAdded: newEdges.length,
|
||||
assetsCopied,
|
||||
assetsReused,
|
||||
npcsCreated,
|
||||
npcsReused,
|
||||
renamedSideTitles,
|
||||
},
|
||||
assetCopies,
|
||||
@@ -613,6 +774,41 @@ export function computeGraphImportOffsetX(target: Project, padding = 120): numbe
|
||||
return maxX + padding;
|
||||
}
|
||||
|
||||
function topologicalNpcGroups(groups: ProjectNpcGroup[]): ProjectNpcGroup[] {
|
||||
const byId = new Map(groups.map((g) => [g.id, g]));
|
||||
const out: ProjectNpcGroup[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (id: string): void => {
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
const g = byId.get(asNpcGroupId(id));
|
||||
if (!g) return;
|
||||
if (g.parentId && byId.has(g.parentId)) visit(g.parentId);
|
||||
out.push(g);
|
||||
};
|
||||
for (const g of groups) visit(g.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
function remapNpcBinding(
|
||||
binding: NpcBinding,
|
||||
sceneIdMap: Map<SceneId, SceneId>,
|
||||
graphNodeIdMap: Map<GraphNodeId, GraphNodeId>,
|
||||
): NpcBinding {
|
||||
if (binding.kind === 'none') return noneBinding();
|
||||
if (binding.kind === 'scene') {
|
||||
const mapped = sceneIdMap.get(binding.sceneId);
|
||||
return mapped ? { kind: 'scene', sceneId: mapped } : noneBinding();
|
||||
}
|
||||
if (binding.storyline.kind === 'main') {
|
||||
return { kind: 'storyline', storyline: { kind: 'main' } };
|
||||
}
|
||||
const mappedGn = graphNodeIdMap.get(binding.storyline.startGraphNodeId);
|
||||
return mappedGn
|
||||
? { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: mappedGn } }
|
||||
: noneBinding();
|
||||
}
|
||||
|
||||
export function newExportBundleProjectId(): ProjectId {
|
||||
return asProjectId(`p_${generateId()}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
MediaAsset,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
NpcGroupId,
|
||||
NpcBinding,
|
||||
NpcsOverlayEvent,
|
||||
NpcsOverlayState,
|
||||
Project,
|
||||
@@ -22,6 +24,7 @@ import type {
|
||||
VideoPlaybackState,
|
||||
} from '../types';
|
||||
import type {
|
||||
NpcImportResolution,
|
||||
SceneImportResolution,
|
||||
StorylineImportMergeReport,
|
||||
StorylineLabels,
|
||||
@@ -66,6 +69,9 @@ export const ipcChannels = {
|
||||
pickNpcAvatar: 'project.pickNpcAvatar',
|
||||
upsertNpcRelation: 'project.upsertNpcRelation',
|
||||
deleteNpcRelation: 'project.deleteNpcRelation',
|
||||
upsertNpcGroup: 'project.upsertNpcGroup',
|
||||
deleteNpcGroup: 'project.deleteNpcGroup',
|
||||
setNpcGroupsOrder: 'project.setNpcGroupsOrder',
|
||||
importScenePreview: 'project.importScenePreview',
|
||||
clearScenePreview: 'project.clearScenePreview',
|
||||
assetFileUrl: 'project.assetFileUrl',
|
||||
@@ -90,6 +96,8 @@ export const ipcChannels = {
|
||||
mergeImportZip: 'project.mergeImportZip',
|
||||
mergeImportFromProject: 'project.mergeImportFromProject',
|
||||
importZipFromPath: 'project.importZipFromPath',
|
||||
importFoundry: 'project.importFoundry',
|
||||
pickFoundrySource: 'project.pickFoundrySource',
|
||||
deleteProject: 'project.deleteProject',
|
||||
importZipProgress: 'project.importZipProgress',
|
||||
exportZipProgress: 'project.exportZipProgress',
|
||||
@@ -297,11 +305,24 @@ export type IpcInvokeMap = {
|
||||
| { canceled: false; filePath: string; previewDataUrl: string };
|
||||
};
|
||||
[ipcChannels.project.upsertNpc]: {
|
||||
req: { npcId?: NpcId; name: string; description?: string; filePath?: string };
|
||||
req: {
|
||||
npcId?: NpcId;
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
};
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.updateNpcFields]: {
|
||||
req: { npcId: NpcId; name?: string; description?: string };
|
||||
req: {
|
||||
npcId: NpcId;
|
||||
name?: string;
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
};
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.updateNpcPosition]: {
|
||||
@@ -335,6 +356,23 @@ export type IpcInvokeMap = {
|
||||
req: { relationId: NpcRelationId };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.upsertNpcGroup]: {
|
||||
req: {
|
||||
groupId?: NpcGroupId;
|
||||
name: string;
|
||||
color?: string;
|
||||
parentId?: NpcGroupId | null;
|
||||
};
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.deleteNpcGroup]: {
|
||||
req: { groupId: NpcGroupId };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.setNpcGroupsOrder]: {
|
||||
req: { groupIds: NpcGroupId[] };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.importScenePreview]: {
|
||||
req: { sceneId: SceneId; filePath?: string };
|
||||
res: { project: Project; assetId: AssetId | null; background: boolean };
|
||||
@@ -438,6 +476,7 @@ export type IpcInvokeMap = {
|
||||
filePath: string;
|
||||
storylineSelections: StorylineSelection[];
|
||||
sceneResolutions: SceneImportResolution[];
|
||||
npcResolutions?: NpcImportResolution[];
|
||||
};
|
||||
res: { project: Project; report: StorylineImportMergeReport };
|
||||
};
|
||||
@@ -446,6 +485,7 @@ export type IpcInvokeMap = {
|
||||
sourceProjectId: ProjectId;
|
||||
storylineSelections: StorylineSelection[];
|
||||
sceneResolutions: SceneImportResolution[];
|
||||
npcResolutions?: NpcImportResolution[];
|
||||
};
|
||||
res: { project: Project; report: StorylineImportMergeReport };
|
||||
};
|
||||
@@ -453,6 +493,14 @@ export type IpcInvokeMap = {
|
||||
req: { filePath: string };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.pickFoundrySource]: {
|
||||
req: { mode: 'folder' | 'archive' };
|
||||
res: { canceled: true } | { canceled: false; sourcePath: string };
|
||||
};
|
||||
[ipcChannels.project.importFoundry]: {
|
||||
req: { sourcePath: string };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.exportZip]: {
|
||||
req: { projectId: ProjectId; storylineSelections: StorylineSelection[]; labels: StorylineLabels };
|
||||
res: { canceled: true } | { canceled: false };
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { listSideStoryStarts } from '../graph/sceneGraphLineage';
|
||||
import type { GraphNodeId, NpcBinding, NpcStorylineRef, Project, ProjectNpc, SceneId } from '../types';
|
||||
|
||||
export function noneBinding(): NpcBinding {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
export function isNpcBindingNone(b: NpcBinding | undefined | null): boolean {
|
||||
return !b || b.kind === 'none';
|
||||
}
|
||||
|
||||
export function storylineRefKey(ref: NpcStorylineRef): string {
|
||||
return ref.kind === 'main' ? 'main' : `side:${ref.startGraphNodeId}`;
|
||||
}
|
||||
|
||||
export function normalizeNpcBinding(
|
||||
raw: unknown,
|
||||
ctx: { sceneIds: Set<SceneId>; sideStartIds: Set<GraphNodeId>; hasMainStart: boolean },
|
||||
): NpcBinding {
|
||||
if (!raw || typeof raw !== 'object') return noneBinding();
|
||||
const obj = raw as {
|
||||
kind?: string;
|
||||
sceneId?: string;
|
||||
storyline?: { kind?: string; startGraphNodeId?: string };
|
||||
};
|
||||
if (obj.kind === 'scene' && typeof obj.sceneId === 'string') {
|
||||
const sceneId = obj.sceneId as SceneId;
|
||||
if (ctx.sceneIds.has(sceneId)) return { kind: 'scene', sceneId };
|
||||
return noneBinding();
|
||||
}
|
||||
if (obj.kind === 'storyline' && obj.storyline && typeof obj.storyline === 'object') {
|
||||
if (obj.storyline.kind === 'main') {
|
||||
return ctx.hasMainStart ? { kind: 'storyline', storyline: { kind: 'main' } } : noneBinding();
|
||||
}
|
||||
if (obj.storyline.kind === 'side' && typeof obj.storyline.startGraphNodeId === 'string') {
|
||||
const id = obj.storyline.startGraphNodeId as GraphNodeId;
|
||||
if (ctx.sideStartIds.has(id)) {
|
||||
return { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: id } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return noneBinding();
|
||||
}
|
||||
|
||||
export function clearNpcBindingsForDeletedScene(npcs: ProjectNpc[], sceneId: SceneId): ProjectNpc[] {
|
||||
return npcs.map((n) => {
|
||||
if (n.binding?.kind === 'scene' && n.binding.sceneId === sceneId) {
|
||||
return { ...n, binding: noneBinding() };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearNpcBindingsForRemovedStoryline(
|
||||
npcs: ProjectNpc[],
|
||||
removed: NpcStorylineRef,
|
||||
): ProjectNpc[] {
|
||||
return npcs.map((n) => {
|
||||
if (n.binding?.kind !== 'storyline') return n;
|
||||
const ref = n.binding.storyline;
|
||||
if (removed.kind === 'main' && ref.kind === 'main') return { ...n, binding: noneBinding() };
|
||||
if (removed.kind === 'side' && ref.kind === 'side' && ref.startGraphNodeId === removed.startGraphNodeId) {
|
||||
return { ...n, binding: noneBinding() };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
export function listStorylineOptionsForBinding(project: Project): {
|
||||
main: boolean;
|
||||
sides: { startGraphNodeId: GraphNodeId; label: string }[];
|
||||
} {
|
||||
const hasMain = project.sceneGraphNodes.some((n) => n.isStartScene);
|
||||
const sides = listSideStoryStarts(project.sceneGraphNodes).map((n) => {
|
||||
const scene = project.scenes[n.sceneId];
|
||||
const label = n.sideStoryLineTitle.trim() || scene?.title.trim() || String(n.id);
|
||||
return { startGraphNodeId: n.id, label };
|
||||
});
|
||||
return { main: hasMain, sides };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { NpcGroupId, ProjectNpc, ProjectNpcGroup } from '../types';
|
||||
import { asNpcGroupId } from '../types/ids';
|
||||
|
||||
export const DEFAULT_NPC_GROUP_COLOR = '#6b7280';
|
||||
|
||||
export function normalizeHexColor(raw: unknown, fallback = DEFAULT_NPC_GROUP_COLOR): string {
|
||||
if (typeof raw !== 'string') return fallback;
|
||||
const s = raw.trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/u.test(s)) return s.toLowerCase();
|
||||
if (/^#[0-9a-fA-F]{3}$/u.test(s)) {
|
||||
const r = s[1]!;
|
||||
const g = s[2]!;
|
||||
const b = s[3]!;
|
||||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function uniqueNpcGroupName(base: string, existing: string[], exceptId?: NpcGroupId | null): string {
|
||||
const root = base.trim() || 'Group';
|
||||
const used = new Set(
|
||||
existing
|
||||
.map((n, i) => ({ n: n.trim().toLowerCase(), i }))
|
||||
.filter(() => true)
|
||||
.map((x) => x.n),
|
||||
);
|
||||
// existing is just names; caller filters except
|
||||
void exceptId;
|
||||
if (!used.has(root.toLowerCase())) return root;
|
||||
let i = 2;
|
||||
while (used.has(`${root.toLowerCase()} (${String(i)})`)) i += 1;
|
||||
return `${root} (${String(i)})`;
|
||||
}
|
||||
|
||||
export function normalizeNpcGroups(raw: unknown): ProjectNpcGroup[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const parsed: ProjectNpcGroup[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const obj = item as { id?: string; name?: string; color?: string; parentId?: string | null };
|
||||
if (!obj.id || typeof obj.name !== 'string') continue;
|
||||
const name = obj.name.trim();
|
||||
if (!name) continue;
|
||||
const id = String(obj.id);
|
||||
if (ids.has(id)) continue;
|
||||
ids.add(id);
|
||||
parsed.push({
|
||||
id: asNpcGroupId(id),
|
||||
name,
|
||||
color: normalizeHexColor(obj.color),
|
||||
parentId:
|
||||
typeof obj.parentId === 'string' && obj.parentId.trim() ? asNpcGroupId(obj.parentId.trim()) : null,
|
||||
});
|
||||
}
|
||||
// Drop parent links to missing groups (flatten to root).
|
||||
const idSet = new Set(parsed.map((g) => g.id));
|
||||
return parsed.map((g) => ({
|
||||
...g,
|
||||
parentId: g.parentId && idSet.has(g.parentId) && g.parentId !== g.id ? g.parentId : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Разрешить groupId НПС; неизвестный → null («Без группы»). */
|
||||
export function resolveNpcGroupId(raw: unknown, groupIds: Set<NpcGroupId>): NpcGroupId | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
const id = asNpcGroupId(raw.trim());
|
||||
return groupIds.has(id) ? id : null;
|
||||
}
|
||||
|
||||
export type NpcGroupTreeNode = {
|
||||
group: ProjectNpcGroup;
|
||||
children: NpcGroupTreeNode[];
|
||||
npcs: ProjectNpc[];
|
||||
};
|
||||
|
||||
/** Дерево групп + НПС без группы отдельно. */
|
||||
export function buildNpcGroupForest(
|
||||
groups: ProjectNpcGroup[],
|
||||
npcs: ProjectNpc[],
|
||||
): { roots: NpcGroupTreeNode[]; ungrouped: ProjectNpc[] } {
|
||||
const byParent = new Map<string | null, ProjectNpcGroup[]>();
|
||||
for (const g of groups) {
|
||||
const key = g.parentId;
|
||||
const list = byParent.get(key) ?? [];
|
||||
list.push(g);
|
||||
byParent.set(key, list);
|
||||
}
|
||||
// Preserve array order among siblings (already ordered in groups array).
|
||||
const build = (parentId: NpcGroupId | null): NpcGroupTreeNode[] => {
|
||||
const siblings = groups.filter((g) => g.parentId === parentId);
|
||||
return siblings.map((group) => ({
|
||||
group,
|
||||
children: build(group.id),
|
||||
npcs: npcs.filter((n) => n.groupId === group.id),
|
||||
}));
|
||||
};
|
||||
return {
|
||||
roots: build(null),
|
||||
ungrouped: npcs.filter((n) => n.groupId === null),
|
||||
};
|
||||
}
|
||||
|
||||
export function collectDescendantGroupIds(groups: ProjectNpcGroup[], rootId: NpcGroupId): Set<NpcGroupId> {
|
||||
const out = new Set<NpcGroupId>([rootId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const g of groups) {
|
||||
if (g.parentId && out.has(g.parentId) && !out.has(g.id)) {
|
||||
out.add(g.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Нельзя сделать parentId потомком (цикл). */
|
||||
export function wouldCreateGroupCycle(
|
||||
groups: ProjectNpcGroup[],
|
||||
groupId: NpcGroupId,
|
||||
newParentId: NpcGroupId | null,
|
||||
): boolean {
|
||||
if (!newParentId) return false;
|
||||
if (newParentId === groupId) return true;
|
||||
return collectDescendantGroupIds(groups, groupId).has(newParentId);
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcId, NpcRelationId, ProjectId, SceneId } from './ids';
|
||||
import type {
|
||||
AssetId,
|
||||
GraphNodeId,
|
||||
MaterialId,
|
||||
NpcGroupId,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
ProjectId,
|
||||
SceneId,
|
||||
} from './ids';
|
||||
|
||||
export const PROJECT_SCHEMA_VERSION = 8 as const;
|
||||
export const PROJECT_SCHEMA_VERSION = 9 as const;
|
||||
|
||||
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */
|
||||
export type ProjectMaterial = {
|
||||
@@ -10,6 +19,26 @@ export type ProjectMaterial = {
|
||||
rotationDeg: 0 | 90 | 180 | 270;
|
||||
};
|
||||
|
||||
/** Группа НПС (дерево через parentId). */
|
||||
export type ProjectNpcGroup = {
|
||||
id: NpcGroupId;
|
||||
name: string;
|
||||
/** Hex `#rrggbb`. */
|
||||
color: string;
|
||||
parentId: NpcGroupId | null;
|
||||
};
|
||||
|
||||
/** Привязка НПС к сюжетной линии. */
|
||||
export type NpcStorylineRef =
|
||||
| { kind: 'main' }
|
||||
| { kind: 'side'; startGraphNodeId: GraphNodeId };
|
||||
|
||||
/** Привязка НПС к линии или сцене; `none` — без привязки. */
|
||||
export type NpcBinding =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'storyline'; storyline: NpcStorylineRef }
|
||||
| { kind: 'scene'; sceneId: SceneId };
|
||||
|
||||
/** НПС кампании: персонаж с аватаром, описанием и связями на графе. */
|
||||
export type ProjectNpc = {
|
||||
id: NpcId;
|
||||
@@ -20,6 +49,9 @@ export type ProjectNpc = {
|
||||
/** Позиция карточки на графе взаимосвязей. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** `null` — системная секция «Без группы». */
|
||||
groupId: NpcGroupId | null;
|
||||
binding: NpcBinding;
|
||||
};
|
||||
|
||||
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */
|
||||
@@ -167,6 +199,8 @@ export type Project = {
|
||||
materials: ProjectMaterial[];
|
||||
/** НПС кампании (порядок = порядок в списке редактора/пульта). */
|
||||
npcs: ProjectNpc[];
|
||||
/** Группы НПС (порядок среди siblings = порядок в массиве). */
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
/** Связи между НПС (однонаправленные; между одной парой направлений может быть несколько). */
|
||||
npcRelations: ProjectNpcRelation[];
|
||||
currentSceneId: SceneId | null;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type GraphNodeId = Brand<string, 'GraphNodeId'>;
|
||||
export type MaterialId = Brand<string, 'MaterialId'>;
|
||||
export type NpcId = Brand<string, 'NpcId'>;
|
||||
export type NpcRelationId = Brand<string, 'NpcRelationId'>;
|
||||
export type NpcGroupId = Brand<string, 'NpcGroupId'>;
|
||||
|
||||
export function asProjectId(value: string): ProjectId {
|
||||
return value as ProjectId;
|
||||
@@ -35,3 +36,7 @@ export function asNpcId(value: string): NpcId {
|
||||
export function asNpcRelationId(value: string): NpcRelationId {
|
||||
return value as NpcRelationId;
|
||||
}
|
||||
|
||||
export function asNpcGroupId(value: string): NpcGroupId {
|
||||
return value as NpcGroupId;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user