Files
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

140 lines
4.5 KiB
TypeScript

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',
);
});