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(); 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[] { 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; 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 { 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 ? `

${escapeHtml(journal.name)}

` : ''; } function escapeHtml(s: string): string { return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); }