Initial commit

This commit is contained in:
Octoturge
2026-06-09 22:06:56 +02:00
commit fb42c6e8cc
121 changed files with 14976 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
/**
* 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;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Bumps the patch version in package.json and system.json in lock-step.
* Run automatically as part of `bun run package`.
*
* Usage:
* bun scripts/bump-version.ts # patch bump (0.1.0 → 0.1.1)
* bun scripts/bump-version.ts minor # minor bump (0.1.0 → 0.2.0)
* bun scripts/bump-version.ts major # major bump (0.1.0 → 1.0.0)
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
type BumpType = 'major' | 'minor' | 'patch';
const bump = (process.argv[2] ?? 'patch') as BumpType;
function bumpVersion(version: string, type: BumpType): string {
const [major, minor, patch] = version.split('.').map(Number);
if (type === 'major') return `${major + 1}.0.0`;
if (type === 'minor') return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
// --- package.json ---
const pkgPath = resolve(root, 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string };
const oldVersion = pkg.version;
const newVersion = bumpVersion(oldVersion, bump);
pkg.version = newVersion;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
// --- system.json ---
const sysPath = resolve(root, 'system.json');
const sys = JSON.parse(readFileSync(sysPath, 'utf8')) as { version: string; download: string };
sys.version = newVersion;
// Update the versioned filename in the download URL if it follows the vX.Y.Z pattern
sys.download = sys.download.replace(/-v[\d.]+\.zip$/, `-v${newVersion}.zip`);
writeFileSync(sysPath, JSON.stringify(sys, null, 2) + '\n');
console.log(`✓ Version bumped ${oldVersion}${newVersion}`);
+15
View File
@@ -0,0 +1,15 @@
import * as fs from 'fs';
function checkBOM(filePath: string) {
const buffer = fs.readFileSync(filePath);
if (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
console.log(`[BOM DETECTED] File starts with UTF-8 BOM: ${filePath}`);
return true;
} else {
console.log(`[CLEAN] No BOM detected: ${filePath} (starts with bytes: ${Array.from(buffer.slice(0, 5)).map(b => '0x' + b.toString(16).toUpperCase()).join(', ')})`);
return false;
}
}
checkBOM('./templates/actor/character.hbs');
checkBOM('./templates/actor/npc.hbs');
+75
View File
@@ -0,0 +1,75 @@
import * as fs from 'fs';
function analyzeTags(filePath: string) {
console.log(`\n--- Analyzing tags for: ${filePath} ---`);
const content = fs.readFileSync(filePath, 'utf8');
// A regex to find HTML tags (opening, closing, self-closing) across newlines
const tagRegex = /<(\/?[a-zA-Z0-9\-]+)(?:\s+[^>]*?)?>/gs;
const stack: { tag: string; line: number; index: number }[] = [];
// Helper to find line number from string index
const getLineNumber = (index: number) => {
return content.slice(0, index).split('\n').length;
};
const selfClosingTags = new Set([
'img', 'input', 'br', 'hr', 'meta', 'link'
]);
// Clean Handlebars comments first as they might contain html
let cleanedContent = content.replace(/\{\{!--[\s\S]*?--\}\}/g, '');
// Clean Handlebars blocks, but be careful of block helpers containing tags.
// Actually, we can replace all {{...}} with space to preserve index positions
cleanedContent = cleanedContent.replace(/\{\{[\s\S]*?\}\}/g, (match) => {
return ' '.repeat(match.length);
});
let match;
let hasErrors = false;
while ((match = tagRegex.exec(cleanedContent)) !== null) {
const tag = match[1];
const isClosing = tag.startsWith('/');
const tagName = isClosing ? tag.slice(1).toLowerCase() : tag.toLowerCase();
const line = getLineNumber(match.index);
if (selfClosingTags.has(tagName) || match[0].endsWith('/>')) {
continue;
}
if (!isClosing) {
stack.push({ tag: tagName, line, index: match.index });
} else {
if (stack.length === 0) {
console.log(`[Error] Extra closing tag </${tagName}> on line ${line}`);
hasErrors = true;
} else {
const last = stack.pop()!;
if (last.tag !== tagName) {
console.log(`[Mismatch] Expected </${last.tag}> (opened on line ${last.line}), but found </${tagName}> on line ${line}`);
hasErrors = true;
// Push back last to try to recover
stack.push(last);
}
}
}
}
if (stack.length > 0) {
console.log(`[Error] Unclosed tags at end of file:`);
stack.forEach(item => {
console.log(` - <${item.tag}> opened on line ${item.line}`);
});
hasErrors = true;
}
if (!hasErrors) {
console.log(`[Success] All non-self-closing tags are perfectly balanced!`);
}
}
analyzeTags('./templates/actor/character.hbs');
analyzeTags('./templates/actor/npc.hbs');
+116
View File
@@ -0,0 +1,116 @@
import { resolve, dirname } from 'node:path';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import { parseSpells } from './parsers/spell-parser.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const systemRoot = resolve(__dirname, '..');
const repoRoot = resolve(systemRoot, '..', '..');
const spellsOutDir = resolve(repoRoot, 'ObsidianNotes', 'spells');
async function run() {
const ctx = {
repoRoot,
strict: false,
idOverrides: JSON.parse(readFileSync(resolve(__dirname, 'parsers', '_id-overrides.json'), 'utf8')),
labelMappings: JSON.parse(readFileSync(resolve(__dirname, 'parsers', '_label-mappings.json'), 'utf8')),
};
const allDocs = await parseSpells(ctx);
console.log(`Parsed ${allDocs.length} spells.`);
let created = 0;
for (const doc of allDocs) {
if (doc.subType !== 'spell') continue;
const sys = doc.system as any;
// Determine directory structure
let category = 'academic';
let subcategory = sys.school;
if (sys.isSuperspell) {
category = 'general';
subcategory = 'superspells';
} else if (sys.castingMode === 'sacred') {
category = 'sacred';
subcategory = sys.deity || 'common-prayers';
if (subcategory === 'common') subcategory = 'common-prayers';
} else if (sys.school === 'abyss' || sys.school === 'abyssAspects' || sys.school === 'abyssPrimal' || sys.school === 'eldritch' || sys.school === 'eldritchAspects' || sys.school === 'eldritchPrimal' || sys.sourceBook === 'klatwa-otchlani') {
category = 'eldritch';
subcategory = '';
sys.school = 'eldritch';
} else if (sys.school === 'crimson' || sys.sourceBook === 'crimson-cult') {
category = 'crimson';
subcategory = '';
sys.school = 'crimson';
} else if (sys.castingMode === 'blood' || sys.school === 'blood') {
category = 'blood';
subcategory = '';
} else if (sys.school === 'general') {
category = 'general';
subcategory = '';
} else {
// academic mappings
const academicMappings: Record<string, string> = {
'elementsAir': 'air-magic',
'elementsWater': 'water-magic',
'elementsFire': 'fire-magic',
'elementsEarth': 'earth-magic',
'alchemyTransmutation': 'alchemy-transmutation',
'alchemyBrewing': 'alchemy-brewing',
'artifice': 'artifice',
'golemancy': 'golemancy',
'runes': 'rune-magic',
'illusion': 'illusion-magic',
'witch': 'witch-magic',
'necromancy': 'necromancy',
'botany': 'botany',
'general': 'general-magic',
'manaSourceMage': 'mana-source-mage',
'wildWitch': 'wild-witch-magic'
};
subcategory = academicMappings[sys.school] || sys.school;
}
let spellName = doc.name;
if (spellName === 'Superzaklęcie - Requiem') spellName = 'Requiem';
// Sanitize filename to english kebab-case
const baseSlug = spellName.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/ł/g, 'l').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const englishSlug = ctx.idOverrides[baseSlug] ?? baseSlug;
const sanitizedName = englishSlug;
let targetDir = resolve(spellsOutDir, category);
if (subcategory) {
targetDir = resolve(targetDir, subcategory);
}
mkdirSync(targetDir, { recursive: true });
// Build Frontmatter
const frontmatter = {
tags: ['spell', category, subcategory].filter(Boolean),
...sys
};
const yamlStr = yaml.dump(frontmatter, { skipInvalid: true, noRefs: true });
const mdContent = `---
${yamlStr}---
# ${spellName}
${doc.description}
`;
const filePath = resolve(targetDir, `${sanitizedName}.md`);
writeFileSync(filePath, mdContent, 'utf8');
created++;
}
console.log(`Successfully extracted ${created} spells to ObsidianNotes/spells.`);
}
run().catch(console.error);
+267
View File
@@ -0,0 +1,267 @@
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
const systemRoot = resolve(import.meta.dirname, '..');
const packsSrcDir = resolve(systemRoot, 'packs-src');
const outDir = resolve(systemRoot, 'lang', 'compendium', 'en');
// Load en.json for racesList and talentsList
const enJsonPath = resolve(systemRoot, 'lang', 'en.json');
const enJson = JSON.parse(readFileSync(enJsonPath, 'utf8'));
const racesList = enJson.HBM?.racesList ?? {};
const talentsList = enJson.HBM?.talentsList ?? {};
const nameTranslations: Record<string, string> = {
// Spells
"magiczny-pocisk": "Magic Missile",
"magiczna-tarcza": "Magic Shield",
"poblask": "Gleam",
"telekineza": "Telekinesis",
"przeskok": "Blink",
"teleportacja": "Teleport",
"przebicie-eteru": "Ether Pierce",
"fala-energii": "Energy Wave",
"przeblysk-prawdy": "Glimpse of Truth",
"szybka-mysl": "Quick Thought",
"latanie": "Flight",
"powietrzna-fala": "Air Wave",
"wyczucie-zagrozenia": "Danger Sense",
"celnosc": "Accuracy",
"szybki-jak-wiatr": "Swift as Wind",
"tworzenie-i-kontrolowanie-wody": "Create and Control Water",
"lodowa-podloga": "Ice Floor",
"ukojenie": "Solace",
"gradobicie": "Hailstorm",
"mistyczna-mgla": "Mystic Fog",
"podpalenie": "Ignite",
"wzniecanie-ognia": "Kindle",
"ognista-powloka": "Fire Cloak",
"kula-ognia": "Fireball",
"gorejaca-skora": "Burning Skin",
"skalista-tarcza": "Stone Shield",
"ruchome-piaski": "Quicksand",
"trzesienie-ziemi": "Earthquake",
"zwirowa-zbroja": "Gravel Armor",
"skalista-pulapka": "Stone Trap",
"eteryczny-przewodnik": "Ether Guide",
"labirynt-umyslu": "Mind Labyrinth",
"madrosc-salomona": "Wisdom of Solomon",
"magiczna-odpornosc": "Magic Resistance",
"magiczne-kajdany": "Magic Shackles",
"magnetyzm": "Magnetism",
"negacja": "Negation",
"niewidzialnosc": "Invisibility",
"ozywienie-szkieletu": "Animate Skeleton",
"przemiana-w-olow": "Transmutation to Lead",
"rozmawianie-ze-zmarlymi": "Speak with Dead",
"rozsypanie": "Shatter",
"uzdrowienie-chorych": "Heal Sick",
"woal-snow": "Veil of Dreams",
"zloto-glupcow": "Fool's Gold",
"zwierciadlany-wizerunek": "Mirror Image",
"blogoslawienstwo-zasiewow": "Blessing of Sowing",
"blyskawice-lancuchowe": "Chain Lightning",
"boska-celnosc": "Divine Accuracy",
"boska-sprawiedliwosc": "Divine Justice",
"boska-wlocznia": "Divine Spear",
"chmara-insektow": "Swarm of Insects",
"cykl-por-roku": "Cycle of Seasons",
"egida": "Aegis",
"gniew-matki-ziemi": "Wrath of Mother Earth",
"grom-z-nieba": "Thunderbolt",
"klatwa-hekate": "Curse of Hekate",
"kradziez-many": "Mana Drain",
"leczacy-dotyk": "Healing Touch",
"magiczne-przyspieszenie": "Magic Haste",
"moc-ziemi": "Earth Power",
"modlitwa-o-opieke": "Prayer for Protection",
"obecnosc-krola-bogow": "Presence of the King of Gods",
"piekno-bogini": "Beauty of the Goddess",
"plomien-namietnosci": "Flame of Passion",
"pomost": "Bridge",
"pozadanie-zmyslow": "Desire of Senses",
"promien-harmonii": "Ray of Harmony",
"przebudzenie": "Awakening",
"przyspieszone-zniwa": "Accelerated Harvest",
"sad-ostateczny": "Last Judgment",
"strzala-kupidyna": "Cupid's Arrow",
"strzala-pozadania": "Arrow of Desire",
"swieta-stal": "Holy Steel",
"tajemne-poznanie": "Arcane Cognition",
"tesknota-serca": "Heart Longing",
"urok-niewinnosci": "Charm of Innocence",
"uswiecenie": "Sanctification",
"wiez-serc": "Bond of Hearts",
"wyladowanie-elektrostatyczne": "Electrostatic Discharge",
"wzmocnienie-magii": "Magic Empowerment",
"neutralizacja": "Neutralization",
"pierwotna-kula": "Primal Ball",
"tajemniczy-strumien": "Mysterious Stream",
"zakazana-oslona": "Forbidden Shield",
"klatwa-szkarlatu": "Crimson Curse",
"szkarlatna-sprawiedliwosc": "Crimson Justice",
"szkarlatne-plomienie": "Crimson Flames",
"szkarlatny-pocisk": "Crimson Bolt",
"szkarlatny-sztylet": "Crimson Dagger",
"superzaklecie-requiem": "Superspell Requiem",
"dar-otchlani": "Gift of the Abyss",
"metamagia": "Metamagic",
"swobodny-przeplyw-magii": "Free Flow of Magic",
// Disciplines
"alchemytransmutation": "Alchemy - Transmutation",
"alchemybrewing": "Alchemy - Brewing of Elixirs",
"botany": "Botany",
"elementsair": "Elemental Magic - Air",
"elementswater": "Elemental Magic - Water",
"elementsfire": "Elemental Magic - Fire",
"elementsearth": "Elemental Magic - Earth",
"artifice": "Crafting of Artifacts",
"golemancy": "Golemancy",
"runes": "Runic Magic",
"manasourcemage": "Sources of Power",
"illusion": "Magic of Illusion",
"sacred": "Sacred Magic",
"sacredexorcism": "Sacred Magic - Exorcisms",
"witch": "Witch Magic",
"necromancy": "Necromancy",
"blood": "Blood Magic",
"wildwitch": "Wild Witch Magic",
};
const folderNameTranslations: Record<string, string> = {
"Amor (Eros)": "Cupid (Eros)",
"Zeus (Jowisz)": "Zeus (Jupiter)",
"Afrodyta (Wenus)": "Aphrodite (Venus)",
"Hekate": "Hecate",
"Demeter (Ceres)": "Demeter (Ceres)",
"Modlitwy Ogólne": "General Prayers",
"Bóg (Jedyny)": "God (The One)",
"Artemida (Diana)": "Artemis (Diana)",
"Magia Żywiołów": "Elemental Magic",
"Magia Powietrza": "Air Magic",
"Magia Wody": "Water Magic",
"Magia Ognia": "Fire Magic",
"Magia Ziemi": "Earth Magic",
"Alchemia": "Alchemy",
"Alchemia - Transmutacja": "Alchemy - Transmutation",
"Alchemia - Warzenie Eliksirów": "Alchemy - Brewing of Elixirs",
"Rzemiosło Artefaktów": "Crafting of Artifacts",
"Golemancja": "Golemancy",
"Magia Runiczna": "Runic Magic",
"Magia Iluzji": "Magic of Illusion",
"Wiedźmia Magia": "Witch Magic",
"Nekromancja": "Necromancy",
"Botanika": "Botany",
"Magia Aspektów": "Aspect Magic",
"Pierwotna Magia": "Primal Magic",
"Superzaklęcia": "Superspells",
"Magia Otchłani": "Abyss Magic",
"Magia Sakralna": "Sacred Magic"
};
// Descriptions translation helper
function translateDescription(desc: string): string {
if (!desc) return '';
return desc
.replace(/\* \*\*Punkty Pancerza:\*\*/g, '* **Armor Points:**')
.replace(/\* \*\*Pancerz:\*\*/g, '* **Armor:**')
.replace(/\* \*\*Wymagania:\*\*/g, '* **Requirements:**')
.replace(/\* \*\*Cechy:\*\*/g, '* **Traits:**')
.replace(/\* \*\*Cena:\*\*/g, '* **Price:**')
.replace(/\* \*\*Obrażenia:\*\*/g, '* **Damage:**')
.replace(/\* \*\*Zasięg:\*\*/g, '* **Range:**')
.replace(/Ciało/g, 'Body')
.replace(/Umysł/g, 'Mind')
.replace(/Dusza/g, 'Soul')
.replace(/Magia/g, 'Magic')
.replace(/Subtelny/g, 'Subtle')
.replace(/Hałaśliwy/g, 'Noisy')
.replace(/Tarcza, gdy jest używana, podnosi Obronę właściciela o jeden stopień/g, 'A shield, when used, increases the owner\'s Defense by one step.');
}
function titleCase(slug: string): string {
return slug
.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
// Main logic
if (!existsSync(outDir)) {
mkdirSync(outDir, { recursive: true });
}
const packs = readdirSync(packsSrcDir);
for (const pack of packs) {
const packPath = join(packsSrcDir, pack);
const files = readdirSync(packPath).filter(f => f.endsWith('.json'));
const translations: Record<string, any> = {};
for (const file of files) {
const filePath = join(packPath, file);
const content = JSON.parse(readFileSync(filePath, 'utf8'));
const polishName = content.name;
const slug = content.flags?.['hbm-rpg-v3']?.slug ?? file.replace('.json', '');
let englishName = '';
if (file.startsWith('_folder-')) {
englishName = folderNameTranslations[polishName] ?? polishName;
} else if (racesList[slug]) {
englishName = racesList[slug];
} else if (talentsList[slug]) {
englishName = talentsList[slug];
} else if (nameTranslations[slug]) {
englishName = nameTranslations[slug];
} else if (nameTranslations[slug.toLowerCase().replace(/[^a-z0-9]/g, '')]) {
englishName = nameTranslations[slug.toLowerCase().replace(/[^a-z0-9]/g, '')];
} else {
englishName = titleCase(slug);
}
const description = content.system?.description ?? '';
const englishDesc = translateDescription(description);
translations[polishName] = {
name: englishName,
};
if (englishDesc) {
translations[polishName].description = englishDesc;
}
}
const packLabels: Record<string, string> = {
"spells-general": "Spells - General",
"spells-sacred": "Spells - Sacred",
"spells-academic": "Academic Disciplines",
"spells-blood": "Blood Magic",
"spells-crimson": "Crimson Cult Magic",
"spells-eldritch": "Spells - Eldritch",
"talents": "Talents",
"talents-blood": "Talents - Blood Magic",
"talents-eldritch": "Talents - Curse of the Eldritch",
"talents-npc": "Talents (NPC)",
"races": "Races",
"disciplines": "Magic Disciplines",
"disciplines-forbidden": "Forbidden Disciplines",
"hbm-macros": "HbM Macros",
"items-weapons": "Weapons",
"items-armor": "Armor",
"items-gear": "Equipment (Other)"
};
const output = {
label: packLabels[pack] ?? titleCase(pack),
entries: translations
};
const outPath = join(outDir, `${pack}.json`);
writeFileSync(outPath, JSON.stringify(output, null, 2) + '\n', 'utf8');
console.log(`Generated Babele translation for ${pack} -> ${outPath}`);
}
console.log('Babele translation generation complete!');
+55
View File
@@ -0,0 +1,55 @@
/**
* UUID reference linter — scans parsed docs (packs-src/*) for any
* `@UUID[...]` references that don't resolve within the generated set.
*
* Run after build-packs.ts: `bun scripts/lint-uuid-refs.ts`
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const systemRoot = resolve(__dirname, '..');
const packsSrcDir = resolve(systemRoot, 'packs-src');
const allIds = new Set<string>();
const refs: Array<{ from: string; uuid: string }> = [];
function walk(dir: string): void {
for (const entry of readdirSync(dir)) {
const full = resolve(dir, entry);
const s = statSync(full);
if (s.isDirectory()) {
walk(full);
continue;
}
if (!full.endsWith('.json')) continue;
const json = JSON.parse(readFileSync(full, 'utf8')) as { _id?: string; system?: unknown };
if (json._id) allIds.add(json._id);
const text = readFileSync(full, 'utf8');
const matches = text.matchAll(/@UUID\[([^\]]+)\]/g);
for (const m of matches) refs.push({ from: full, uuid: m[1] });
}
}
walk(packsSrcDir);
const broken = refs.filter((r) => {
// UUID format: Compendium.system.pack.Item.<id> — only validate Item refs.
const m = r.uuid.match(/^Compendium\.[^.]+\.[^.]+\.Item\.(.+)$/);
if (!m) return false;
return !allIds.has(m[1]);
});
if (broken.length === 0) {
console.log(`[lint-uuid-refs] OK — ${refs.length} refs scanned, none broken`);
process.exit(0);
}
console.error(`[lint-uuid-refs] ${broken.length} broken refs:`);
for (const b of broken) {
console.error(` · ${b.from}\n ${b.uuid}`);
}
process.exit(1);
+16
View File
@@ -0,0 +1,16 @@
// HbM Apply Condition — pick a status effect and toggle it on targeted/selected tokens.
const targets = game.user.targets.size > 0 ? Array.from(game.user.targets) : canvas.tokens.controlled;
if (targets.length === 0) return ui.notifications.warn('Zaznacz lub naceluj token(y).');
const effects = CONFIG.statusEffects.filter((e) => e.id?.startsWith('hbm.'));
const opts = effects.map((e) => `<option value="${e.id}">${game.i18n.localize(e.name ?? e.label ?? e.id)}</option>`).join('');
const result = await Dialog.prompt({
title: 'Nałóż przypadłość',
content: `<form><div class="form-group"><label>Przypadłość:</label><select name="effect">${opts}</select></div></form>`,
label: 'Nałóż',
callback: (html) => String(new foundry.applications.ux.FormDataExtended(html.querySelector('form')).object.effect),
});
if (!result) return;
for (const t of targets) {
if (t.actor) await t.actor.toggleStatusEffect(result, { active: true });
}
ui.notifications.info(`Nałożono ${result} na ${targets.length} cel(e).`);
+30
View File
@@ -0,0 +1,30 @@
// HbM Attribute Check — pick attribute on selected token's actor, roll d6 pool.
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
if (!actor) return ui.notifications.warn('Wybierz token postaci.');
const attrs = ['body', 'mind', 'soul', 'magic'];
const labels = { body: 'Ciało', mind: 'Umysł', soul: 'Dusza', magic: 'Magia' };
const opts = attrs.map((a) => `<option value="${a}">${labels[a]} (${actor.system.attributes[a]?.value ?? 0})</option>`).join('');
const result = await Dialog.prompt({
title: `Test atrybutu — ${actor.name}`,
content: `
<form>
<div class="form-group">
<label>Atrybut:</label>
<select name="attr">${opts}</select>
</div>
<div class="form-group">
<label>Próg sukcesu (TS):</label>
<input type="number" name="threshold" value="4" min="2" max="6"/>
</div>
</form>
`,
label: 'Rzuć',
callback: (html) => {
const fd = new foundry.applications.ux.FormDataExtended(html.querySelector('form')).object;
return { attr: String(fd.attr), threshold: Number(fd.threshold) };
},
});
if (!result) return;
const pool = actor.system.attributes[result.attr]?.value ?? 1;
const roll = await new Roll(`${pool}d6cs>=${result.threshold}`).evaluate();
await roll.toMessage({ flavor: `${actor.name} — test ${labels[result.attr]} (TS ${result.threshold})`, speaker: ChatMessage.getSpeaker({ actor }) });
+24
View File
@@ -0,0 +1,24 @@
// HbM Group Cast Helper — sum mana of controlled token actors (potential co-casters).
const tokens = canvas.tokens.controlled;
if (tokens.length < 2) return ui.notifications.warn('Zaznacz co najmniej 2 tokeny współrzucających.');
const lines = [];
let totalMana = 0;
let totalPool = 0;
for (const t of tokens) {
const a = t.actor;
if (!a) continue;
const mana = a.system.attributes?.mana?.value ?? 0;
const magic = a.system.attributes?.magic?.value ?? 0;
totalMana += mana;
totalPool += magic;
lines.push(`<li><strong>${a.name}</strong> — Mana ${mana}, Magia ${magic}</li>`);
}
const html = `
<div class="hbm-group-cast">
<p>Współrzucający (${tokens.length}):</p>
<ul>${lines.join('')}</ul>
<p><strong>Łączna mana: ${totalMana}</strong></p>
<p><strong>Łączna pula d6: ${totalPool}</strong></p>
</div>
`;
await ChatMessage.create({ content: html, speaker: ChatMessage.getSpeaker() });
+6
View File
@@ -0,0 +1,6 @@
// HbM Long Rest — invoke game.hbm.rest(actor, 'long') on selected token's actor.
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
if (!actor) return ui.notifications.warn('Wybierz token postaci.');
if (!game.hbm?.rest) return ui.notifications.error('Brak game.hbm.rest — system niezainicjowany.');
const result = await game.hbm.rest(actor, 'long');
ui.notifications.info(`${actor.name}: długi odpoczynek — pełna regeneracja zasobów.`);
+25
View File
@@ -0,0 +1,25 @@
// HbM Pool Roll — prompts for pool size and threshold, then rolls a d6 pool.
const result = await Dialog.prompt({
title: 'Rzut puli d6',
content: `
<form>
<div class="form-group">
<label>Pula (liczba kości):</label>
<input type="number" name="pool" value="3" min="1" max="20"/>
</div>
<div class="form-group">
<label>Próg sukcesu (TS):</label>
<input type="number" name="threshold" value="4" min="2" max="6"/>
</div>
</form>
`,
label: 'Rzuć',
callback: (html) => {
const fd = new foundry.applications.ux.FormDataExtended(html.querySelector('form')).object;
return { pool: Number(fd.pool), threshold: Number(fd.threshold) };
},
});
if (!result) return;
const formula = `${result.pool}d6cs>=${result.threshold}`;
const roll = await new Roll(formula).evaluate();
await roll.toMessage({ flavor: `Pula d6 (TS ${result.threshold})` });
+8
View File
@@ -0,0 +1,8 @@
// HbM Refill Zeal — bumps zeal by 1 on selected token's actor (debug helper).
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
if (!actor) return ui.notifications.warn('Wybierz token postaci.');
const cur = actor.system.attributes?.zeal?.value ?? 0;
const max = actor.system.attributes?.zeal?.max ?? 0;
const next = Math.min(max, cur + 1);
await actor.update({ 'system.attributes.zeal.value': next });
ui.notifications.info(`${actor.name}: Zapał ${cur}${next}/${max}.`);
+6
View File
@@ -0,0 +1,6 @@
// HbM Short Rest — invoke game.hbm.rest(actor, 'short') on selected token's actor.
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
if (!actor) return ui.notifications.warn('Wybierz token postaci.');
if (!game.hbm?.rest) return ui.notifications.error('Brak game.hbm.rest — system niezainicjowany.');
const result = await game.hbm.rest(actor, 'short');
ui.notifications.info(`${actor.name}: krótki odpoczynek — przywrócono ${result?.healed ?? 0} PW.`);
+38
View File
@@ -0,0 +1,38 @@
/**
* Package the built dist/ into a versioned zip ready to drop into a
* Foundry VTT instance's Data/systems/ directory (or to upload as a release).
*/
import { createWriteStream, readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import archiver from 'archiver';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const distDir = resolve(root, 'dist');
const systemJson = JSON.parse(readFileSync(resolve(distDir, 'system.json'), 'utf8')) as { id: string; version: string };
const outFile = resolve(root, `${systemJson.id}-v${systemJson.version}.zip`);
await new Promise<void>((resolveAll, rejectAll) => {
const output = createWriteStream(outFile);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => {
console.log(`✓ Packaged ${outFile} (${archive.pointer()} bytes)`);
resolveAll();
});
archive.on('warning', (err) => {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') console.warn(err);
else rejectAll(err);
});
archive.on('error', rejectAll);
archive.pipe(output);
// Place dist/* inside a folder named after the system id (Foundry expects this)
archive.directory(distDir, systemJson.id, (entry) => {
if (entry.name.endsWith('LOCK')) return false;
return entry;
});
archive.finalize();
});
+184
View File
@@ -0,0 +1,184 @@
{
"_comment": "Override auto-generated slugs to use English IDs internally for database entries and filenames.",
"aniol": "angel",
"czlowiek": "human",
"magiczny-pocisk": "magic-missile",
"magiczna-tarcza": "magic-shield",
"poblask": "gleam",
"telekineza": "telekinesis",
"przeskok": "blink",
"teleportacja": "teleport",
"przebicie-eteru": "ether-pierce",
"fala-energii": "energy-wave",
"przeblysk-prawdy": "truth-glimpse",
"szybka-mysl": "quick-thought",
"latanie": "flight",
"powietrzna-fala": "air-wave",
"wyczucie-zagrozenia": "danger-sense",
"celnosc": "accuracy",
"szybki-jak-wiatr": "wind-swift",
"tworzenie-i-kontrolowanie-wody": "water-create",
"lodowa-podloga": "ice-floor",
"ukojenie": "solace",
"gradobicie": "hailstorm",
"mistyczna-mgla": "mystic-fog",
"podpalenie": "ignition",
"wzniecanie-ognia": "kindle",
"ognista-powloka": "fire-cloak",
"kula-ognia": "fireball",
"gorejaca-skora": "burning-skin",
"skalista-tarcza": "stone-shield",
"ruchome-piaski": "quicksand",
"trzesienie-ziemi": "earthquake",
"zwirowa-zbroja": "gravel-armor",
"skalista-pulapka": "stone-trap",
"eteryczny-przewodnik": "ether-guide",
"labirynt-umyslu": "mind-labyrinth",
"madrosc-salomona": "wisdom-of-solomon",
"magiczna-odpornosc": "magic-resistance",
"magiczne-kajdany": "magic-shackles",
"magnetyzm": "magnetism",
"negacja": "negation",
"niewidzialnosc": "invisibility",
"ozywienie-szkieletu": "animate-skeleton",
"przemiana-w-olow": "lead-transmutation",
"rozmawianie-ze-zmarlymi": "speak-with-dead",
"rozsypanie": "shatter",
"uzdrowienie-chorych": "heal-sick",
"woal-snow": "dream-veil",
"wymagania-rasa-czlowiek": "requirements-race-human",
"zloto-glupcow": "fools-gold",
"zwierciadlany-wizerunek": "mirror-image",
"blogoslawienstwo-zasiewow": "sowing-blessing",
"blyskawice-lancuchowe": "chain-lightning",
"boska-celnosc": "divine-accuracy",
"boska-sprawiedliwosc": "divine-justice",
"boska-wlocznia": "divine-spear",
"chmara-insektow": "insect-swarm",
"cykl-por-roku": "seasons-cycle",
"egida": "aegis",
"gniew-matki-ziemi": "wrath-of-mother-earth",
"grom-z-nieba": "thunderbolt",
"klatwa-hekate": "curse-of-hekate",
"kradziez-many": "mana-drain",
"leczacy-dotyk": "healing-touch",
"magiczne-przyspieszenie": "magic-haste",
"moc-ziemi": "earth-power",
"modlitwa-o-opieke": "prayer-for-protection",
"obecnosc-krola-bogow": "presence-of-the-king-of-gods",
"piekno-bogini": "beauty-of-the-goddess",
"plomien-namietnosci": "flame-of-passion",
"pomost": "bridge",
"pozadanie-zmyslow": "senses-desire",
"promien-harmonii": "ray-of-harmony",
"przebudzenie": "awakening",
"przyspieszone-zniwa": "accelerated-harvest",
"sad-ostateczny": "last-judgment",
"strzala-kupidyna": "cupids-arrow",
"strzala-pozadania": "arrow-of-desire",
"swieta-stal": "holy-steel",
"tajemne-poznanie": "arcane-cognition",
"tesknota-serca": "heart-longing",
"urok-niewinnosci": "charm-of-innocence",
"uswiecenie": "sanctification",
"wiez-serc": "bond-of-hearts",
"wyladowanie-elektrostatyczne": "electrostatic-discharge",
"wymagania-rasa-aniol": "requirements-race-angel",
"wzmocnienie-magii": "magic-empowerment",
"neutralizacja": "neutralization",
"pierwotna-kula": "primal-orb",
"tajemniczy-strumien": "mysterious-stream",
"zakazana-oslona": "forbidden-shield",
"klatwa-szkarlatu": "crimson-curse",
"szkarlatna-sprawiedliwosc": "crimson-justice",
"szkarlatne-plomienie": "crimson-flames",
"szkarlatny-pocisk": "crimson-bolt",
"szkarlatny-sztylet": "crimson-dagger",
"superzaklecie-requiem": "superspell-requiem",
"arcymag-dziedzina": "archmage",
"blogoslawienstwo-bostwo": "deity-blessing",
"blogoslawione-pochodzenie": "blessed-origin",
"cios-w-plecy": "backstab",
"czuly-zmysl": "keen-sense",
"czuly-zmysl-zmysl": "keen-sense",
"darmowy-talent": "free-talent",
"dyplomata": "diplomat",
"finta": "feint",
"gadanina": "babble",
"gotowosc-do-walki": "battle-readiness",
"jednosc-z-magia": "unity-with-magic",
"jednosc-z-natura": "unity-with-nature",
"magiczna-dyscyplina": "magic-discipline",
"magiczny-zmysl-zmysl": "magical-sense",
"mistrz-magii-dziedzina": "master-of-magic",
"niezniszczalne-zaklecia": "indestructible-spells",
"obureczny": "ambidextrous",
"odpornosc-na-magie": "magic-resistance",
"odpornosc-na-trucizny": "poison-resistance",
"oportunista": "opportunist",
"pancerz-wiary": "armor-of-faith",
"parowanie": "parry",
"powietrzne-uniki": "air-evasion",
"riposta": "riposte",
"rozplatanie-zaklec": "spell-shattering",
"sen-na-jawie": "daydream",
"technologia-jest-wsrod-nas": "technology-is-among-us",
"urok-osobisty": "personal-charm",
"zakazana-wiedza-dziedzina": "forbidden-knowledge",
"zawisanie-w-powietrzu": "levitation",
"medyk-polowy": "field-medic",
"olimpijczyk": "olympian",
"czlowiek-z-zelaza": "man-of-iron",
"pasjonat-zwierzat": "animal-enthusiast",
"jestes-magiem-krwi-i-rozumiesz-jak-cenne-jest-zycie": "blood-mage-precious-life",
"oltarz-krwi": "blood-altar",
"szacunek-do-zycia": "respect-for-life",
"dar-otchlani": "gift-of-the-abyss",
"magowie-aspektow-moga-sie-ukrywac-jako-magowie-swojej-pierwotnej-dziedziny": "aspect-mages-disguise",
"metamagia": "metamagic",
"swobodny-przeplyw-magii": "free-flow-of-magic",
"gear-bicz": "whip",
"gear-bron-dwureczna": "two-handed-weapon",
"gear-bron-jednoreczna-lub-poltorareczna": "one-handed-or-bastard-weapon",
"gear-laska-quarterstaff": "quarterstaff",
"gear-luk-bloczkowy": "compound-bow",
"gear-luk-elfi": "elven-bow",
"gear-luk-klasyczny": "classic-bow",
"gear-sztylet": "dagger",
"gear-wlocznia": "spear",
"gear-pancerz-ciezki": "heavy-armor",
"gear-pancerz-lekki": "light-armor",
"gear-pancerz-sredni": "medium-armor",
"gear-tarcza": "shield",
"gear-artefakt-przechowujacy-mane": "mana-storage-artifact",
"gear-artefakt-skupiajacy-magie": "magic-focus-artifact",
"gear-karty-do-gry": "playing-cards",
"gear-kociolek": "cauldron",
"gear-miecz-swietlny": "lightsaber",
"gear-miecz-treningowy": "training-sword",
"gear-przedmioty-do-wrozenia": "divination-tools",
"gear-przybory-do-golemancji": "golemancy-tools",
"gear-przybory-do-tworzenia-artefatow": "artifact-crafting-tools",
"gear-shinai": "shinai",
"gear-szklana-fiolka": "glass-vial",
"gear-tablica-ouija": "ouija-board",
"gear-teleskop": "telescope",
"gear-zestaw-do-rytualow": "ritual-kit",
"ostrze-dawida": "blade-of-david",
"fanatyczne-poswiecenie": "fanatical-sacrifice",
"wykrycie-zmian": "detect-changes",
"destrukcja": "destruction",
"prawdziwa-dezintegracja": "true-disintegration",
"zaglada": "doom",
"magiczna-plaga": "magic-plague",
"aegis": "aegis",
"transmutacja-krajobrazu": "landscape-transmutation",
"bramy-wymiarow": "dimension-gates",
"reanimacja": "reanimation",
"plomienna-strzala": "flaming-arrow",
"tarcza-eteryczna": "ethereal-shield",
"echo-zmierzchu": "twilight-echo",
"requiem": "requiem",
"przywolanie-istoty-z-otchlani": "summon-eldritch-entity",
"nic-przeznaczenia": "thread-of-destiny"
}
+129
View File
@@ -0,0 +1,129 @@
{
"_comment": "Polish (book) label → English internal id used in constants.ts. Lowercased for case-insensitive lookup.",
"schools": {
"magia ogólna": "general",
"magia żywiołów": "elements",
"magia żywiołów — powietrze": "elementsAir",
"magia żywiołów (powietrze)": "elementsAir",
"magia powietrza": "elementsAir",
"magia żywiołów — woda": "elementsWater",
"magia żywiołów (woda)": "elementsWater",
"magia wody": "elementsWater",
"magia żywiołów — ogień": "elementsFire",
"magia żywiołów (ogień)": "elementsFire",
"magia ognia": "elementsFire",
"magia żywiołów — ziemia": "elementsEarth",
"magia żywiołów (ziemia)": "elementsEarth",
"magia ziemi": "elementsEarth",
"magia sakralna": "sacred",
"magia sakralna — egzorcyzmy": "sacredExorcism",
"magia sakralna - egzorcyzmy": "sacredExorcism",
"egzorcyzmy": "sacredExorcism",
"wiedźmia magia": "witch",
"magia iluzji": "illusion",
"alchemia transmutacji": "alchemyTransmutation",
"magia alchemii transmutacji": "alchemyTransmutation",
"alchemia - transmutacja": "alchemyTransmutation",
"warzenie eliksirów": "alchemyBrewing",
"alchemia - warzenie eliksirów": "alchemyBrewing",
"rzemiosło artefaktów": "artifice",
"golemancja": "golemancy",
"magia runiczna": "runes",
"źródła mocy": "manaSourceMage",
"źródło mocy": "manaSourceMage",
"botanika": "botany",
"nekromancja": "necromancy",
"magia krwi": "blood",
"magia szkarłatu": "crimson",
"magia otchłani — magia aspektów": "eldritchAspects",
"magia otchłani — pierwotna magia": "eldritchPrimal",
"dzika wiedźmia magia": "wildWitch"
},
"disciplines": {
"alchemia transmutacji": "alchemyTransmutation",
"magia alchemii transmutacji": "alchemyTransmutation",
"alchemia - transmutacja": "alchemyTransmutation",
"warzenie eliksirów": "alchemyBrewing",
"alchemia - warzenie eliksirów": "alchemyBrewing",
"botanika": "botany",
"magia żywiołów — powietrze": "elementsAir",
"magia żywiołów (powietrze)": "elementsAir",
"magia żywiołów — woda": "elementsWater",
"magia żywiołów (woda)": "elementsWater",
"magia żywiołów — ogień": "elementsFire",
"magia żywiołów (ogień)": "elementsFire",
"magia żywiołów — ziemia": "elementsEarth",
"magia żywiołów (ziemia)": "elementsEarth",
"rzemiosło artefaktów": "artifice",
"golemancja": "golemancy",
"magia runiczna": "runes",
"źródła mocy": "manaSourceMage",
"źródło mocy": "manaSourceMage",
"magia iluzji": "illusion",
"magia sakralna": "sacred",
"magia sakralna — egzorcyzmy": "sacredExorcism",
"magia sakralna - egzorcyzmy": "sacredExorcism",
"egzorcyzmy": "sacredExorcism",
"wiedźmia magia": "witch",
"nekromancja": "necromancy",
"magia krwi": "blood",
"magia szkarłatu": "crimson",
"magia otchłani": "eldritch",
"dzika wiedźmia magia": "wildWitch"
},
"deities": {
"modlitwy powszechne": "common",
"bóg-jahwe-jedyny": "jahwe",
"jahwe": "jahwe",
"zeus-jowisz": "zeus",
"zeus": "zeus",
"demeter-ceres": "demeter",
"demeter": "demeter",
"artemida-diana": "artemis",
"artemida": "artemis",
"hekate": "hekate",
"afrodyta-wenus": "aphrodite",
"afrodyta": "aphrodite",
"amor-eros": "eros",
"eros": "eros"
},
"symbols": {
"potentia": "Potentia",
"tutamen": "Tutamen",
"lux": "Lux",
"motus": "Motus",
"iter": "Iter",
"vacuos": "Vacuos",
"vitium": "Vitium",
"praecantatio": "Praecantatio",
"aer": "Aer",
"aqua": "Aqua",
"gelum": "Gelum",
"ignis": "Ignis",
"terra": "Terra",
"cognitio": "Cognitio",
"alienis": "Alienis",
"illusio": "Illusio",
"somnium": "Somnium",
"tenebrae": "Tenebrae",
"auram": "Auram",
"vinculum": "Vinculum",
"telum": "Telum",
"sensus": "Sensus",
"perditio": "Perditio",
"perfodio": "Perfodio",
"sano": "Sano",
"volatus": "Volatus",
"tempestas": "Tempestas"
},
"attributes": {
"ciało": "body",
"umysł": "mind",
"dusza": "soul",
"magia": "magic"
},
"skills": {
"zdolności magiczne": "magicalAbilities",
"oddanie": "devotion"
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* Lightweight markdown walker tuned to the HbM book layout.
*
* Supports two formats:
*
* 1. Legacy Google-Docs export (plain text, `_books/`):
* ## Chapter heading
* Section heading (plain text / bold)
* Item Name
* * Field: value
*
* 2. Native Obsidian/repo files (`rules/`, `disciplines/`, etc.):
* --- ← YAML frontmatter (skipped)
* ...
* ---
* ## Rozdział I - ... ← chapter (any heading level)
* ### Sub-section ← section / sub-section
* #### Spell Name ← item name
* - [[#Anchor|Label]] ← Obsidian TOC bullet (skipped)
* * Field: value
*
* The walker emits one `BookBlock` per item it can find. Section context
* is carried through so parsers know e.g. which discipline a spell lives
* under.
*/
import { readFileSync } from 'node:fs';
export interface BookBlock {
/** The line directly preceding the bullet block — usually the item name. */
name: string;
/** Bullet keys/values, preserved in order. */
fields: Array<{ key: string; value: string }>;
/** Free-form paragraph text following the bullet block (description). */
description: string;
/** Most recent chapter (any `#+ ...`) heading. */
chapter: string;
/** Most recent non-bullet, non-empty line above the item name (sub-section). */
section: string;
/** 1-based line number of the item name in the source file. */
line: number;
}
const SEPARATOR = /^_{3,}$/;
const BULLET = /^\*\s+([^:]+):\s*(.+)$/;
// Match any markdown heading level (1-6 `#` chars).
const HEADING = /^#{1,6}\s+(.+)$/;
// Chapter headings are the two highest heading levels we encounter — heuristic:
// treat ## headings as "chapter" and ### headings as "section".
const CHAPTER_HEADING = /^#{1,2}\s+(.+)$/;
const SECTION_HEADING = /^#{3,4}\s+(.+)$/;
// Obsidian TOC bullets: `- [[#Anchor|Label]]` or `* [[#Anchor|Label]]`
const OBSIDIAN_TOC_BULLET = /^[-*]\s+\[\[#/;
// Frontmatter fence
const FRONTMATTER_FENCE = /^---\s*$/;
/** Strip leading markdown heading markers and bold `**` from a raw line. */
function stripHeadingMarkers(line: string): string {
return line.replace(/^#{1,6}\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').trim();
}
/** Skip YAML frontmatter block at start of file. Returns the index of the first non-frontmatter line. */
function skipFrontmatter(lines: string[]): number {
if (lines.length === 0 || !FRONTMATTER_FENCE.test(lines[0].trim())) return 0;
for (let i = 1; i < lines.length; i++) {
if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1;
}
return 0; // malformed — start from 0
}
export interface WalkOptions {
/**
* Predicate that returns `true` when the line (after stripping heading markers)
* is a known section header (e.g. matches a discipline / school / deity label).
* Used by the walker to track the current section across separators and prose.
*/
isSectionHeader?: (line: string) => boolean;
}
export function walkBook(path: string, opts: WalkOptions = {}): BookBlock[] {
const text = readFileSync(path, 'utf8');
const lines = text.split(/\r?\n/);
const blocks: BookBlock[] = [];
let chapter = '';
let section = '';
const isSectionHeader = opts.isSectionHeader ?? (() => false);
const startIdx = skipFrontmatter(lines);
// Look ahead for bullet-block starts; track the line above as the name.
for (let i = startIdx; i < lines.length; i++) {
const rawLine = lines[i].trimEnd();
// Skip Obsidian TOC bullets — they look like `- [[#Section|Label]]`.
if (OBSIDIAN_TOC_BULLET.test(rawLine.trim())) continue;
// Chapter headings: ## or # level (the two highest we honour).
const chMatch = rawLine.match(CHAPTER_HEADING);
if (chMatch) {
chapter = stripHeadingMarkers(rawLine);
// Reset section only for top-level (##) headings.
if (rawLine.startsWith('## ') || rawLine.startsWith('# ')) section = '';
continue;
}
// Section sub-headings: ### or #### level become section markers.
const secMatch = rawLine.match(SECTION_HEADING);
if (secMatch) {
const bare = stripHeadingMarkers(rawLine);
const isL3 = rawLine.startsWith('### ');
if (isL3 || isSectionHeader(bare)) {
section = bare;
continue;
}
}
// Track plain-text section headers (legacy format).
const trimmed = rawLine.trim();
if (trimmed && !SEPARATOR.test(trimmed) && !BULLET.test(trimmed) && isSectionHeader(trimmed)) {
section = trimmed;
continue;
}
if (BULLET.test(rawLine)) {
// Walk up to find the name line (first non-empty, non-separator, non-heading line above).
let nameLine = '';
let nameIdx = i - 1;
while (nameIdx >= startIdx) {
const candidate = lines[nameIdx].trim();
// Skip empty lines, separators, TOC links, list items, and requirements lines
if (
candidate &&
!SEPARATOR.test(candidate) &&
!OBSIDIAN_TOC_BULLET.test(candidate) &&
!/^[*-]\s+/.test(candidate) &&
!/^(?:[*-]\s+)?Wymagania?:/i.test(candidate) &&
!/^>/.test(candidate)
) {
// Strip any heading markers; skip pure chapter/section headings as the name
// (they are already captured in `chapter`/`section`).
nameLine = stripHeadingMarkers(candidate);
break;
}
nameIdx--;
}
// Collect the bullet block.
const fields: Array<{ key: string; value: string }> = [];
let j = i;
while (j < lines.length) {
const m = lines[j].trimEnd().match(BULLET);
if (!m) break;
fields.push({ key: m[1].trim(), value: m[2].trim() });
j++;
}
// Description = following lines until next bullet block, separator, or heading.
const descLines: string[] = [];
let k = j;
while (k < lines.length) {
const cur = lines[k].trimEnd();
if (SEPARATOR.test(cur)) break;
if (HEADING.test(cur)) break;
if (BULLET.test(cur)) break;
if (OBSIDIAN_TOC_BULLET.test(cur.trim())) break;
descLines.push(cur);
k++;
}
blocks.push({
name: nameLine,
fields,
description: descLines.join('\n').trim(),
chapter,
section,
line: nameIdx + 1,
});
i = k - 1; // advance past description
}
}
return blocks;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Discipline parser. Disciplines are identified by the entries in
* `_label-mappings.json#disciplines`; we emit one stub Item per discipline
* with name, source book, and an empty description (filled in later).
*
* Many disciplines also have a "passive ability" mentioned right after the
* discipline header in Podręcznik Gry — we capture the next non-empty line
* if it looks like a short ability description.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { normalizeKey, slugify } from './helpers';
interface DisciplineSeed {
id: string;
polishName: string;
pack: string;
book: SourceBookId;
}
const DISCIPLINE_SEEDS: DisciplineSeed[] = [
{ id: 'alchemyTransmutation', polishName: 'Alchemia - Transmutacja', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'alchemyBrewing', polishName: 'Alchemia - Warzenie Eliksirów', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'botany', polishName: 'Botanika', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'elementsAir', polishName: 'Magia Żywiołów (Powietrze)', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'elementsWater', polishName: 'Magia Żywiołów (Woda)', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'elementsFire', polishName: 'Magia Żywiołów (Ogień)', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'elementsEarth', polishName: 'Magia Żywiołów (Ziemia)', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'artifice', polishName: 'Rzemiosło Artefaktów', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'golemancy', polishName: 'Golemancja', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'runes', polishName: 'Magia Runiczna', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'manaSourceMage', polishName: 'Źródło Mocy', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'illusion', polishName: 'Magia Iluzji', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'sacred', polishName: 'Magia Sakralna', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'sacredExorcism', polishName: 'Magia Sakralna — Egzorcyzmy', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'witch', polishName: 'Wiedźmia Magia', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'necromancy', polishName: 'Nekromancja', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'blood', polishName: 'Magia Krwi', pack: 'disciplines-forbidden', book: 'arcanum-sanguinis' },
{ id: 'crimson', polishName: 'Magia Szkarłatu', pack: 'disciplines-forbidden', book: 'crimson-cult' },
{ id: 'abyssAspects', polishName: 'Magia Otchłani — Magia Aspektów', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
{ id: 'abyssPrimal', polishName: 'Magia Otchłani — Pierwotna Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
{ id: 'wildWitch', polishName: 'Dzika Wiedźmia Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
];
const SUGGESTED_ATTRIBUTE: Record<string, string> = {
sacred: 'soul',
blood: 'soul',
crimson: 'soul',
};
const SUGGESTED_SKILLS: Record<string, string[]> = {
sacred: ['devotion'],
blood: ['magicalAbilities'],
};
/**
* Pull a one-paragraph discipline description out of Podręcznik Gry by
* matching the header line and grabbing the next non-empty paragraph.
*/
/** Strip markdown heading markers from a line. */
function stripHeadingMarkers(line: string): string {
return line.replace(/^#{1,6}\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').trim();
}
/** Skip YAML frontmatter (--- ... ---) at file start. */
function skipFrontmatter(lines: string[]): number {
if (lines.length === 0 || !/^---\s*$/.test(lines[0].trim())) return 0;
for (let i = 1; i < lines.length; i++) {
if (/^---\s*$/.test(lines[i].trim())) return i + 1;
}
return 0;
}
function lookupDescription(repoRoot: string, polishName: string): string {
// Try split-book sources first (disciplines from Księga Magii or Klątwa Otchłani
// now live in rules/01.* and rules/02.*).
const splitSources = [
resolve(repoRoot, 'ObsidianNotes/rules/01. Księga Magii.md'),
resolve(repoRoot, 'ObsidianNotes/rules/02. Klątwa Otchłani.md'),
resolve(repoRoot, 'ObsidianNotes/rules/00. Podręcznik Gry.md'),
];
const target = normalizeKey(polishName.split('—')[0]);
for (const file of splitSources) {
let text = '';
try {
text = readFileSync(file, 'utf8');
} catch {
continue;
}
const rawLines = text.split(/\r?\n/);
const start = skipFrontmatter(rawLines);
const lines = rawLines.slice(start);
for (let i = 0; i < lines.length; i++) {
const bare = normalizeKey(stripHeadingMarkers(lines[i].trim()));
if (bare === target) {
// Capture next paragraph (until blank line).
const buf: string[] = [];
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
const cur = lines[j].trim();
if (cur === '' && buf.length > 0) break;
if (cur && !/^_{3,}$/.test(cur) && !/^#{1,6}\s/.test(cur)) buf.push(cur);
}
if (buf.length > 0) return buf.join(' ');
}
}
}
return '';
}
export const parseDisciplines: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
for (const seed of DISCIPLINE_SEEDS) {
const baseSlug = ctx.idOverrides[slugify(seed.polishName)] ?? seed.id;
const description = lookupDescription(ctx.repoRoot, seed.polishName);
docs.push({
id: baseSlug,
name: seed.polishName,
documentType: 'Item',
subType: 'discipline',
pack: seed.pack,
source: { book: seed.book },
system: {
color: '',
suggestedAttribute: SUGGESTED_ATTRIBUTE[seed.id] ?? 'magic',
suggestedSkills: SUGGESTED_SKILLS[seed.id] ?? ['magicalAbilities'],
passiveAbility: '',
availableSpells: [],
description,
},
description,
});
}
return docs;
};
+161
View File
@@ -0,0 +1,161 @@
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { resolve, join, extname, basename } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn } from './types';
import { slugify, parseList } from './helpers';
export const parseGear: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
const itemsDir = resolve(ctx.repoRoot, 'ObsidianNotes', 'items');
if (!existsSync(itemsDir)) {
console.warn(`[gear-parser] Directory not found: ${itemsDir}`);
return docs;
}
const subdirs = [
{ name: 'weapons', pack: 'items-weapons', category: 'weapon' },
{ name: 'armor', pack: 'items-armor', category: 'armor' },
{ name: 'gear', pack: 'items-gear', category: 'equipment' },
];
for (const dir of subdirs) {
const fullDirPath = resolve(itemsDir, dir.name);
if (!existsSync(fullDirPath)) continue;
const files = readdirSync(fullDirPath).filter((f) => extname(f) === '.md');
for (const file of files) {
const filePath = join(fullDirPath, file);
const raw = readFileSync(filePath, 'utf8');
const { frontmatter, content } = extractMarkdown(raw);
const name = basename(file, '.md').replace(/_/g, ' ');
const doc = buildGearDoc(name, frontmatter, content, dir.category, dir.pack);
docs.push(doc);
}
}
return docs;
};
function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } {
const fm: Record<string, string> = {};
let content = raw;
if (raw.startsWith('---')) {
const endIdx = raw.indexOf('---', 3);
if (endIdx > -1) {
const fmText = raw.slice(3, endIdx).trim();
content = raw.slice(endIdx + 3).trim();
const lines = fmText.split('\n');
for (const line of lines) {
const colonIdx = line.indexOf(':');
if (colonIdx > -1) {
const key = line.slice(0, colonIdx).trim();
let val = line.slice(colonIdx + 1).trim();
if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
fm[key] = val;
}
}
}
}
return { frontmatter: fm, content };
}
function buildGearDoc(name: string, fm: Record<string, string>, content: string, category: string, pack: string): ParsedDoc {
// Common properties
const priceStr = fm.price || fm.cena || '0';
const priceMatch = priceStr.match(/\d+/);
const value = priceMatch ? parseInt(priceMatch[0], 10) : 0;
const description = content
.split('\n')
.filter(l => !l.startsWith('# ') && !l.startsWith('* **Cena:**') && !l.startsWith('* **Obrażenia:**') && !l.startsWith('* **Cechy:**') && !l.startsWith('* **Pancerz:**'))
.join('\n')
.trim();
// HTML format description by wrapping paragraphs if needed
const htmlDescription = description ? `<p>${description.replace(/\n\n/g, '</p><p>')}</p>` : '';
const system: any = {
category,
rarity: 'common',
quantity: 1,
weight: 0,
value,
equipped: false,
damageReductionBonus: 0,
description: htmlDescription,
weapon: {
damage: '',
damageType: 'physical',
properties: [],
},
armor: {
damageReduction: 0,
condition: 1,
conditionMax: 1,
armorType: 'light',
stealthDisadvantage: false,
strengthRequirement: 0,
}
};
if (category === 'weapon') {
system.weapon.damage = fm.damage || fm.obrazenia || '';
if (fm.traits || fm.cechy) {
system.weapon.properties = parseList(fm.traits || fm.cechy);
}
} else if (category === 'armor') {
const isShield = name.toLowerCase().includes('tarcza') || name.toLowerCase().includes('shield');
const drStr = fm.armorClass || fm.pancerz || fm.armor_points || (isShield ? '1' : '0');
const drMatch = drStr.match(/\d+/);
const dr = drMatch ? parseInt(drMatch[0], 10) : 0;
system.armor.damageReduction = dr;
const conditionStr = fm.wytrzymalosc || fm.condition || fm.durability;
let cond = dr;
if (conditionStr) {
const condMatch = conditionStr.match(/\d+/);
if (condMatch) {
cond = parseInt(condMatch[0], 10);
}
}
if (cond < 1) cond = 1;
system.armor.condition = cond;
system.armor.conditionMax = cond;
if (isShield) {
system.armor.armorType = 'shield';
} else if (dr === 3) {
system.armor.armorType = 'heavy';
} else if (dr === 2) {
system.armor.armorType = 'medium';
} else {
system.armor.armorType = 'light';
}
const traitsStr = fm.traits || fm.cechy || '';
if (traitsStr.toLowerCase().includes('hałaśliwy') || traitsStr.toLowerCase().includes('noisy')) {
system.armor.stealthDisadvantage = true;
}
const reqStr = fm.requirements || fm.wymagania || '';
const reqMatch = reqStr.match(/(?:ciało|body)\s*>=\s*(\d+)/i);
if (reqMatch) {
system.armor.strengthRequirement = parseInt(reqMatch[1], 10);
}
}
return {
id: slugify(`gear-${name}`),
name,
documentType: 'Item',
subType: 'gear',
pack,
source: { book: 'core-rules' },
system,
};
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Polish-aware slug helper. Lowercases, strips diacritics, replaces
* whitespace and punctuation with hyphens, collapses repeats.
*/
export function slugify(input: string): string {
return input
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/ł/g, 'l')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
}
/** Strip Polish diacritics for case-insensitive map lookups. */
export function normalizeKey(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
.normalize('NFC');
}
/** Parse "5:2" -> { threshold: 5, successes: 2 }. */
export function parseDifficulty(s: string): { threshold: number; successes: number } | null {
const m = s.trim().match(/^(\d+)\s*:\s*([\d\w]+)$/);
if (!m) return null;
const successes = isNaN(Number(m[2])) ? 1 : Number(m[2]);
return { threshold: Number(m[1]), successes };
}
/** Parse "1 punkt", "3 punkty", "5 punktów" → 1/3/5. Returns 0 on failure. */
export function parsePoints(s: string): number {
const m = s.trim().match(/^(\d+)/);
return m ? Number(m[1]) : 0;
}
/** Parse a comma- or whitespace-separated symbol list. */
export function parseList(s: string): string[] {
return s
.split(/[,;]+/)
.map((x) => x.trim())
.filter((x) => x.length > 0);
}
/**
* Parse a Polish range string into structure.
* "Na siebie" → { kind: 'self' }
* "Dotyk" → { kind: 'touch' }
* "15 m" → { kind: 'distance', value: 15, unit: 'm' }
* anything else → { kind: 'special', text }
*/
export function parseRange(s: string): { kind: string; value?: number; unit?: string; text?: string } {
const t = s.trim();
if (/^na siebie$/i.test(t)) return { kind: 'self' };
if (/^dotyk$/i.test(t)) return { kind: 'touch' };
const m = t.match(/^(\d+)\s*(m|km|cm)\b/i);
if (m) return { kind: 'distance', value: Number(m[1]), unit: m[2].toLowerCase() };
return { kind: 'special', text: t };
}
/**
* Parse area-of-effect text into structured shape.
* "3×8 m" → rectangle 3×8
* "promień 5 m" → sphere radius 5
* "stożek 10 m" → cone length 10
* "linia 20 m" → line length 20
*/
export function parseAreaOfEffect(s: string): { shape: string; x?: number; y?: number; unit?: string; text?: string } | null {
const t = s.trim();
let m = t.match(/(\d+)\s*[x×]\s*(\d+)\s*(m|km|cm)?/i);
if (m) return { shape: 'rectangle', x: Number(m[1]), y: Number(m[2]), unit: (m[3] ?? 'm').toLowerCase() };
m = t.match(/promień\s+(\d+)\s*(m|km|cm)?/i);
if (m) return { shape: 'sphere', x: Number(m[1]), unit: (m[2] ?? 'm').toLowerCase() };
m = t.match(/stożek\s+(\d+)\s*(m|km|cm)?/i);
if (m) return { shape: 'cone', x: Number(m[1]), unit: (m[2] ?? 'm').toLowerCase() };
m = t.match(/linia\s+(\d+)\s*(m|km|cm)?/i);
if (m) return { shape: 'line', x: Number(m[1]), unit: (m[2] ?? 'm').toLowerCase() };
return null;
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Macro parser. Reads hand-coded macro source files from `scripts/macros/*.js`
* and emits ParsedDoc[] for the build pipeline. Macros are not parsed from
* books — they're a curated set of GM/player utilities.
*/
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ParsedDoc, ParserContext, ParserFn } from './types';
const __dirname = dirname(fileURLToPath(import.meta.url));
const macrosDir = resolve(__dirname, '..', 'macros');
interface MacroDef {
id: string;
name: string;
file: string;
img: string;
}
const MACROS: MacroDef[] = [
{ id: 'pool-roll', name: 'Rzut Puli d6', file: 'pool-roll.js', img: 'icons/svg/d20.svg' },
{ id: 'attribute-check', name: 'Test Atrybutu', file: 'attribute-check.js', img: 'icons/svg/dice-target.svg' },
{ id: 'apply-condition', name: 'Nałóż Przypadłość', file: 'apply-condition.js', img: 'icons/svg/aura.svg' },
{ id: 'short-rest', name: 'Krótki Odpoczynek', file: 'short-rest.js', img: 'icons/svg/regen.svg' },
{ id: 'long-rest', name: 'Długi Odpoczynek', file: 'long-rest.js', img: 'icons/svg/sun.svg' },
{ id: 'group-cast-helper', name: 'Pomocnik Rzucania Grupowego', file: 'group-cast-helper.js', img: 'icons/svg/upgrade.svg' },
{ id: 'refill-zeal', name: 'Doładuj Zapał', file: 'refill-zeal.js', img: 'icons/svg/lightning.svg' },
];
export const parseMacros: ParserFn = async (_ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
for (const m of MACROS) {
let command = '';
try {
command = readFileSync(resolve(macrosDir, m.file), 'utf8');
} catch (err) {
console.warn(`[macro-parser] could not read ${m.file}: ${(err as Error).message}`);
continue;
}
docs.push({
id: m.id,
name: m.name,
documentType: 'Item',
subType: 'macro',
pack: 'hbm-macros',
source: { book: 'podrecznik-gry' },
system: {
type: 'script',
scope: 'global',
command,
img: m.img,
},
});
}
return docs;
};
+180
View File
@@ -0,0 +1,180 @@
/**
* Race parser. Races in HbM live in Podręcznik Gry, Rozdział II.
*
* Format:
* Race Name (optional /Variant)
* <flavor paragraphs>
*
* Cechy Rasowe:
* * Dostępne Dziedziny Magii:
* * <discipline 1>
* * <discipline 2>
* * N Punktów Atrybutów
* * Darmowe Talenty:
* * <talent 1>
* * <talent 2>
* * 1 Punkt Umiejętności w <skill>
* * N Punktów Umiejętności
* ________________
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn } from './types';
import { normalizeKey, slugify } from './helpers';
const SEPARATOR = /^_{3,}$/;
const RACE_FEATURES_HEADER = /^Cechy Rasowe:\s*$/;
const SOURCE_FILE = '_books/HbM RPG v3 - Podręcznik Gry.md';
const KNOWN_RACES = new Set([
'człowiek',
'elf',
'feles (kotowate)',
'feles',
'lamia (naga)',
'lamia',
'anioł/anielica',
'anioł',
'krasnolud',
'demon',
'malferianin',
]);
export const parseRaces: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
const path = resolve(ctx.repoRoot, SOURCE_FILE);
let lines: string[] = [];
try {
lines = readFileSync(path, 'utf8').split(/\r?\n/);
} catch {
return docs;
}
// Find each `Cechy Rasowe:` block; the race name is the most recent
// non-empty, non-separator line above the block that matches a known race.
for (let i = 0; i < lines.length; i++) {
if (!RACE_FEATURES_HEADER.test(lines[i].trim())) continue;
// Walk up looking for the race name + flavor paragraphs.
let nameLine = '';
let nameIdx = i - 1;
while (nameIdx >= 0) {
const cur = lines[nameIdx].trim();
if (cur && !SEPARATOR.test(cur)) {
// The name is the FIRST line above that matches KNOWN_RACES; flavor lines come between.
if (KNOWN_RACES.has(normalizeKey(cur))) {
nameLine = cur;
break;
}
}
nameIdx--;
}
if (!nameLine) continue;
// Flavor description = lines between race name and `Cechy Rasowe:`.
const flavorLines: string[] = [];
for (let j = nameIdx + 1; j < i; j++) {
const cur = lines[j].trimEnd();
if (cur === '' || SEPARATOR.test(cur.trim())) continue;
flavorLines.push(cur);
}
const physicalDescription = flavorLines.join('\n').trim();
// Walk down through the bullet block until next separator/blank-paragraph.
const bulletLines: string[] = [];
let k = i + 1;
while (k < lines.length) {
const cur = lines[k].trimEnd();
if (SEPARATOR.test(cur.trim())) break;
bulletLines.push(cur);
k++;
}
const parsed = parseRaceFeatures(bulletLines);
const baseSlug = slugify(stripVariant(nameLine));
const id = ctx.idOverrides[baseSlug] ?? baseSlug;
docs.push({
id,
name: stripVariant(nameLine),
documentType: 'Item',
subType: 'race',
pack: 'races',
source: { book: 'podrecznik-gry', chapter: 'Tworzenie Postaci', line: nameIdx + 1 },
system: {
availableDisciplines: parsed.disciplines,
attributePoints: parsed.attributePoints,
skillPoints: parsed.skillPoints,
freeTalents: parsed.freeTalents,
racialAbilities: [],
physicalDescription,
description: physicalDescription,
},
description: physicalDescription,
});
}
return docs;
};
function stripVariant(name: string): string {
// "Anioł/Anielica" → "Anioł"; "Feles (Kotowate)" → "Feles"
return name.split('/')[0].split('(')[0].trim();
}
interface ParsedRaceFeatures {
disciplines: string[];
attributePoints: number;
skillPoints: number;
freeTalents: string[];
}
function parseRaceFeatures(lines: string[]): ParsedRaceFeatures {
const result: ParsedRaceFeatures = {
disciplines: [],
attributePoints: 0,
skillPoints: 0,
freeTalents: [],
};
// Detect "* <Header>:" then collect indented child bullets that follow.
let mode: 'none' | 'disciplines' | 'talents' = 'none';
for (const raw of lines) {
const line = raw.trimEnd();
if (line === '') continue;
// Top-level bullet: `* xxx`
const top = line.match(/^\*\s+(.+)$/);
if (top) {
const text = top[1].trim();
if (/^Dostępne Dziedziny Magii:/i.test(text)) {
mode = 'disciplines';
continue;
}
if (/^Darmowe Talenty:/i.test(text)) {
mode = 'talents';
continue;
}
mode = 'none';
// Standalone counters.
const attr = text.match(/^(\d+)\s+(?:Punkt|Punkty|Punktów)\s+Atrybutów/i);
if (attr) result.attributePoints = Number(attr[1]);
const skill = text.match(/^(\d+)\s+(?:Punkt|Punkty|Punktów)\s+Umiejętności(?:\s+do\s+rozdania)?/i);
if (skill && !/Umiejętności\s+w\s+/i.test(text)) result.skillPoints = Number(skill[1]);
continue;
}
// Indented child bullet ` * xxx` (Google Docs export uses tabs/spaces).
const child = line.match(/^\s+\*\s+(.+)$/);
if (!child) continue;
const text = child[1].trim();
if (mode === 'disciplines') result.disciplines.push(text);
else if (mode === 'talents') result.freeTalents.push(text);
}
return result;
}
+138
View File
@@ -0,0 +1,138 @@
import { resolve, basename } from 'node:path';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { z } from 'zod';
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { slugify } from './helpers';
import yaml from 'js-yaml';
/** Looser zod schema — mirrors `SpellData` defaults; build pipeline converts to system payload. */
const SpellSystemSchema = z.object({
castingMode: z.enum(['standard', 'sacred', 'witch', 'blood']),
school: z.string(),
deity: z.string().default(''),
sourceBook: z.string(),
manaCost: z.number().int().min(0),
bloodCost: z.number().int().min(0).default(0),
complexityLevel: z.number().int().min(0).default(0),
isSuperspell: z.boolean().default(false),
requiresGroupCast: z.boolean().default(false),
minCasters: z.number().int().min(1).default(1),
nonCombatOnly: z.boolean().default(false),
damageBase: z.string().default(''),
damageType: z.string().default(''),
ignoresArmor: z.boolean().default(false),
statusEffects: z.array(z.string()).default([]),
saveAttribute: z.string().default(''),
saveSkill: z.string().default(''),
triggers: z.array(z.object({ event: z.string(), effect: z.string() })).default([]),
components: z.object({
verbal: z.boolean().default(false),
somatic: z.boolean().default(false),
material: z.string().default(''),
symbols: z.array(z.string()).default([]),
}),
requirements: z.object({
race: z.array(z.string()).default([]),
talent: z.array(z.string()).default([]),
discipline: z.array(z.string()).default([]),
}),
difficulty: z.object({
threshold: z.number().int().min(1).max(6),
successes: z.number().int().min(1),
}),
range: z.object({
kind: z.string(),
value: z.number().optional(),
unit: z.string().optional(),
text: z.string().optional(),
}),
duration: z.string().default(''),
castingTime: z.string().default(''),
target: z.string().default(''),
areaOfEffect: z
.object({ shape: z.string(), x: z.number().optional(), y: z.number().optional(), unit: z.string().optional() })
.nullable()
.default(null),
overcastOptions: z
.array(z.object({ text: z.string(), repeatable: z.boolean().default(true), cost: z.number().int().default(0) }))
.default([]),
description: z.string().default(''),
});
function getAllFiles(dirPath: string, arrayOfFiles: string[] = []) {
const files = readdirSync(dirPath);
for (const file of files) {
const fullPath = resolve(dirPath, file);
if (statSync(fullPath).isDirectory()) {
arrayOfFiles = getAllFiles(fullPath, arrayOfFiles);
} else if (file.endsWith('.md')) {
arrayOfFiles.push(fullPath);
}
}
return arrayOfFiles;
}
export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
const spellsDir = resolve(ctx.repoRoot, 'ObsidianNotes', 'spells');
let files: string[] = [];
try {
files = getAllFiles(spellsDir);
} catch (e) {
console.warn('[spell-parser] No ObsidianNotes/spells directory found or unreadable.');
return docs;
}
for (const file of files) {
const text = readFileSync(file, 'utf8');
const match = text.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) continue;
const [_, frontmatterRaw, bodyRaw] = match;
let frontmatter: any;
try {
frontmatter = yaml.load(frontmatterRaw);
} catch (e) {
if (ctx.strict) throw new Error(`[spell-parser] Invalid YAML in ${file}: ${e}`);
continue;
}
const payload = { ...frontmatter, description: bodyRaw.trim() };
const nameMatch = payload.description.match(/^#\s+(.+)$/m);
const spellName = nameMatch ? nameMatch[1].trim() : basename(file, '.md');
payload.description = payload.description.replace(/^#\s+.+$/m, '').trim();
const parsed = SpellSystemSchema.safeParse(payload);
if (!parsed.success) {
if (ctx.strict) throw new Error(`[spell-parser] Validation failed for "${file}": ${parsed.error.message}`);
continue;
}
const baseId = basename(file, '.md');
let pack = 'spells-academic';
const relativePath = file.substring(spellsDir.length).replace(/\\/g, '/');
if (relativePath.includes('/general/')) pack = 'spells-general';
else if (relativePath.includes('/sacred/')) pack = 'spells-sacred';
else if (relativePath.includes('/eldritch/')) pack = 'spells-eldritch';
else if (relativePath.includes('/crimson/')) pack = 'spells-crimson';
else if (relativePath.includes('/blood/')) pack = 'spells-blood';
docs.push({
id: baseId,
name: spellName,
documentType: 'Item',
subType: 'spell',
pack,
source: { book: parsed.data.sourceBook, chapter: 'Spells', line: 1 },
system: parsed.data,
description: payload.description,
});
}
return docs;
};
+250
View File
@@ -0,0 +1,250 @@
/**
* Talent parser. Talents in HbM are formatted as plain-text headers:
*
* Talent Name
* Wymagania: req1, req2 … (optional; single line)
* <description paragraphs>
* ________________
*
* They live in:
* - Podręcznik Gry, "Rozdział IV - Talenty"
* - Bestiariusz, "Rozdział III - Talenty" (NPC-only talents)
*
* The walker can't help us here — we segment by `________________`
* separators within the talent chapter and look for a "Wymagania:" line.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { slugify } from './helpers';
const SEPARATOR = /^_{3,}$/;
const REQUIREMENTS_PREFIX = /^(?:\*\*|\*)?Wymagania:(?:\*\*|\*)?\s*(.+)$/i;
interface TalentSource {
book: SourceBookId;
file: string;
/** Inclusive line range (1-based) of the talent chapter. */
startMarker: RegExp;
endMarker: RegExp;
/** Pack id to write into. */
pack: string;
/** Optional source-module flag stamped onto each talent. */
sourceModule?: 'arcanum-sanguinis' | 'abyss-curse' | 'crimson-cult' | null;
}
const TALENT_SOURCES: TalentSource[] = [
{
book: 'podrecznik-gry',
file: 'ObsidianNotes/rules/00. Podręcznik Gry.md',
startMarker: /^Rozdział IV - Talenty\s*$/,
endMarker: /^Rozdział V\b/,
pack: 'talents',
sourceModule: null,
},
{
book: 'bestiariusz',
file: 'ObsidianNotes/rules/06. Bestiariusz.md',
startMarker: /^Rozdział III - Talenty\s*$/,
endMarker: /^Rozdział IV\b/,
pack: 'talents-npc',
sourceModule: null,
},
{
// Arcanum Sanguinis: now lives in ObsidianNotes/rules/04. Arcanum Sanguinis.md.
// Headings may be prefixed with `## ` or `### `.
book: 'arcanum-sanguinis',
file: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md',
startMarker: /^#{0,4}\s*Rozdział IV - Talenty\s*$/,
endMarker: /^#{0,4}\s*(Rozdział V\b|Aneks\b)/,
pack: 'talents-blood',
sourceModule: 'arcanum-sanguinis',
},
{
// Klątwa Otchłani: now lives in ObsidianNotes/rules/02. Klątwa Otchłani.md.
book: 'klatwa-otchlani',
file: 'ObsidianNotes/rules/02. Klątwa Otchłani.md',
startMarker: /^#{0,4}\s*Rozdział II - Talenty\s*$/,
endMarker: /^#{0,4}\s*Rozdział III\b/,
pack: 'talents-eldritch',
sourceModule: 'abyss-curse',
},
];
/**
* Talent chapters are noisy — split into segments by separator lines, then
* within each segment find consecutive talent blocks. A talent block is:
* - first non-empty line = name (short, no trailing punctuation, no colon)
* - optional `Wymagania: ...` line
* - subsequent lines until next name candidate or end of segment = description
*
* Heuristic for name detection: line is short (< 60 chars), no trailing `.`,
* not a bullet, not a header (`Rozdział`, `Aneks`).
*/
export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
for (const source of TALENT_SOURCES) {
const path = resolve(ctx.repoRoot, source.file);
let lines: string[] = [];
try {
lines = readFileSync(path, 'utf8').split(/\r?\n/);
} catch {
continue;
}
const startIdx = lines.findIndex((l) => source.startMarker.test(l.trim()));
if (startIdx < 0) continue;
const endIdx = lines.findIndex((l, i) => i > startIdx && source.endMarker.test(l.trim()));
const slice = lines.slice(startIdx + 1, endIdx > 0 ? endIdx : lines.length);
const hasMarkdownHeaders = slice.some((l) => l.trim().startsWith('### '));
// Walk the slice looking for talent blocks.
let i = 0;
while (i < slice.length) {
// Skip blanks and separators.
while (i < slice.length && (slice[i].trim() === '' || SEPARATOR.test(slice[i].trim()))) i++;
if (i >= slice.length) break;
const rawLine = slice[i].trim();
// Stop heuristic — bail out of obvious chapter changes.
if (/^Rozdział\b/.test(rawLine) || /^Aneks\b/.test(rawLine) || /^#{1,6}\s+(?:Rozdział|Aneks)\b/i.test(rawLine)) break;
let isName = false;
let nameLine = '';
if (hasMarkdownHeaders) {
if (rawLine.startsWith('### ')) {
isName = true;
nameLine = rawLine.replace(/^###\s+/, '').trim();
}
} else {
nameLine = rawLine.replace(/^#{1,6}\s+/, '').trim();
if (isPlausibleName(nameLine)) {
isName = true;
}
}
if (!isName) {
// Can't read this — skip the line and continue.
i++;
continue;
}
i++;
// Optional requirements line.
let requirements = '';
if (i < slice.length) {
const m = slice[i].trim().match(REQUIREMENTS_PREFIX);
if (m) {
requirements = m[1].trim();
i++;
}
}
// Description until next plausible name OR separator OR chapter.
const descLines: string[] = [];
while (i < slice.length) {
const cur = slice[i].trim();
if (SEPARATOR.test(cur)) {
i++; // consume separator and end this talent
break;
}
if (/^Rozdział\b/.test(cur) || /^Aneks\b/.test(cur) || /^#{1,6}\s+(?:Rozdział|Aneks)\b/i.test(cur)) break;
if (hasMarkdownHeaders) {
if (cur.startsWith('### ')) {
break;
}
} else {
// If we've already captured at least one description line and the
// current line looks like a talent header (plausible name; next line
// is Wymagania, blank, or another short line), treat as next talent.
const cleanCur = cur.replace(/^#{1,6}\s+/, '').trim();
if (descLines.length > 0 && cur && isPlausibleName(cleanCur)) {
const next = i + 1 < slice.length ? slice[i + 1].trim() : '';
if (REQUIREMENTS_PREFIX.test(next) || next === '' || isProseOrReq(next)) {
break;
}
}
}
descLines.push(slice[i]);
i++;
}
let finalName = nameLine;
if (nameLine === 'Czuły Zmysł') {
finalName = 'Czuły Zmysł (Zmysł)';
}
const baseSlug = slugify(finalName);
const id = ctx.idOverrides[baseSlug] ?? baseSlug;
const description = descLines.join('\n').trim();
// Detect "(Dziedzina)" / "(Bóstwo)" / similar parameterised talents.
const multiSelect = /\((Dziedzina|Bóstwo|Zmysł|Atrybut|Umiejętność)\)$/i.test(finalName);
docs.push({
id,
name: finalName,
documentType: 'Item',
subType: 'talent',
pack: source.pack,
source: { book: source.book, chapter: 'Talenty', line: startIdx + 1 },
system: {
requirements: parseRequirements(requirements, ctx),
multiSelect,
cost: '',
damageReductionBonus: 0,
description,
effect: '',
},
description,
flags: source.sourceModule ? { sourceModule: source.sourceModule } : undefined,
});
}
}
return docs;
};
/** A talent name is short, doesn't end with `.`, no `:`, not a bullet, no digits-only. */
function isPlausibleName(line: string): boolean {
if (!line || line.length > 80) return false;
if (line.endsWith('.') || line.endsWith(',')) return false;
if (line.includes(':')) return false;
if (/^[*\-•]/.test(line)) return false;
if (/^\d+$/.test(line)) return false;
if (/^Wymagania\b/i.test(line)) return false;
// Must have at least one capital letter (talent names are Title Case).
if (!/[A-ZŻŹĆĄŚĘŁÓŃ]/.test(line)) return false;
return true;
}
function isProseOrReq(line: string): boolean {
if (!line) return false;
if (/^Wymagania\b/i.test(line)) return true;
// Proseish: long, ends with punctuation, contains lowercase mid-sentence words.
return line.length > 60 || /[.!?]$/.test(line);
}
function parseRequirements(text: string, _ctx: ParserContext): Record<string, unknown> {
// Keep raw + crude extraction. Detailed parsing can come later.
if (!text) {
return { race: '', attribute: '', skill: '', talent: '', title: '', discipline: '' };
}
return {
race: extractParen(text, /Rasa\s*\(([^)]+)\)/i),
attribute: '',
skill: '',
talent: '',
title: extractParen(text, /Tytuł\s*\(([^)]+)\)/i),
discipline: extractParen(text, /Dziedzina\s+Magii\s*\(([^)]+)\)/i),
raw: text,
};
}
function extractParen(text: string, re: RegExp): string {
const m = text.match(re);
return m ? m[1].trim() : '';
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Shared parser types — what each parser emits before being handed off
* to the pack builder.
*
* Every parsed document has a stable id (slug) and a sourceBook ref so
* we can deduplicate, lint, and re-link references later.
*/
export type SourceBookId =
| 'podrecznik-gry'
| 'ksiega-magii'
| 'bestiariusz'
| 'przewodnik'
| 'arcanum-sanguinis'
| 'crimson-cult'
| 'klatwa-otchlani'
| 'zlote-stal-magia';
export interface ParsedDoc {
/** Compendium-stable slug (kebab-case). */
id: string;
/** Display name (Polish, as in book). */
name: string;
/** Foundry document type — `Item` or `Actor`. */
documentType: 'Item' | 'Actor';
/** Foundry sub-type for the data model (e.g. `spell`, `talent`). */
subType: string;
/** Pack id this doc belongs to (e.g. `spells-general`). */
pack: string;
/** Source book + chapter for traceability. */
source: { book: SourceBookId; chapter?: string; line?: number };
/** The `system.*` payload that will be written into the pack. */
system: Record<string, unknown>;
/** Optional rich-text description (markdown → HTML). */
description?: string;
/** Optional extra flags merged into `flags['hbm-rpg-v3']`. */
flags?: Record<string, unknown>;
/** Non-fatal parser warnings (missing fields, ambiguous values, etc.). */
warnings?: string[];
}
export interface ParserContext {
/** Absolute path to repository root (so parsers can resolve `_books/`). */
repoRoot: string;
/** Strict mode aborts on first warning; default `false`. */
strict?: boolean;
/** ID overrides loaded from `_id-overrides.json`. */
idOverrides: Record<string, string>;
/** Label → constant mapping (Polish UI label → English internal id). */
labelMappings: {
schools: Record<string, string>;
disciplines: Record<string, string>;
deities: Record<string, string>;
symbols: Record<string, string>;
attributes: Record<string, string>;
skills: Record<string, string>;
};
}
export type ParserFn = (ctx: ParserContext) => Promise<ParsedDoc[]>;
+83
View File
@@ -0,0 +1,83 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// 1. Resolve paths and load package manifest
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
let id = '';
let version = '';
if (existsSync(resolve(root, 'module.json'))) {
const moduleJson = JSON.parse(readFileSync(resolve(root, 'module.json'), 'utf8'));
id = moduleJson.id;
version = moduleJson.version;
} else if (existsSync(resolve(root, 'system.json'))) {
const systemJson = JSON.parse(readFileSync(resolve(root, 'system.json'), 'utf8'));
id = systemJson.id;
version = systemJson.version;
} else if (existsSync(resolve(root, 'dist', 'system.json'))) {
const systemJson = JSON.parse(readFileSync(resolve(root, 'dist', 'system.json'), 'utf8'));
id = systemJson.id;
version = systemJson.version;
} else {
console.error("Error: Could not find system.json or module.json manifest!");
process.exit(1);
}
const zipPath = resolve(root, `${id}-v${version}.zip`);
if (!existsSync(zipPath)) {
console.error(`Error: Packaged zip file not found at ${zipPath}`);
console.error("Please run 'bun run package' first to generate the zip package.");
process.exit(1);
}
// 2. Determine ingest URL and API token from environment variables
const ingestUrl = process.env.FOUNDRY_INGEST_URL;
const apiToken = process.env.FOUNDRY_INGEST_TOKEN;
if (!ingestUrl) {
console.error("Error: FOUNDRY_INGEST_URL environment variable is not defined!");
console.error("Please define FOUNDRY_INGEST_URL in your .env file.");
process.exit(1);
}
if (!apiToken) {
console.error("Error: FOUNDRY_INGEST_TOKEN environment variable is not defined!");
console.error("Please define FOUNDRY_INGEST_TOKEN in your .env file.");
process.exit(1);
}
console.log(`Pushing package "${id}" version ${version}...`);
console.log(`Target URL: ${ingestUrl}`);
// 3. Build multipart form data and perform upload
const formData = new FormData();
const fileBlob = Bun.file(zipPath);
formData.append('file', fileBlob, `${id}-v${version}.zip`);
try {
const response = await fetch(ingestUrl, {
method: 'POST',
headers: {
'X-API-TOKEN': apiToken
},
body: formData
});
if (response.ok) {
const data = await response.json();
console.log("✓ Package successfully pushed and ingested!");
console.log(JSON.stringify(data, null, 2));
} else {
console.error(`Error: Ingest server returned status ${response.status} ${response.statusText}`);
const text = await response.text();
console.error(`Response details: ${text}`);
process.exit(1);
}
} catch (error) {
console.error("Error connecting to ingest server:", error);
process.exit(1);
}
+112
View File
@@ -0,0 +1,112 @@
import { Window } from 'happy-dom';
import * as fs from 'fs';
import Handlebars from 'handlebars';
const window = new Window();
// Mock Handlebars helpers used in templates
Handlebars.registerHelper('eq', (a: any, b: any) => a === b);
Handlebars.registerHelper('gt', (a: number, b: number) => a > b);
Handlebars.registerHelper('lt', (a: number, b: number) => a < b);
Handlebars.registerHelper('or', (...args: any[]) => (args.slice(0, -1) as any[]).some(Boolean));
Handlebars.registerHelper('and', (...args: any[]) => (args.slice(0, -1) as any[]).every(Boolean));
Handlebars.registerHelper('concat', (...args: any[]) => (args.slice(0, -1) as string[]).join(''));
Handlebars.registerHelper('localize', (key: string) => `[localized:${key}]`);
// Register the actor effects partial as an empty template for testing
Handlebars.registerPartial('systems/hbm-rpg-v3/templates/actor/_actor-effects.hbs', '<fieldset class="mock-effects"></fieldset>');
function testRender(filePath: string) {
console.log(`\n--- Rendering Handlebars template: ${filePath} ---`);
const templateSource = fs.readFileSync(filePath, 'utf8');
const template = Handlebars.compile(templateSource);
// Mock context simulating what CharacterSheet / NpcSheet returns
const context = {
document: { name: 'Test Character', img: 'icons/svg/mystery-man.svg' },
system: {
details: { year: 1, race: 'czlowiek', discipline: 'alchemyTransmutation', customEquipment: 'Test gear' },
attributes: {
body: { value: 3 },
mind: { value: 3 },
soul: { value: 3 },
magic: { value: 1 },
health: { value: 10, max: 10 },
mana: { value: 10, max: 10, maxPerSpell: 3 },
zeal: { value: 0, max: 5 },
insanity: 0,
magicalArmor: { value: 0, max: 0, runicCounter: 0 },
magicalShield: { value: 0 },
physicalArmor: { value: 0, max: 5 },
initiative: 3,
},
skills: {
athletics: { value: 1 },
melee: { value: 2 },
},
combat: {
attacks: [
{ name: 'Miecz', bonus: 2, damage: '2d6', description: 'Zwykły miecz' }
],
specialAbilities: [],
reactions: [],
legendaryActions: []
},
lore: { description: 'Test lore description' }
},
races: [{ key: 'czlowiek', label: 'Człowiek' }],
disciplines: [{ key: 'alchemyTransmutation', label: 'Transmutacja' }],
attributes: [
{ key: 'body', value: 3, label: 'Budowa' },
{ key: 'mind', value: 3, label: 'Umysł' },
{ key: 'soul', value: 3, label: 'Dusza' },
{ key: 'magic', value: 1, label: 'Magia' }
],
skills: [
{ key: 'athletics', value: 1, label: 'Atletyka' },
{ key: 'melee', value: 2, label: 'Walka wręcz' }
],
tabGroups: { primary: 'stats' },
tabs: [
{ id: 'stats', label: 'Statystyki', active: true, cssClass: 'active' },
{ id: 'actions', label: 'Akcje', active: false, cssClass: '' },
{ id: 'skills', label: 'Umiejętności', active: false, cssClass: '' },
{ id: 'spells', label: 'Zaklęcia', active: false, cssClass: '' },
{ id: 'talents', label: 'Talenty', active: false, cssClass: '' },
{ id: 'inventory', label: 'Ekwipunek', active: false, cssClass: '' },
{ id: 'effects', label: 'Efekty', active: false, cssClass: '' },
{ id: 'biography', label: 'Biografia', active: false, cssClass: '' }
],
spells: [],
spellSchoolGroups: [],
hasBloodMagic: false,
elixirCap: 4,
gear: [],
abilities: [],
talents: [],
actorEffects: []
};
const renderedHtml = template(context);
const parser = new window.DOMParser();
const doc = parser.parseFromString(renderedHtml, 'text/html');
const body = doc.body;
const children = Array.from(body.childNodes).filter(node => {
// Filter out whitespace-only text nodes
if (node.nodeType === 3 && !node.textContent.trim()) return false;
return true;
});
console.log(`Rendered children count: ${children.length}`);
children.forEach((node, i) => {
console.log(`Child ${i}: type=${node.nodeType} (${node.nodeName}), textLength=${node.textContent.trim().length}`);
if (children.length > 1) {
console.log(`OuterHTML snippet:`, (node as any).outerHTML || node.textContent.slice(0, 100));
}
});
}
testRender('./templates/actor/character.hbs');
testRender('./templates/actor/npc.hbs');
+37
View File
@@ -0,0 +1,37 @@
import { Window } from 'happy-dom';
import * as fs from 'fs';
const window = new Window();
function testFile(filePath: string) {
console.log(`\n--- Testing file: ${filePath} ---`);
let content = fs.readFileSync(filePath, 'utf8');
// Basic Handlebars comments and blocks stripping to leave clean HTML
content = content.replace(/\{\{!--[\s\S]*?--\}\}/g, '');
content = content.replace(/\{\{[\s\S]*?\}\}/g, '');
const parser = new window.DOMParser();
const doc = parser.parseFromString(content, 'text/html');
const body = doc.body;
const children = Array.from(body.childNodes).filter(node => {
// Mimic the likely behavior of Foundry: filter out empty text nodes
if (node.nodeType === 3 && !node.textContent.trim()) return false;
return true;
});
console.log(`Parsed children count: ${children.length}`);
children.forEach((node, i) => {
console.log(`Child ${i}: type=${node.nodeType} (${node.nodeName}), textLength=${node.textContent.trim().length}`);
if (children.length > 1) {
console.log(`OuterHTML snippet:`, (node as any).outerHTML || node.textContent.slice(0, 100));
}
});
}
const characterPath = './templates/actor/character.hbs';
const npcPath = './templates/actor/npc.hbs';
testFile(characterPath);
testFile(npcPath);