f4c0ac1438
Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts. Co-authored-by: Cursor <cursoragent@cursor.com>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
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('"', '"');
|
||
}
|