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
+242
View File
@@ -0,0 +1,242 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { ClassicLevel } from 'classic-level';
import type {
FoundryActorDoc,
FoundryAdventureDoc,
FoundryFolderDoc,
FoundryJournalDoc,
FoundryLoadedDocuments,
FoundryPlaylistDoc,
FoundrySceneDoc,
} from '../../shared/foundry/foundryTypes';
function isRecord(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
}
function asDocArray<T>(docs: unknown[]): T[] {
return docs.filter((d) => isRecord(d) && typeof d._id === 'string' && typeof d.name === 'string') as T[];
}
/** NeDB: по строке JSON на линию (игнор пустых / битых). */
export async function readNedbDocuments(filePath: string): Promise<Record<string, unknown>[]> {
const raw = await fs.readFile(filePath, 'utf8');
const out: Record<string, unknown>[] = [];
for (const line of raw.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed: unknown = JSON.parse(trimmed);
if (isRecord(parsed) && typeof parsed._id === 'string') out.push(parsed);
} catch {
// skip broken line
}
}
return out;
}
function isLevelDbDir(entries: { name: string; isFile(): boolean; isDirectory(): boolean }[]): boolean {
const names = new Set(entries.map((e) => e.name));
return (
names.has('CURRENT') ||
names.has('LOG') ||
[...names].some((n) => n.endsWith('.ldb') || n.startsWith('MANIFEST-'))
);
}
/** Читает primary documents из LevelDB-папки Foundry (ключи `!collection!id`). */
export async function readLevelDbDocuments(dirPath: string): Promise<Record<string, unknown>[]> {
const db = new ClassicLevel(dirPath, {
keyEncoding: 'utf8',
valueEncoding: 'json',
createIfMissing: false,
});
try {
const out: Record<string, unknown>[] = [];
for await (const [key, value] of db.iterator()) {
if (typeof key !== 'string') continue;
const parts = key.split('!');
// "", collection, id
if (parts.length < 3) continue;
const collection = parts[1] ?? '';
if (!collection || collection.includes('.')) continue; // embedded
if (!isRecord(value)) continue;
if (typeof value._id !== 'string') continue;
out.push(value);
}
return out;
} finally {
await db.close().catch(() => undefined);
}
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function readCollectionDocs(baseDir: string, collection: string): Promise<Record<string, unknown>[]> {
const candidates = [
path.join(baseDir, collection),
path.join(baseDir, `${collection}.db`),
path.join(baseDir, 'data', collection),
path.join(baseDir, 'data', `${collection}.db`),
];
for (const candidate of candidates) {
if (!(await pathExists(candidate))) continue;
const st = await fs.stat(candidate);
if (st.isFile() && candidate.endsWith('.db')) {
return readNedbDocuments(candidate);
}
if (st.isDirectory()) {
const entries = await fs.readdir(candidate, { withFileTypes: true });
if (isLevelDbDir(entries)) {
return readLevelDbDocuments(candidate);
}
}
}
return [];
}
export async function readPackDocuments(
packAbsPath: string,
documentType: string,
): Promise<Record<string, unknown>[]> {
if (!(await pathExists(packAbsPath))) return [];
const st = await fs.stat(packAbsPath);
if (st.isFile() && packAbsPath.endsWith('.db')) {
return readNedbDocuments(packAbsPath);
}
if (st.isDirectory()) {
const entries = await fs.readdir(packAbsPath, { withFileTypes: true });
if (isLevelDbDir(entries)) {
return readLevelDbDocuments(packAbsPath);
}
// Иногда path указывает на папку с .db внутри.
const nestedDb = path.join(packAbsPath, `${path.basename(packAbsPath)}.db`);
if (await pathExists(nestedDb)) return readNedbDocuments(nestedDb);
}
void documentType;
return [];
}
function mergeById<T extends { _id: string }>(lists: T[][]): T[] {
const map = new Map<string, T>();
for (const list of lists) {
for (const doc of list) {
if (!map.has(doc._id)) map.set(doc._id, doc);
}
}
return [...map.values()];
}
function flattenAdventureDocs(adventures: FoundryAdventureDoc[]): {
scenes: FoundrySceneDoc[];
actors: FoundryActorDoc[];
playlists: FoundryPlaylistDoc[];
journals: FoundryJournalDoc[];
folders: FoundryFolderDoc[];
} {
const scenes: FoundrySceneDoc[] = [];
const actors: FoundryActorDoc[] = [];
const playlists: FoundryPlaylistDoc[] = [];
const journals: FoundryJournalDoc[] = [];
const folders: FoundryFolderDoc[] = [];
for (const adv of adventures) {
if (Array.isArray(adv.scenes)) scenes.push(...asDocArray<FoundrySceneDoc>(adv.scenes));
if (Array.isArray(adv.actors)) actors.push(...asDocArray<FoundryActorDoc>(adv.actors));
if (Array.isArray(adv.playlists)) playlists.push(...asDocArray<FoundryPlaylistDoc>(adv.playlists));
if (Array.isArray(adv.journal)) journals.push(...asDocArray<FoundryJournalDoc>(adv.journal));
if (Array.isArray(adv.folders)) {
for (const f of adv.folders) {
if (f && typeof f === 'object' && typeof (f as FoundryFolderDoc)._id === 'string') {
folders.push(f as FoundryFolderDoc);
}
}
}
}
return { scenes, actors, playlists, journals, folders };
}
function asFolderArray(docs: unknown[]): FoundryFolderDoc[] {
return docs.filter(
(d): d is FoundryFolderDoc =>
Boolean(d) &&
typeof d === 'object' &&
typeof (d as FoundryFolderDoc)._id === 'string' &&
typeof (d as FoundryFolderDoc).name === 'string',
);
}
export async function loadWorldDocuments(rootDir: string): Promise<FoundryLoadedDocuments> {
const [scenesRaw, actorsRaw, playlistsRaw, journalsRaw, adventuresRaw, foldersRaw] =
await Promise.all([
readCollectionDocs(rootDir, 'scenes'),
readCollectionDocs(rootDir, 'actors'),
readCollectionDocs(rootDir, 'playlists'),
readCollectionDocs(rootDir, 'journal'),
readCollectionDocs(rootDir, 'adventures'),
readCollectionDocs(rootDir, 'folders'),
]);
const adventures = asDocArray<FoundryAdventureDoc>(adventuresRaw);
const embedded = flattenAdventureDocs(adventures);
return {
scenes: mergeById([asDocArray<FoundrySceneDoc>(scenesRaw), embedded.scenes]),
actors: mergeById([asDocArray<FoundryActorDoc>(actorsRaw), embedded.actors]),
playlists: mergeById([asDocArray<FoundryPlaylistDoc>(playlistsRaw), embedded.playlists]),
journals: mergeById([asDocArray<FoundryJournalDoc>(journalsRaw), embedded.journals]),
adventures,
folders: mergeById([asFolderArray(foldersRaw), embedded.folders]),
};
}
export async function loadModuleDocuments(
rootDir: string,
packs: { path: string; type: string }[],
): Promise<FoundryLoadedDocuments> {
const scenes: FoundrySceneDoc[][] = [];
const actors: FoundryActorDoc[][] = [];
const playlists: FoundryPlaylistDoc[][] = [];
const journals: FoundryJournalDoc[][] = [];
const adventures: FoundryAdventureDoc[][] = [];
for (const pack of packs) {
const abs = path.isAbsolute(pack.path) ? pack.path : path.join(rootDir, pack.path);
// path в module.json иногда с `./` и иногда ещё со старым `.db`
const variants = [abs, abs.endsWith('.db') ? abs : `${abs}.db`, abs.replace(/\.db$/u, '')];
let docs: Record<string, unknown>[] = [];
for (const v of variants) {
docs = await readPackDocuments(v, pack.type);
if (docs.length > 0) break;
}
const type = pack.type;
if (type === 'Scene') scenes.push(asDocArray<FoundrySceneDoc>(docs));
else if (type === 'Actor') actors.push(asDocArray<FoundryActorDoc>(docs));
else if (type === 'Playlist') playlists.push(asDocArray<FoundryPlaylistDoc>(docs));
else if (type === 'JournalEntry') journals.push(asDocArray<FoundryJournalDoc>(docs));
else if (type === 'Adventure') adventures.push(asDocArray<FoundryAdventureDoc>(docs));
}
const advMerged = mergeById(adventures);
const embedded = flattenAdventureDocs(advMerged);
return {
scenes: mergeById([...scenes, embedded.scenes]),
actors: mergeById([...actors, embedded.actors]),
playlists: mergeById([...playlists, embedded.playlists]),
journals: mergeById([...journals, embedded.journals]),
adventures: advMerged,
folders: mergeById([embedded.folders]),
};
}