Files
DndGamePlayer/app/shared/foundry/foundryGraph.ts
T
Ivan Fontosh f4c0ac1438 feat(npcs): add groups, storyline bindings, and Foundry import
Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 13:19:22 +08:00

282 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}