Initial commit
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Build LevelDB JournalEntry packs from book chapters.
|
||||
*
|
||||
* One pack per book / topic (mapped via PACK_CONFIGS + TOPICAL_CONFIGS).
|
||||
*
|
||||
* Split-book packs accept multiple source files: a list of `SourceFile`
|
||||
* entries each pointing at a `rules/` or `lore/` markdown file (instead of a
|
||||
* single monolithic `_books/` file). An optional `prefaceTitle` per source
|
||||
* captures the introductory content before the first chapter heading.
|
||||
*
|
||||
* Run: `bun scripts/build-journal-packs.ts` from `.src/foundry-lore/`.
|
||||
*/
|
||||
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve, basename, extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compilePack } from '@foundryvtt/foundryvtt-cli';
|
||||
import { parseJournalBook, type JournalDoc, type JournalPage } from './parsers/journal-parser';
|
||||
import { parseTopicalFolder } from './parsers/topical-parser';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const moduleRoot = resolve(__dirname, '..');
|
||||
const repoRoot = resolve(moduleRoot, '..', '..');
|
||||
const booksDir = resolve(repoRoot, '_books');
|
||||
const packsSrcDir = resolve(moduleRoot, 'packs-src');
|
||||
const packsOutDir = resolve(moduleRoot, 'packs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SourceFile {
|
||||
/** Path relative to repoRoot. */
|
||||
path: string;
|
||||
/**
|
||||
* If set, lines before the first chapter heading are collected and emitted
|
||||
* as a synthetic journal entry with this title (e.g. "Wstęp").
|
||||
*/
|
||||
prefaceTitle?: string;
|
||||
}
|
||||
|
||||
interface PackConfig {
|
||||
pack: string;
|
||||
/** For provenance / flags. */
|
||||
sourceBook: string;
|
||||
/** One or more source files that together make up this pack. */
|
||||
sources: SourceFile[];
|
||||
/** Skip chapters whose title matches this regex. */
|
||||
skip?: RegExp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pack configurations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PACK_CONFIGS: PackConfig[] = [
|
||||
{
|
||||
pack: 'core-rules-lore',
|
||||
sourceBook: 'core-rules',
|
||||
sources: [{ path: 'ObsidianNotes/rules/00. Podręcznik Gry.md' }],
|
||||
skip: /^(Talenty|Zaklęcia|Atrybuty|Umiejętności|Wyposażenie)/i,
|
||||
},
|
||||
{
|
||||
pack: 'bestiary-lore',
|
||||
sourceBook: 'bestiary',
|
||||
sources: [{ path: 'ObsidianNotes/rules/06. Bestiariusz.md' }],
|
||||
skip: /^Talenty/i,
|
||||
},
|
||||
|
||||
// ── Split books: combine lore/ chapters + Wstęp intro from rules/ ──
|
||||
{
|
||||
pack: 'magic-book-lore',
|
||||
sourceBook: 'magic-book',
|
||||
sources: [
|
||||
// Lore intro lives inside the rules file (no dedicated lore/ file).
|
||||
{ path: 'ObsidianNotes/rules/01. Księga Magii.md', prefaceTitle: 'Wstęp' },
|
||||
],
|
||||
skip: /^(Zaklęcia|Lista zaklęć|Rozdział V)/i,
|
||||
},
|
||||
{
|
||||
pack: 'abyss',
|
||||
sourceBook: 'abyss-curse',
|
||||
sources: [
|
||||
// Pure lore narrative.
|
||||
{ path: 'ObsidianNotes/lore/02. Otchłań i Magia.md' },
|
||||
// Rules intro (Wstęp only — mechanics chapters are skipped).
|
||||
{ path: 'ObsidianNotes/rules/02. Klątwa Otchłani.md', prefaceTitle: 'Wstęp' },
|
||||
],
|
||||
skip: /^(Rozdział II - Talenty|Rozdział III - Dary|Rozdział IV - Choroby|Rozdział V - Zaklęcia|Rozdział X - Artefakty)/i,
|
||||
},
|
||||
{
|
||||
pack: 'blood-magic-history',
|
||||
sourceBook: 'arcanum-sanguinis',
|
||||
sources: [
|
||||
{ path: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md', prefaceTitle: 'Wstęp' },
|
||||
],
|
||||
skip: /^Rozdział IV - Talenty/i,
|
||||
},
|
||||
{
|
||||
pack: 'crimson-cult',
|
||||
sourceBook: 'crimson-cult',
|
||||
sources: [
|
||||
// Lore narrative (former Chwała Szkarłatnemu Kultowi lore section).
|
||||
{ path: 'ObsidianNotes/lore/05. Szkarłatny Kult.md' },
|
||||
// Rules intro only (spells/artifacts skipped).
|
||||
{ path: 'ObsidianNotes/rules/05. Vivat Patriarcha coccineus!.md', prefaceTitle: 'Wstęp' },
|
||||
],
|
||||
skip: /^(Rozdział I - Zaklęcia|Rozdział II - Artefakty)/i,
|
||||
},
|
||||
{
|
||||
pack: 'economy',
|
||||
sourceBook: 'gold-steel-magic',
|
||||
sources: [
|
||||
{ path: 'ObsidianNotes/lore/03. Ekonomia Magicznego Świata.md' },
|
||||
{ path: 'ObsidianNotes/rules/03. Złoto, Stal i Magia.md', prefaceTitle: 'Wstęp' },
|
||||
],
|
||||
skip: /^(Aneks A - Nowe Talenty|Aneks B - Przedmioty)/i,
|
||||
},
|
||||
|
||||
// ── New: Humanity Guide ──
|
||||
{
|
||||
pack: 'humanity-guide',
|
||||
sourceBook: 'humanity-guide',
|
||||
sources: [{ path: 'ObsidianNotes/lore/10. Przewodnik Ludzkości po Magicznym Świecie.md' }],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topical vault-folder configurations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TopicalConfig {
|
||||
/** Folder path relative to repoRoot. */
|
||||
folder: string;
|
||||
/** Foundry pack id. */
|
||||
pack: string;
|
||||
}
|
||||
|
||||
const TOPICAL_CONFIGS: TopicalConfig[] = [
|
||||
{ folder: 'ObsidianNotes/disciplines', pack: 'disciplines-lore' },
|
||||
{ folder: 'ObsidianNotes/organizations', pack: 'organizations' },
|
||||
{ folder: 'ObsidianNotes/races', pack: 'races-lore' },
|
||||
{ folder: 'ObsidianNotes/classes', pack: 'classes' },
|
||||
{ folder: 'ObsidianNotes/concepts', pack: 'concepts' },
|
||||
{ folder: 'ObsidianNotes/locations', pack: 'locations' },
|
||||
{ folder: 'ObsidianNotes/npcs', pack: 'npcs' },
|
||||
{ folder: 'ObsidianNotes/conflicts', pack: 'conflicts' },
|
||||
{ folder: 'ObsidianNotes/player-characters', pack: 'player-characters' },
|
||||
{ folder: 'ObsidianNotes/adventures', pack: 'adventures' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log(`[lore] repo root: ${repoRoot}`);
|
||||
|
||||
const allDocs: JournalDoc[] = [];
|
||||
|
||||
// ── Book packs ──
|
||||
for (const cfg of PACK_CONFIGS) {
|
||||
let packDocs: JournalDoc[] = [];
|
||||
for (const src of cfg.sources) {
|
||||
const bookPath = resolve(repoRoot, src.path);
|
||||
if (!existsSync(bookPath)) {
|
||||
console.warn(` · skip source ${src.path}: file not found`);
|
||||
continue;
|
||||
}
|
||||
const docs = parseJournalBook({
|
||||
bookPath,
|
||||
pack: cfg.pack,
|
||||
sourceBook: cfg.sourceBook,
|
||||
chapterSkip: cfg.skip,
|
||||
prefaceTitle: src.prefaceTitle,
|
||||
});
|
||||
|
||||
packDocs.push(...docs);
|
||||
}
|
||||
console.log(` · ${cfg.pack}: ${packDocs.length} entries`);
|
||||
allDocs.push(...packDocs);
|
||||
}
|
||||
|
||||
// ── Topical packs ──
|
||||
for (const top of TOPICAL_CONFIGS) {
|
||||
const folderPath = resolve(repoRoot, top.folder);
|
||||
if (!existsSync(folderPath)) {
|
||||
console.warn(` · skip topical ${top.pack}: folder not found (${top.folder})`);
|
||||
continue;
|
||||
}
|
||||
const docs = parseTopicalFolder({ folderPath, pack: top.pack });
|
||||
console.log(` · ${top.pack}: ${docs.length} entries`);
|
||||
allDocs.push(...docs);
|
||||
}
|
||||
|
||||
console.log(`[lore] parsed ${allDocs.length} journal entries total`);
|
||||
|
||||
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
|
||||
const byPack = new Map<string, JournalDoc[]>();
|
||||
for (const doc of allDocs) {
|
||||
const list = byPack.get(doc.pack) ?? [];
|
||||
list.push(doc);
|
||||
byPack.set(doc.pack, list);
|
||||
}
|
||||
|
||||
for (const [pack, docs] of byPack) {
|
||||
const dir = resolve(packsSrcDir, pack);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
let sort = 0;
|
||||
for (const doc of docs) {
|
||||
sort += 100;
|
||||
const foundryDoc = toFoundryJournal(doc, sort);
|
||||
writeFileSync(resolve(dir, `${doc.id}.json`), `${JSON.stringify(foundryDoc, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
console.log(` · wrote ${pack}: ${docs.length} entries`);
|
||||
}
|
||||
|
||||
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,
|
||||
transformEntry: (doc: any, context: any) => {
|
||||
if (!doc._key) {
|
||||
console.error(`[build-journal-packs] ERROR: Document missing _key! Name: ${doc.name}, ID: ${doc._id}`);
|
||||
}
|
||||
if (doc.pages) {
|
||||
for (const page of doc.pages) {
|
||||
if (!page._key) {
|
||||
console.error(`[build-journal-packs] ERROR: Page missing _key! Journal: ${doc.name}, Page: ${page.name}, Page ID: ${page._id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
console.log(` ✓ compiled ${pack}`);
|
||||
}
|
||||
|
||||
console.log(`[lore] done — ${byPack.size} packs in packs/`);
|
||||
|
||||
function toFoundryJournal(doc: JournalDoc, sort: number): Record<string, unknown> {
|
||||
const fId = makeFoundryId(doc.id);
|
||||
return {
|
||||
_key: `!journal!${fId}`,
|
||||
_id: fId,
|
||||
name: doc.name,
|
||||
folder: null,
|
||||
sort,
|
||||
pages: doc.pages.map((page, i) => {
|
||||
const pageFId = makeFoundryId(`${doc.id}-page-${i}`);
|
||||
return {
|
||||
_key: `!journal.pages!${fId}.${pageFId}`,
|
||||
_id: pageFId,
|
||||
name: page.name,
|
||||
type: 'text',
|
||||
title: { show: true, level: 1 },
|
||||
text: { content: page.html, format: 1 /* HTML */ },
|
||||
sort: page.sort,
|
||||
};
|
||||
}),
|
||||
flags: {
|
||||
'hbm-rpg-v3-lore': {
|
||||
slug: doc.id,
|
||||
sourceBook: doc.source.book,
|
||||
sourceChapter: doc.source.chapter,
|
||||
sourceLine: doc.source.line,
|
||||
},
|
||||
},
|
||||
_stats: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a stable 12-character Foundry document ID from a slug.
|
||||
* Uses two independent FNV-1a 32-bit hashes to spread entropy across the
|
||||
* *entire* slug (not just the first 12 bytes), avoiding collisions between
|
||||
* slugs that share a long common prefix (e.g. page-0 vs page-1).
|
||||
*/
|
||||
function makeFoundryId(slug: string): string {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
// FNV-1a 32-bit, two independent passes with different offsets
|
||||
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;
|
||||
}
|
||||
// Encode 64 bits (two 32-bit hashes) into 12 base-62 chars
|
||||
let out = '';
|
||||
let lo = h1;
|
||||
let hi = h2;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
// Combine both halves cycling
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user