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:
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user