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