Files
hbm-foundry-system/scripts/build-packs.ts

300 lines
9.8 KiB
TypeScript

/**
* Build LevelDB compendium packs from parsed book content.
*
* Pipeline:
* 1. Load id overrides + label mappings.
* 2. Run all enabled parsers; collect ParsedDoc[].
* 3. Group by pack id; convert each ParsedDoc into a Foundry document JSON.
* 4. Pipe into `compilePack` (LevelDB) one pack at a time.
* 5. Emit a manifest summary to stdout.
*
* Run with: `bun scripts/build-packs.ts` from `.src/foundry-system/`.
*/
import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { compilePack } from '@foundryvtt/foundryvtt-cli';
import type { ParsedDoc, ParserContext } from './parsers/types';
import { parseSpells } from './parsers/spell-parser';
import { parseTalents } from './parsers/talent-parser';
import { parseRaces } from './parsers/race-parser';
import { parseDisciplines } from './parsers/discipline-parser';
import { parseMacros } from './parsers/macro-parser';
import { parseGear } from './parsers/gear-parser';
const __dirname = dirname(fileURLToPath(import.meta.url));
const systemRoot = resolve(__dirname, '..');
const repoRoot = resolve(systemRoot, '..', '..');
const packsSrcDir = resolve(systemRoot, 'packs-src');
const packsOutDir = resolve(systemRoot, 'packs');
function loadJson<T>(path: string): T {
return JSON.parse(readFileSync(path, 'utf8')) as T;
}
const ctx: ParserContext = {
repoRoot,
strict: process.argv.includes('--strict'),
idOverrides: loadJson(resolve(__dirname, 'parsers', '_id-overrides.json')),
labelMappings: loadJson(resolve(__dirname, 'parsers', '_label-mappings.json')),
};
console.log(`[build-packs] repo root: ${repoRoot}`);
console.log(`[build-packs] strict mode: ${ctx.strict ?? false}`);
// 1-2. Run parsers.
const allDocs: ParsedDoc[] = [];
allDocs.push(...(await parseSpells(ctx)));
allDocs.push(...(await parseTalents(ctx)));
allDocs.push(...(await parseRaces(ctx)));
allDocs.push(...(await parseDisciplines(ctx)));
allDocs.push(...(await parseMacros(ctx)));
allDocs.push(...(await parseGear(ctx)));
console.log(`[build-packs] parsed ${allDocs.length} documents total`);
// 3. Group by pack and emit one JSON per doc into packs-src/<pack>/.
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
const byPack = new Map<string, ParsedDoc[]>();
for (const doc of allDocs) {
if (ctx.idOverrides[doc.id]) {
doc.id = ctx.idOverrides[doc.id];
}
const list = byPack.get(doc.pack) ?? [];
list.push(doc);
byPack.set(doc.pack, list);
}
function getFolderHierarchy(doc: ParsedDoc): { name: string; parentName?: string }[] | null {
if (doc.subType === 'spell') {
const system = doc.system as Record<string, any>;
const school = system.school;
const deity = system.deity;
const isSuper = system.isSuperspell;
if (doc.pack === 'spells-academic') {
if (school === 'elementsAir') {
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Powietrza', parentName: 'Magia Żywiołów' }];
}
if (school === 'elementsWater') {
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Wody', parentName: 'Magia Żywiołów' }];
}
if (school === 'elementsFire') {
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Ognia', parentName: 'Magia Żywiołów' }];
}
if (school === 'elementsEarth') {
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Ziemi', parentName: 'Magia Żywiołów' }];
}
if (school === 'alchemyTransmutation') {
return [{ name: 'Alchemia' }, { name: 'Alchemia - Transmutacja', parentName: 'Alchemia' }];
}
if (school === 'alchemyBrewing') {
return [{ name: 'Alchemia' }, { name: 'Alchemia - Warzenie Eliksirów', parentName: 'Alchemia' }];
}
if (school === 'artifice') {
return [{ name: 'Rzemiosło Artefaktów' }];
}
if (school === 'golemancy') {
return [{ name: 'Golemancja' }];
}
if (school === 'runes') {
return [{ name: 'Magia Runiczna' }];
}
if (school === 'illusion') {
return [{ name: 'Magia Iluzji' }];
}
if (school === 'witch') {
return [{ name: 'Wiedźmia Magia' }];
}
if (school === 'necromancy') {
return [{ name: 'Nekromancja' }];
}
if (school === 'botany') {
return [{ name: 'Botanika' }];
}
}
if (doc.pack === 'spells-sacred') {
const DEITY_NAMES: Record<string, string> = {
common: 'Modlitwy Ogólne',
jahwe: 'Bóg (Jedyny)',
zeus: 'Zeus (Jowisz)',
demeter: 'Demeter (Ceres)',
artemis: 'Artemida (Diana)',
hekate: 'Hekate',
aphrodite: 'Afrodyta (Wenus)',
eros: 'Amor (Eros)',
};
const name = DEITY_NAMES[deity] ?? 'Inne Modlitwy';
return [{ name }];
}
if (doc.pack === 'spells-abyss') {
if (school === 'abyssAspects') {
return [{ name: 'Magia Aspektów' }];
}
if (school === 'abyssPrimal') {
return [{ name: 'Pierwotna Magia' }];
}
}
if (doc.pack === 'spells-general') {
if (isSuper) {
return [{ name: 'Superzaklęcia' }];
}
}
}
if (doc.subType === 'discipline') {
if (doc.pack === 'disciplines') {
if (doc.id === 'alchemyTransmutation' || doc.id === 'alchemyBrewing') {
return [{ name: 'Alchemia' }];
}
if (doc.id.startsWith('elements')) {
return [{ name: 'Magia Żywiołów' }];
}
if (doc.id.startsWith('sacred')) {
return [{ name: 'Magia Sakralna' }];
}
}
if (doc.pack === 'disciplines-forbidden') {
if (doc.id.startsWith('abyss')) {
return [{ name: 'Magia Otchłani' }];
}
}
}
return null;
}
for (const [pack, docs] of byPack) {
const dir = resolve(packsSrcDir, pack);
mkdirSync(dir, { recursive: true });
const foldersMap = new Map<string, { _id: string; name: string; type: string; folder: string | null; sort: number; flags: any }>();
for (const doc of docs) {
let folderId: string | null = null;
const hierarchy = getFolderHierarchy(doc);
if (hierarchy) {
for (const step of hierarchy) {
const stepId = makeFoundryId(`folder-${pack}-${step.parentName ? step.parentName + '-' : ''}${step.name}`, 16);
let parentId: string | null = null;
if (step.parentName) {
parentId = makeFoundryId(`folder-${pack}-${step.parentName}`, 16);
}
if (!foldersMap.has(stepId)) {
foldersMap.set(stepId, {
_id: stepId,
name: step.name,
type: 'Item',
folder: parentId,
sort: 0,
flags: {}
});
}
folderId = stepId;
}
}
const foundryDoc = toFoundryDoc(doc, folderId);
writeFileSync(resolve(dir, `${doc.id}.json`), `${JSON.stringify(foundryDoc, null, 2)}\n`, 'utf8');
}
for (const [folderId, folderDoc] of foldersMap) {
const folderFoundryDoc = {
_key: `!folders!${folderId}`,
...folderDoc
};
writeFileSync(resolve(dir, `_folder-${folderId}.json`), `${JSON.stringify(folderFoundryDoc, null, 2)}\n`, 'utf8');
}
console.log(` · ${pack}: ${docs.length} docs → packs-src/${pack}/`);
}
// 4. Compile each pack into LevelDB under packs/.
if (existsSync(packsOutDir)) rmSync(packsOutDir, { recursive: true });
for (const pack of byPack.keys()) {
const src = resolve(packsSrcDir, pack);
const dest = resolve(packsOutDir, pack);
await compilePack(src, dest, { recursive: false, log: false });
console.log(` ✓ compiled ${pack}`);
}
console.log(`[build-packs] done - ${byPack.size} packs in packs/`);
/** Convert a ParsedDoc into a Foundry document JSON object suitable for compilePack. */
function toFoundryDoc(doc: ParsedDoc, folderId: string | null = null): Record<string, unknown> {
const fId = makeFoundryId(doc.id);
// Macro documents have a different shape than Items.
if (doc.subType === 'macro') {
const m = doc.system as { command: string; img?: string; scope?: string; type?: string };
return {
_key: `!macros!${fId}`,
_id: fId,
name: doc.name,
type: m.type ?? 'script',
scope: m.scope ?? 'global',
command: m.command,
img: m.img ?? 'icons/svg/dice-target.svg',
author: null,
folder: folderId,
sort: 0,
flags: {
'hbm-rpg-v3': {
slug: doc.id,
sourceBook: doc.source.book,
...(doc.flags ?? {}),
},
},
_stats: { systemId: 'hbm-rpg-v3' },
};
}
return {
_key: `!items!${fId}`,
_id: fId,
name: doc.name,
type: doc.subType,
img: 'icons/svg/book.svg',
system: doc.system,
folder: folderId,
sort: 0,
flags: {
'hbm-rpg-v3': {
slug: doc.id,
sourceBook: doc.source.book,
sourceChapter: doc.source.chapter ?? '',
sourceLine: doc.source.line ?? 0,
...(doc.flags ?? {}),
},
},
_stats: { systemId: 'hbm-rpg-v3' },
};
}
/** Foundry doc IDs must be 12 chars [A-Za-z0-9] (16 chars for Folders). Hash slug to a deterministic id.
* Uses two independent FNV-1a 32-bit hashes to avoid collisions on long
* common-prefix slugs (e.g. talent-page-0 vs talent-page-1). */
function makeFoundryId(slug: string, length = 12): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let h1 = 0x811c9dc5;
let h2 = 0x4b9ace3f;
for (let i = 0; i < slug.length; i++) {
const c = slug.charCodeAt(i);
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
h2 = Math.imul(h2 ^ (c + i + 1), 0x01000193) >>> 0;
}
let out = '';
let lo = h1;
let hi = h2;
for (let i = 0; i < length; i++) {
const combined = (i % 2 === 0 ? lo : hi) >>> 0;
out += alphabet[combined % alphabet.length];
lo = Math.imul(lo, 1664525) + 1013904223 >>> 0;
hi = Math.imul(hi, 22695477) + 1013904223 >>> 0;
}
return out;
}