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:
Ivan Fontosh
2026-07-20 13:19:22 +08:00
parent 37ba855faf
commit f4c0ac1438
41 changed files with 5210 additions and 418 deletions
+139
View File
@@ -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',
);
});
+281
View File
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}
+11
View File
@@ -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;
}
}
+144
View File
@@ -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;
};
+63
View File
@@ -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 };
}