Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
@@ -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!');
|
||||
@@ -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);
|
||||
@@ -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).`);
|
||||
@@ -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 }) });
|
||||
@@ -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() });
|
||||
@@ -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.`);
|
||||
@@ -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})` });
|
||||
@@ -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}.`);
|
||||
@@ -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.`);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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() : '';
|
||||
}
|
||||
@@ -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[]>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user