Initial commit

This commit is contained in:
Octoturge
2026-06-09 22:06:56 +02:00
commit fb42c6e8cc
121 changed files with 14976 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
{
"_comment": "Override auto-generated slugs to use English IDs internally for database entries and filenames.",
"aniol": "angel",
"czlowiek": "human",
"magiczny-pocisk": "magic-missile",
"magiczna-tarcza": "magic-shield",
"poblask": "gleam",
"telekineza": "telekinesis",
"przeskok": "blink",
"teleportacja": "teleport",
"przebicie-eteru": "ether-pierce",
"fala-energii": "energy-wave",
"przeblysk-prawdy": "truth-glimpse",
"szybka-mysl": "quick-thought",
"latanie": "flight",
"powietrzna-fala": "air-wave",
"wyczucie-zagrozenia": "danger-sense",
"celnosc": "accuracy",
"szybki-jak-wiatr": "wind-swift",
"tworzenie-i-kontrolowanie-wody": "water-create",
"lodowa-podloga": "ice-floor",
"ukojenie": "solace",
"gradobicie": "hailstorm",
"mistyczna-mgla": "mystic-fog",
"podpalenie": "ignition",
"wzniecanie-ognia": "kindle",
"ognista-powloka": "fire-cloak",
"kula-ognia": "fireball",
"gorejaca-skora": "burning-skin",
"skalista-tarcza": "stone-shield",
"ruchome-piaski": "quicksand",
"trzesienie-ziemi": "earthquake",
"zwirowa-zbroja": "gravel-armor",
"skalista-pulapka": "stone-trap",
"eteryczny-przewodnik": "ether-guide",
"labirynt-umyslu": "mind-labyrinth",
"madrosc-salomona": "wisdom-of-solomon",
"magiczna-odpornosc": "magic-resistance",
"magiczne-kajdany": "magic-shackles",
"magnetyzm": "magnetism",
"negacja": "negation",
"niewidzialnosc": "invisibility",
"ozywienie-szkieletu": "animate-skeleton",
"przemiana-w-olow": "lead-transmutation",
"rozmawianie-ze-zmarlymi": "speak-with-dead",
"rozsypanie": "shatter",
"uzdrowienie-chorych": "heal-sick",
"woal-snow": "dream-veil",
"wymagania-rasa-czlowiek": "requirements-race-human",
"zloto-glupcow": "fools-gold",
"zwierciadlany-wizerunek": "mirror-image",
"blogoslawienstwo-zasiewow": "sowing-blessing",
"blyskawice-lancuchowe": "chain-lightning",
"boska-celnosc": "divine-accuracy",
"boska-sprawiedliwosc": "divine-justice",
"boska-wlocznia": "divine-spear",
"chmara-insektow": "insect-swarm",
"cykl-por-roku": "seasons-cycle",
"egida": "aegis",
"gniew-matki-ziemi": "wrath-of-mother-earth",
"grom-z-nieba": "thunderbolt",
"klatwa-hekate": "curse-of-hekate",
"kradziez-many": "mana-drain",
"leczacy-dotyk": "healing-touch",
"magiczne-przyspieszenie": "magic-haste",
"moc-ziemi": "earth-power",
"modlitwa-o-opieke": "prayer-for-protection",
"obecnosc-krola-bogow": "presence-of-the-king-of-gods",
"piekno-bogini": "beauty-of-the-goddess",
"plomien-namietnosci": "flame-of-passion",
"pomost": "bridge",
"pozadanie-zmyslow": "senses-desire",
"promien-harmonii": "ray-of-harmony",
"przebudzenie": "awakening",
"przyspieszone-zniwa": "accelerated-harvest",
"sad-ostateczny": "last-judgment",
"strzala-kupidyna": "cupids-arrow",
"strzala-pozadania": "arrow-of-desire",
"swieta-stal": "holy-steel",
"tajemne-poznanie": "arcane-cognition",
"tesknota-serca": "heart-longing",
"urok-niewinnosci": "charm-of-innocence",
"uswiecenie": "sanctification",
"wiez-serc": "bond-of-hearts",
"wyladowanie-elektrostatyczne": "electrostatic-discharge",
"wymagania-rasa-aniol": "requirements-race-angel",
"wzmocnienie-magii": "magic-empowerment",
"neutralizacja": "neutralization",
"pierwotna-kula": "primal-orb",
"tajemniczy-strumien": "mysterious-stream",
"zakazana-oslona": "forbidden-shield",
"klatwa-szkarlatu": "crimson-curse",
"szkarlatna-sprawiedliwosc": "crimson-justice",
"szkarlatne-plomienie": "crimson-flames",
"szkarlatny-pocisk": "crimson-bolt",
"szkarlatny-sztylet": "crimson-dagger",
"superzaklecie-requiem": "superspell-requiem",
"arcymag-dziedzina": "archmage",
"blogoslawienstwo-bostwo": "deity-blessing",
"blogoslawione-pochodzenie": "blessed-origin",
"cios-w-plecy": "backstab",
"czuly-zmysl": "keen-sense",
"czuly-zmysl-zmysl": "keen-sense",
"darmowy-talent": "free-talent",
"dyplomata": "diplomat",
"finta": "feint",
"gadanina": "babble",
"gotowosc-do-walki": "battle-readiness",
"jednosc-z-magia": "unity-with-magic",
"jednosc-z-natura": "unity-with-nature",
"magiczna-dyscyplina": "magic-discipline",
"magiczny-zmysl-zmysl": "magical-sense",
"mistrz-magii-dziedzina": "master-of-magic",
"niezniszczalne-zaklecia": "indestructible-spells",
"obureczny": "ambidextrous",
"odpornosc-na-magie": "magic-resistance",
"odpornosc-na-trucizny": "poison-resistance",
"oportunista": "opportunist",
"pancerz-wiary": "armor-of-faith",
"parowanie": "parry",
"powietrzne-uniki": "air-evasion",
"riposta": "riposte",
"rozplatanie-zaklec": "spell-shattering",
"sen-na-jawie": "daydream",
"technologia-jest-wsrod-nas": "technology-is-among-us",
"urok-osobisty": "personal-charm",
"zakazana-wiedza-dziedzina": "forbidden-knowledge",
"zawisanie-w-powietrzu": "levitation",
"medyk-polowy": "field-medic",
"olimpijczyk": "olympian",
"czlowiek-z-zelaza": "man-of-iron",
"pasjonat-zwierzat": "animal-enthusiast",
"jestes-magiem-krwi-i-rozumiesz-jak-cenne-jest-zycie": "blood-mage-precious-life",
"oltarz-krwi": "blood-altar",
"szacunek-do-zycia": "respect-for-life",
"dar-otchlani": "gift-of-the-abyss",
"magowie-aspektow-moga-sie-ukrywac-jako-magowie-swojej-pierwotnej-dziedziny": "aspect-mages-disguise",
"metamagia": "metamagic",
"swobodny-przeplyw-magii": "free-flow-of-magic",
"gear-bicz": "whip",
"gear-bron-dwureczna": "two-handed-weapon",
"gear-bron-jednoreczna-lub-poltorareczna": "one-handed-or-bastard-weapon",
"gear-laska-quarterstaff": "quarterstaff",
"gear-luk-bloczkowy": "compound-bow",
"gear-luk-elfi": "elven-bow",
"gear-luk-klasyczny": "classic-bow",
"gear-sztylet": "dagger",
"gear-wlocznia": "spear",
"gear-pancerz-ciezki": "heavy-armor",
"gear-pancerz-lekki": "light-armor",
"gear-pancerz-sredni": "medium-armor",
"gear-tarcza": "shield",
"gear-artefakt-przechowujacy-mane": "mana-storage-artifact",
"gear-artefakt-skupiajacy-magie": "magic-focus-artifact",
"gear-karty-do-gry": "playing-cards",
"gear-kociolek": "cauldron",
"gear-miecz-swietlny": "lightsaber",
"gear-miecz-treningowy": "training-sword",
"gear-przedmioty-do-wrozenia": "divination-tools",
"gear-przybory-do-golemancji": "golemancy-tools",
"gear-przybory-do-tworzenia-artefatow": "artifact-crafting-tools",
"gear-shinai": "shinai",
"gear-szklana-fiolka": "glass-vial",
"gear-tablica-ouija": "ouija-board",
"gear-teleskop": "telescope",
"gear-zestaw-do-rytualow": "ritual-kit",
"ostrze-dawida": "blade-of-david",
"fanatyczne-poswiecenie": "fanatical-sacrifice",
"wykrycie-zmian": "detect-changes",
"destrukcja": "destruction",
"prawdziwa-dezintegracja": "true-disintegration",
"zaglada": "doom",
"magiczna-plaga": "magic-plague",
"aegis": "aegis",
"transmutacja-krajobrazu": "landscape-transmutation",
"bramy-wymiarow": "dimension-gates",
"reanimacja": "reanimation",
"plomienna-strzala": "flaming-arrow",
"tarcza-eteryczna": "ethereal-shield",
"echo-zmierzchu": "twilight-echo",
"requiem": "requiem",
"przywolanie-istoty-z-otchlani": "summon-eldritch-entity",
"nic-przeznaczenia": "thread-of-destiny"
}
+129
View File
@@ -0,0 +1,129 @@
{
"_comment": "Polish (book) label → English internal id used in constants.ts. Lowercased for case-insensitive lookup.",
"schools": {
"magia ogólna": "general",
"magia żywiołów": "elements",
"magia żywiołów — powietrze": "elementsAir",
"magia żywiołów (powietrze)": "elementsAir",
"magia powietrza": "elementsAir",
"magia żywiołów — woda": "elementsWater",
"magia żywiołów (woda)": "elementsWater",
"magia wody": "elementsWater",
"magia żywiołów — ogień": "elementsFire",
"magia żywiołów (ogień)": "elementsFire",
"magia ognia": "elementsFire",
"magia żywiołów — ziemia": "elementsEarth",
"magia żywiołów (ziemia)": "elementsEarth",
"magia ziemi": "elementsEarth",
"magia sakralna": "sacred",
"magia sakralna — egzorcyzmy": "sacredExorcism",
"magia sakralna - egzorcyzmy": "sacredExorcism",
"egzorcyzmy": "sacredExorcism",
"wiedźmia magia": "witch",
"magia iluzji": "illusion",
"alchemia transmutacji": "alchemyTransmutation",
"magia alchemii transmutacji": "alchemyTransmutation",
"alchemia - transmutacja": "alchemyTransmutation",
"warzenie eliksirów": "alchemyBrewing",
"alchemia - warzenie eliksirów": "alchemyBrewing",
"rzemiosło artefaktów": "artifice",
"golemancja": "golemancy",
"magia runiczna": "runes",
"źródła mocy": "manaSourceMage",
"źródło mocy": "manaSourceMage",
"botanika": "botany",
"nekromancja": "necromancy",
"magia krwi": "blood",
"magia szkarłatu": "crimson",
"magia otchłani — magia aspektów": "eldritchAspects",
"magia otchłani — pierwotna magia": "eldritchPrimal",
"dzika wiedźmia magia": "wildWitch"
},
"disciplines": {
"alchemia transmutacji": "alchemyTransmutation",
"magia alchemii transmutacji": "alchemyTransmutation",
"alchemia - transmutacja": "alchemyTransmutation",
"warzenie eliksirów": "alchemyBrewing",
"alchemia - warzenie eliksirów": "alchemyBrewing",
"botanika": "botany",
"magia żywiołów — powietrze": "elementsAir",
"magia żywiołów (powietrze)": "elementsAir",
"magia żywiołów — woda": "elementsWater",
"magia żywiołów (woda)": "elementsWater",
"magia żywiołów — ogień": "elementsFire",
"magia żywiołów (ogień)": "elementsFire",
"magia żywiołów — ziemia": "elementsEarth",
"magia żywiołów (ziemia)": "elementsEarth",
"rzemiosło artefaktów": "artifice",
"golemancja": "golemancy",
"magia runiczna": "runes",
"źródła mocy": "manaSourceMage",
"źródło mocy": "manaSourceMage",
"magia iluzji": "illusion",
"magia sakralna": "sacred",
"magia sakralna — egzorcyzmy": "sacredExorcism",
"magia sakralna - egzorcyzmy": "sacredExorcism",
"egzorcyzmy": "sacredExorcism",
"wiedźmia magia": "witch",
"nekromancja": "necromancy",
"magia krwi": "blood",
"magia szkarłatu": "crimson",
"magia otchłani": "eldritch",
"dzika wiedźmia magia": "wildWitch"
},
"deities": {
"modlitwy powszechne": "common",
"bóg-jahwe-jedyny": "jahwe",
"jahwe": "jahwe",
"zeus-jowisz": "zeus",
"zeus": "zeus",
"demeter-ceres": "demeter",
"demeter": "demeter",
"artemida-diana": "artemis",
"artemida": "artemis",
"hekate": "hekate",
"afrodyta-wenus": "aphrodite",
"afrodyta": "aphrodite",
"amor-eros": "eros",
"eros": "eros"
},
"symbols": {
"potentia": "Potentia",
"tutamen": "Tutamen",
"lux": "Lux",
"motus": "Motus",
"iter": "Iter",
"vacuos": "Vacuos",
"vitium": "Vitium",
"praecantatio": "Praecantatio",
"aer": "Aer",
"aqua": "Aqua",
"gelum": "Gelum",
"ignis": "Ignis",
"terra": "Terra",
"cognitio": "Cognitio",
"alienis": "Alienis",
"illusio": "Illusio",
"somnium": "Somnium",
"tenebrae": "Tenebrae",
"auram": "Auram",
"vinculum": "Vinculum",
"telum": "Telum",
"sensus": "Sensus",
"perditio": "Perditio",
"perfodio": "Perfodio",
"sano": "Sano",
"volatus": "Volatus",
"tempestas": "Tempestas"
},
"attributes": {
"ciało": "body",
"umysł": "mind",
"dusza": "soul",
"magia": "magic"
},
"skills": {
"zdolności magiczne": "magicalAbilities",
"oddanie": "devotion"
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* Lightweight markdown walker tuned to the HbM book layout.
*
* Supports two formats:
*
* 1. Legacy Google-Docs export (plain text, `_books/`):
* ## Chapter heading
* Section heading (plain text / bold)
* Item Name
* * Field: value
*
* 2. Native Obsidian/repo files (`rules/`, `disciplines/`, etc.):
* --- ← YAML frontmatter (skipped)
* ...
* ---
* ## Rozdział I - ... ← chapter (any heading level)
* ### Sub-section ← section / sub-section
* #### Spell Name ← item name
* - [[#Anchor|Label]] ← Obsidian TOC bullet (skipped)
* * Field: value
*
* The walker emits one `BookBlock` per item it can find. Section context
* is carried through so parsers know e.g. which discipline a spell lives
* under.
*/
import { readFileSync } from 'node:fs';
export interface BookBlock {
/** The line directly preceding the bullet block — usually the item name. */
name: string;
/** Bullet keys/values, preserved in order. */
fields: Array<{ key: string; value: string }>;
/** Free-form paragraph text following the bullet block (description). */
description: string;
/** Most recent chapter (any `#+ ...`) heading. */
chapter: string;
/** Most recent non-bullet, non-empty line above the item name (sub-section). */
section: string;
/** 1-based line number of the item name in the source file. */
line: number;
}
const SEPARATOR = /^_{3,}$/;
const BULLET = /^\*\s+([^:]+):\s*(.+)$/;
// Match any markdown heading level (1-6 `#` chars).
const HEADING = /^#{1,6}\s+(.+)$/;
// Chapter headings are the two highest heading levels we encounter — heuristic:
// treat ## headings as "chapter" and ### headings as "section".
const CHAPTER_HEADING = /^#{1,2}\s+(.+)$/;
const SECTION_HEADING = /^#{3,4}\s+(.+)$/;
// Obsidian TOC bullets: `- [[#Anchor|Label]]` or `* [[#Anchor|Label]]`
const OBSIDIAN_TOC_BULLET = /^[-*]\s+\[\[#/;
// Frontmatter fence
const FRONTMATTER_FENCE = /^---\s*$/;
/** Strip leading markdown heading markers and bold `**` from a raw line. */
function stripHeadingMarkers(line: string): string {
return line.replace(/^#{1,6}\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').trim();
}
/** Skip YAML frontmatter block at start of file. Returns the index of the first non-frontmatter line. */
function skipFrontmatter(lines: string[]): number {
if (lines.length === 0 || !FRONTMATTER_FENCE.test(lines[0].trim())) return 0;
for (let i = 1; i < lines.length; i++) {
if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1;
}
return 0; // malformed — start from 0
}
export interface WalkOptions {
/**
* Predicate that returns `true` when the line (after stripping heading markers)
* is a known section header (e.g. matches a discipline / school / deity label).
* Used by the walker to track the current section across separators and prose.
*/
isSectionHeader?: (line: string) => boolean;
}
export function walkBook(path: string, opts: WalkOptions = {}): BookBlock[] {
const text = readFileSync(path, 'utf8');
const lines = text.split(/\r?\n/);
const blocks: BookBlock[] = [];
let chapter = '';
let section = '';
const isSectionHeader = opts.isSectionHeader ?? (() => false);
const startIdx = skipFrontmatter(lines);
// Look ahead for bullet-block starts; track the line above as the name.
for (let i = startIdx; i < lines.length; i++) {
const rawLine = lines[i].trimEnd();
// Skip Obsidian TOC bullets — they look like `- [[#Section|Label]]`.
if (OBSIDIAN_TOC_BULLET.test(rawLine.trim())) continue;
// Chapter headings: ## or # level (the two highest we honour).
const chMatch = rawLine.match(CHAPTER_HEADING);
if (chMatch) {
chapter = stripHeadingMarkers(rawLine);
// Reset section only for top-level (##) headings.
if (rawLine.startsWith('## ') || rawLine.startsWith('# ')) section = '';
continue;
}
// Section sub-headings: ### or #### level become section markers.
const secMatch = rawLine.match(SECTION_HEADING);
if (secMatch) {
const bare = stripHeadingMarkers(rawLine);
const isL3 = rawLine.startsWith('### ');
if (isL3 || isSectionHeader(bare)) {
section = bare;
continue;
}
}
// Track plain-text section headers (legacy format).
const trimmed = rawLine.trim();
if (trimmed && !SEPARATOR.test(trimmed) && !BULLET.test(trimmed) && isSectionHeader(trimmed)) {
section = trimmed;
continue;
}
if (BULLET.test(rawLine)) {
// Walk up to find the name line (first non-empty, non-separator, non-heading line above).
let nameLine = '';
let nameIdx = i - 1;
while (nameIdx >= startIdx) {
const candidate = lines[nameIdx].trim();
// Skip empty lines, separators, TOC links, list items, and requirements lines
if (
candidate &&
!SEPARATOR.test(candidate) &&
!OBSIDIAN_TOC_BULLET.test(candidate) &&
!/^[*-]\s+/.test(candidate) &&
!/^(?:[*-]\s+)?Wymagania?:/i.test(candidate) &&
!/^>/.test(candidate)
) {
// Strip any heading markers; skip pure chapter/section headings as the name
// (they are already captured in `chapter`/`section`).
nameLine = stripHeadingMarkers(candidate);
break;
}
nameIdx--;
}
// Collect the bullet block.
const fields: Array<{ key: string; value: string }> = [];
let j = i;
while (j < lines.length) {
const m = lines[j].trimEnd().match(BULLET);
if (!m) break;
fields.push({ key: m[1].trim(), value: m[2].trim() });
j++;
}
// Description = following lines until next bullet block, separator, or heading.
const descLines: string[] = [];
let k = j;
while (k < lines.length) {
const cur = lines[k].trimEnd();
if (SEPARATOR.test(cur)) break;
if (HEADING.test(cur)) break;
if (BULLET.test(cur)) break;
if (OBSIDIAN_TOC_BULLET.test(cur.trim())) break;
descLines.push(cur);
k++;
}
blocks.push({
name: nameLine,
fields,
description: descLines.join('\n').trim(),
chapter,
section,
line: nameIdx + 1,
});
i = k - 1; // advance past description
}
}
return blocks;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Discipline parser. Disciplines are identified by the entries in
* `_label-mappings.json#disciplines`; we emit one stub Item per discipline
* with name, source book, and an empty description (filled in later).
*
* Many disciplines also have a "passive ability" mentioned right after the
* discipline header in Podręcznik Gry — we capture the next non-empty line
* if it looks like a short ability description.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { normalizeKey, slugify } from './helpers';
interface DisciplineSeed {
id: string;
polishName: string;
pack: string;
book: SourceBookId;
}
const DISCIPLINE_SEEDS: DisciplineSeed[] = [
{ id: 'alchemyTransmutation', polishName: 'Alchemia - Transmutacja', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'alchemyBrewing', polishName: 'Alchemia - Warzenie Eliksirów', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'botany', polishName: 'Botanika', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'elementsAir', polishName: 'Magia Żywiołów (Powietrze)', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'elementsWater', polishName: 'Magia Żywiołów (Woda)', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'elementsFire', polishName: 'Magia Żywiołów (Ogień)', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'elementsEarth', polishName: 'Magia Żywiołów (Ziemia)', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'artifice', polishName: 'Rzemiosło Artefaktów', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'golemancy', polishName: 'Golemancja', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'runes', polishName: 'Magia Runiczna', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'manaSourceMage', polishName: 'Źródło Mocy', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'illusion', polishName: 'Magia Iluzji', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'sacred', polishName: 'Magia Sakralna', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'sacredExorcism', polishName: 'Magia Sakralna — Egzorcyzmy', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'witch', polishName: 'Wiedźmia Magia', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'necromancy', polishName: 'Nekromancja', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'blood', polishName: 'Magia Krwi', pack: 'disciplines-forbidden', book: 'arcanum-sanguinis' },
{ id: 'crimson', polishName: 'Magia Szkarłatu', pack: 'disciplines-forbidden', book: 'crimson-cult' },
{ id: 'abyssAspects', polishName: 'Magia Otchłani — Magia Aspektów', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
{ id: 'abyssPrimal', polishName: 'Magia Otchłani — Pierwotna Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
{ id: 'wildWitch', polishName: 'Dzika Wiedźmia Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
];
const SUGGESTED_ATTRIBUTE: Record<string, string> = {
sacred: 'soul',
blood: 'soul',
crimson: 'soul',
};
const SUGGESTED_SKILLS: Record<string, string[]> = {
sacred: ['devotion'],
blood: ['magicalAbilities'],
};
/**
* Pull a one-paragraph discipline description out of Podręcznik Gry by
* matching the header line and grabbing the next non-empty paragraph.
*/
/** Strip markdown heading markers from a line. */
function stripHeadingMarkers(line: string): string {
return line.replace(/^#{1,6}\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').trim();
}
/** Skip YAML frontmatter (--- ... ---) at file start. */
function skipFrontmatter(lines: string[]): number {
if (lines.length === 0 || !/^---\s*$/.test(lines[0].trim())) return 0;
for (let i = 1; i < lines.length; i++) {
if (/^---\s*$/.test(lines[i].trim())) return i + 1;
}
return 0;
}
function lookupDescription(repoRoot: string, polishName: string): string {
// Try split-book sources first (disciplines from Księga Magii or Klątwa Otchłani
// now live in rules/01.* and rules/02.*).
const splitSources = [
resolve(repoRoot, 'ObsidianNotes/rules/01. Księga Magii.md'),
resolve(repoRoot, 'ObsidianNotes/rules/02. Klątwa Otchłani.md'),
resolve(repoRoot, 'ObsidianNotes/rules/00. Podręcznik Gry.md'),
];
const target = normalizeKey(polishName.split('—')[0]);
for (const file of splitSources) {
let text = '';
try {
text = readFileSync(file, 'utf8');
} catch {
continue;
}
const rawLines = text.split(/\r?\n/);
const start = skipFrontmatter(rawLines);
const lines = rawLines.slice(start);
for (let i = 0; i < lines.length; i++) {
const bare = normalizeKey(stripHeadingMarkers(lines[i].trim()));
if (bare === target) {
// Capture next paragraph (until blank line).
const buf: string[] = [];
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
const cur = lines[j].trim();
if (cur === '' && buf.length > 0) break;
if (cur && !/^_{3,}$/.test(cur) && !/^#{1,6}\s/.test(cur)) buf.push(cur);
}
if (buf.length > 0) return buf.join(' ');
}
}
}
return '';
}
export const parseDisciplines: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
for (const seed of DISCIPLINE_SEEDS) {
const baseSlug = ctx.idOverrides[slugify(seed.polishName)] ?? seed.id;
const description = lookupDescription(ctx.repoRoot, seed.polishName);
docs.push({
id: baseSlug,
name: seed.polishName,
documentType: 'Item',
subType: 'discipline',
pack: seed.pack,
source: { book: seed.book },
system: {
color: '',
suggestedAttribute: SUGGESTED_ATTRIBUTE[seed.id] ?? 'magic',
suggestedSkills: SUGGESTED_SKILLS[seed.id] ?? ['magicalAbilities'],
passiveAbility: '',
availableSpells: [],
description,
},
description,
});
}
return docs;
};
+161
View File
@@ -0,0 +1,161 @@
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { resolve, join, extname, basename } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn } from './types';
import { slugify, parseList } from './helpers';
export const parseGear: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
const itemsDir = resolve(ctx.repoRoot, 'ObsidianNotes', 'items');
if (!existsSync(itemsDir)) {
console.warn(`[gear-parser] Directory not found: ${itemsDir}`);
return docs;
}
const subdirs = [
{ name: 'weapons', pack: 'items-weapons', category: 'weapon' },
{ name: 'armor', pack: 'items-armor', category: 'armor' },
{ name: 'gear', pack: 'items-gear', category: 'equipment' },
];
for (const dir of subdirs) {
const fullDirPath = resolve(itemsDir, dir.name);
if (!existsSync(fullDirPath)) continue;
const files = readdirSync(fullDirPath).filter((f) => extname(f) === '.md');
for (const file of files) {
const filePath = join(fullDirPath, file);
const raw = readFileSync(filePath, 'utf8');
const { frontmatter, content } = extractMarkdown(raw);
const name = basename(file, '.md').replace(/_/g, ' ');
const doc = buildGearDoc(name, frontmatter, content, dir.category, dir.pack);
docs.push(doc);
}
}
return docs;
};
function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } {
const fm: Record<string, string> = {};
let content = raw;
if (raw.startsWith('---')) {
const endIdx = raw.indexOf('---', 3);
if (endIdx > -1) {
const fmText = raw.slice(3, endIdx).trim();
content = raw.slice(endIdx + 3).trim();
const lines = fmText.split('\n');
for (const line of lines) {
const colonIdx = line.indexOf(':');
if (colonIdx > -1) {
const key = line.slice(0, colonIdx).trim();
let val = line.slice(colonIdx + 1).trim();
if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
fm[key] = val;
}
}
}
}
return { frontmatter: fm, content };
}
function buildGearDoc(name: string, fm: Record<string, string>, content: string, category: string, pack: string): ParsedDoc {
// Common properties
const priceStr = fm.price || fm.cena || '0';
const priceMatch = priceStr.match(/\d+/);
const value = priceMatch ? parseInt(priceMatch[0], 10) : 0;
const description = content
.split('\n')
.filter(l => !l.startsWith('# ') && !l.startsWith('* **Cena:**') && !l.startsWith('* **Obrażenia:**') && !l.startsWith('* **Cechy:**') && !l.startsWith('* **Pancerz:**'))
.join('\n')
.trim();
// HTML format description by wrapping paragraphs if needed
const htmlDescription = description ? `<p>${description.replace(/\n\n/g, '</p><p>')}</p>` : '';
const system: any = {
category,
rarity: 'common',
quantity: 1,
weight: 0,
value,
equipped: false,
damageReductionBonus: 0,
description: htmlDescription,
weapon: {
damage: '',
damageType: 'physical',
properties: [],
},
armor: {
damageReduction: 0,
condition: 1,
conditionMax: 1,
armorType: 'light',
stealthDisadvantage: false,
strengthRequirement: 0,
}
};
if (category === 'weapon') {
system.weapon.damage = fm.damage || fm.obrazenia || '';
if (fm.traits || fm.cechy) {
system.weapon.properties = parseList(fm.traits || fm.cechy);
}
} else if (category === 'armor') {
const isShield = name.toLowerCase().includes('tarcza') || name.toLowerCase().includes('shield');
const drStr = fm.armorClass || fm.pancerz || fm.armor_points || (isShield ? '1' : '0');
const drMatch = drStr.match(/\d+/);
const dr = drMatch ? parseInt(drMatch[0], 10) : 0;
system.armor.damageReduction = dr;
const conditionStr = fm.wytrzymalosc || fm.condition || fm.durability;
let cond = dr;
if (conditionStr) {
const condMatch = conditionStr.match(/\d+/);
if (condMatch) {
cond = parseInt(condMatch[0], 10);
}
}
if (cond < 1) cond = 1;
system.armor.condition = cond;
system.armor.conditionMax = cond;
if (isShield) {
system.armor.armorType = 'shield';
} else if (dr === 3) {
system.armor.armorType = 'heavy';
} else if (dr === 2) {
system.armor.armorType = 'medium';
} else {
system.armor.armorType = 'light';
}
const traitsStr = fm.traits || fm.cechy || '';
if (traitsStr.toLowerCase().includes('hałaśliwy') || traitsStr.toLowerCase().includes('noisy')) {
system.armor.stealthDisadvantage = true;
}
const reqStr = fm.requirements || fm.wymagania || '';
const reqMatch = reqStr.match(/(?:ciało|body)\s*>=\s*(\d+)/i);
if (reqMatch) {
system.armor.strengthRequirement = parseInt(reqMatch[1], 10);
}
}
return {
id: slugify(`gear-${name}`),
name,
documentType: 'Item',
subType: 'gear',
pack,
source: { book: 'core-rules' },
system,
};
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Polish-aware slug helper. Lowercases, strips diacritics, replaces
* whitespace and punctuation with hyphens, collapses repeats.
*/
export function slugify(input: string): string {
return input
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/ł/g, 'l')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
}
/** Strip Polish diacritics for case-insensitive map lookups. */
export function normalizeKey(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
.normalize('NFC');
}
/** Parse "5:2" -> { threshold: 5, successes: 2 }. */
export function parseDifficulty(s: string): { threshold: number; successes: number } | null {
const m = s.trim().match(/^(\d+)\s*:\s*([\d\w]+)$/);
if (!m) return null;
const successes = isNaN(Number(m[2])) ? 1 : Number(m[2]);
return { threshold: Number(m[1]), successes };
}
/** Parse "1 punkt", "3 punkty", "5 punktów" → 1/3/5. Returns 0 on failure. */
export function parsePoints(s: string): number {
const m = s.trim().match(/^(\d+)/);
return m ? Number(m[1]) : 0;
}
/** Parse a comma- or whitespace-separated symbol list. */
export function parseList(s: string): string[] {
return s
.split(/[,;]+/)
.map((x) => x.trim())
.filter((x) => x.length > 0);
}
/**
* Parse a Polish range string into structure.
* "Na siebie" → { kind: 'self' }
* "Dotyk" → { kind: 'touch' }
* "15 m" → { kind: 'distance', value: 15, unit: 'm' }
* anything else → { kind: 'special', text }
*/
export function parseRange(s: string): { kind: string; value?: number; unit?: string; text?: string } {
const t = s.trim();
if (/^na siebie$/i.test(t)) return { kind: 'self' };
if (/^dotyk$/i.test(t)) return { kind: 'touch' };
const m = t.match(/^(\d+)\s*(m|km|cm)\b/i);
if (m) return { kind: 'distance', value: Number(m[1]), unit: m[2].toLowerCase() };
return { kind: 'special', text: t };
}
/**
* Parse area-of-effect text into structured shape.
* "3×8 m" → rectangle 3×8
* "promień 5 m" → sphere radius 5
* "stożek 10 m" → cone length 10
* "linia 20 m" → line length 20
*/
export function parseAreaOfEffect(s: string): { shape: string; x?: number; y?: number; unit?: string; text?: string } | null {
const t = s.trim();
let m = t.match(/(\d+)\s*[x×]\s*(\d+)\s*(m|km|cm)?/i);
if (m) return { shape: 'rectangle', x: Number(m[1]), y: Number(m[2]), unit: (m[3] ?? 'm').toLowerCase() };
m = t.match(/promień\s+(\d+)\s*(m|km|cm)?/i);
if (m) return { shape: 'sphere', x: Number(m[1]), unit: (m[2] ?? 'm').toLowerCase() };
m = t.match(/stożek\s+(\d+)\s*(m|km|cm)?/i);
if (m) return { shape: 'cone', x: Number(m[1]), unit: (m[2] ?? 'm').toLowerCase() };
m = t.match(/linia\s+(\d+)\s*(m|km|cm)?/i);
if (m) return { shape: 'line', x: Number(m[1]), unit: (m[2] ?? 'm').toLowerCase() };
return null;
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Macro parser. Reads hand-coded macro source files from `scripts/macros/*.js`
* and emits ParsedDoc[] for the build pipeline. Macros are not parsed from
* books — they're a curated set of GM/player utilities.
*/
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ParsedDoc, ParserContext, ParserFn } from './types';
const __dirname = dirname(fileURLToPath(import.meta.url));
const macrosDir = resolve(__dirname, '..', 'macros');
interface MacroDef {
id: string;
name: string;
file: string;
img: string;
}
const MACROS: MacroDef[] = [
{ id: 'pool-roll', name: 'Rzut Puli d6', file: 'pool-roll.js', img: 'icons/svg/d20.svg' },
{ id: 'attribute-check', name: 'Test Atrybutu', file: 'attribute-check.js', img: 'icons/svg/dice-target.svg' },
{ id: 'apply-condition', name: 'Nałóż Przypadłość', file: 'apply-condition.js', img: 'icons/svg/aura.svg' },
{ id: 'short-rest', name: 'Krótki Odpoczynek', file: 'short-rest.js', img: 'icons/svg/regen.svg' },
{ id: 'long-rest', name: 'Długi Odpoczynek', file: 'long-rest.js', img: 'icons/svg/sun.svg' },
{ id: 'group-cast-helper', name: 'Pomocnik Rzucania Grupowego', file: 'group-cast-helper.js', img: 'icons/svg/upgrade.svg' },
{ id: 'refill-zeal', name: 'Doładuj Zapał', file: 'refill-zeal.js', img: 'icons/svg/lightning.svg' },
];
export const parseMacros: ParserFn = async (_ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
for (const m of MACROS) {
let command = '';
try {
command = readFileSync(resolve(macrosDir, m.file), 'utf8');
} catch (err) {
console.warn(`[macro-parser] could not read ${m.file}: ${(err as Error).message}`);
continue;
}
docs.push({
id: m.id,
name: m.name,
documentType: 'Item',
subType: 'macro',
pack: 'hbm-macros',
source: { book: 'podrecznik-gry' },
system: {
type: 'script',
scope: 'global',
command,
img: m.img,
},
});
}
return docs;
};
+180
View File
@@ -0,0 +1,180 @@
/**
* Race parser. Races in HbM live in Podręcznik Gry, Rozdział II.
*
* Format:
* Race Name (optional /Variant)
* <flavor paragraphs>
*
* Cechy Rasowe:
* * Dostępne Dziedziny Magii:
* * <discipline 1>
* * <discipline 2>
* * N Punktów Atrybutów
* * Darmowe Talenty:
* * <talent 1>
* * <talent 2>
* * 1 Punkt Umiejętności w <skill>
* * N Punktów Umiejętności
* ________________
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn } from './types';
import { normalizeKey, slugify } from './helpers';
const SEPARATOR = /^_{3,}$/;
const RACE_FEATURES_HEADER = /^Cechy Rasowe:\s*$/;
const SOURCE_FILE = '_books/HbM RPG v3 - Podręcznik Gry.md';
const KNOWN_RACES = new Set([
'człowiek',
'elf',
'feles (kotowate)',
'feles',
'lamia (naga)',
'lamia',
'anioł/anielica',
'anioł',
'krasnolud',
'demon',
'malferianin',
]);
export const parseRaces: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
const path = resolve(ctx.repoRoot, SOURCE_FILE);
let lines: string[] = [];
try {
lines = readFileSync(path, 'utf8').split(/\r?\n/);
} catch {
return docs;
}
// Find each `Cechy Rasowe:` block; the race name is the most recent
// non-empty, non-separator line above the block that matches a known race.
for (let i = 0; i < lines.length; i++) {
if (!RACE_FEATURES_HEADER.test(lines[i].trim())) continue;
// Walk up looking for the race name + flavor paragraphs.
let nameLine = '';
let nameIdx = i - 1;
while (nameIdx >= 0) {
const cur = lines[nameIdx].trim();
if (cur && !SEPARATOR.test(cur)) {
// The name is the FIRST line above that matches KNOWN_RACES; flavor lines come between.
if (KNOWN_RACES.has(normalizeKey(cur))) {
nameLine = cur;
break;
}
}
nameIdx--;
}
if (!nameLine) continue;
// Flavor description = lines between race name and `Cechy Rasowe:`.
const flavorLines: string[] = [];
for (let j = nameIdx + 1; j < i; j++) {
const cur = lines[j].trimEnd();
if (cur === '' || SEPARATOR.test(cur.trim())) continue;
flavorLines.push(cur);
}
const physicalDescription = flavorLines.join('\n').trim();
// Walk down through the bullet block until next separator/blank-paragraph.
const bulletLines: string[] = [];
let k = i + 1;
while (k < lines.length) {
const cur = lines[k].trimEnd();
if (SEPARATOR.test(cur.trim())) break;
bulletLines.push(cur);
k++;
}
const parsed = parseRaceFeatures(bulletLines);
const baseSlug = slugify(stripVariant(nameLine));
const id = ctx.idOverrides[baseSlug] ?? baseSlug;
docs.push({
id,
name: stripVariant(nameLine),
documentType: 'Item',
subType: 'race',
pack: 'races',
source: { book: 'podrecznik-gry', chapter: 'Tworzenie Postaci', line: nameIdx + 1 },
system: {
availableDisciplines: parsed.disciplines,
attributePoints: parsed.attributePoints,
skillPoints: parsed.skillPoints,
freeTalents: parsed.freeTalents,
racialAbilities: [],
physicalDescription,
description: physicalDescription,
},
description: physicalDescription,
});
}
return docs;
};
function stripVariant(name: string): string {
// "Anioł/Anielica" → "Anioł"; "Feles (Kotowate)" → "Feles"
return name.split('/')[0].split('(')[0].trim();
}
interface ParsedRaceFeatures {
disciplines: string[];
attributePoints: number;
skillPoints: number;
freeTalents: string[];
}
function parseRaceFeatures(lines: string[]): ParsedRaceFeatures {
const result: ParsedRaceFeatures = {
disciplines: [],
attributePoints: 0,
skillPoints: 0,
freeTalents: [],
};
// Detect "* <Header>:" then collect indented child bullets that follow.
let mode: 'none' | 'disciplines' | 'talents' = 'none';
for (const raw of lines) {
const line = raw.trimEnd();
if (line === '') continue;
// Top-level bullet: `* xxx`
const top = line.match(/^\*\s+(.+)$/);
if (top) {
const text = top[1].trim();
if (/^Dostępne Dziedziny Magii:/i.test(text)) {
mode = 'disciplines';
continue;
}
if (/^Darmowe Talenty:/i.test(text)) {
mode = 'talents';
continue;
}
mode = 'none';
// Standalone counters.
const attr = text.match(/^(\d+)\s+(?:Punkt|Punkty|Punktów)\s+Atrybutów/i);
if (attr) result.attributePoints = Number(attr[1]);
const skill = text.match(/^(\d+)\s+(?:Punkt|Punkty|Punktów)\s+Umiejętności(?:\s+do\s+rozdania)?/i);
if (skill && !/Umiejętności\s+w\s+/i.test(text)) result.skillPoints = Number(skill[1]);
continue;
}
// Indented child bullet ` * xxx` (Google Docs export uses tabs/spaces).
const child = line.match(/^\s+\*\s+(.+)$/);
if (!child) continue;
const text = child[1].trim();
if (mode === 'disciplines') result.disciplines.push(text);
else if (mode === 'talents') result.freeTalents.push(text);
}
return result;
}
+138
View File
@@ -0,0 +1,138 @@
import { resolve, basename } from 'node:path';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { z } from 'zod';
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { slugify } from './helpers';
import yaml from 'js-yaml';
/** Looser zod schema — mirrors `SpellData` defaults; build pipeline converts to system payload. */
const SpellSystemSchema = z.object({
castingMode: z.enum(['standard', 'sacred', 'witch', 'blood']),
school: z.string(),
deity: z.string().default(''),
sourceBook: z.string(),
manaCost: z.number().int().min(0),
bloodCost: z.number().int().min(0).default(0),
complexityLevel: z.number().int().min(0).default(0),
isSuperspell: z.boolean().default(false),
requiresGroupCast: z.boolean().default(false),
minCasters: z.number().int().min(1).default(1),
nonCombatOnly: z.boolean().default(false),
damageBase: z.string().default(''),
damageType: z.string().default(''),
ignoresArmor: z.boolean().default(false),
statusEffects: z.array(z.string()).default([]),
saveAttribute: z.string().default(''),
saveSkill: z.string().default(''),
triggers: z.array(z.object({ event: z.string(), effect: z.string() })).default([]),
components: z.object({
verbal: z.boolean().default(false),
somatic: z.boolean().default(false),
material: z.string().default(''),
symbols: z.array(z.string()).default([]),
}),
requirements: z.object({
race: z.array(z.string()).default([]),
talent: z.array(z.string()).default([]),
discipline: z.array(z.string()).default([]),
}),
difficulty: z.object({
threshold: z.number().int().min(1).max(6),
successes: z.number().int().min(1),
}),
range: z.object({
kind: z.string(),
value: z.number().optional(),
unit: z.string().optional(),
text: z.string().optional(),
}),
duration: z.string().default(''),
castingTime: z.string().default(''),
target: z.string().default(''),
areaOfEffect: z
.object({ shape: z.string(), x: z.number().optional(), y: z.number().optional(), unit: z.string().optional() })
.nullable()
.default(null),
overcastOptions: z
.array(z.object({ text: z.string(), repeatable: z.boolean().default(true), cost: z.number().int().default(0) }))
.default([]),
description: z.string().default(''),
});
function getAllFiles(dirPath: string, arrayOfFiles: string[] = []) {
const files = readdirSync(dirPath);
for (const file of files) {
const fullPath = resolve(dirPath, file);
if (statSync(fullPath).isDirectory()) {
arrayOfFiles = getAllFiles(fullPath, arrayOfFiles);
} else if (file.endsWith('.md')) {
arrayOfFiles.push(fullPath);
}
}
return arrayOfFiles;
}
export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
const spellsDir = resolve(ctx.repoRoot, 'ObsidianNotes', 'spells');
let files: string[] = [];
try {
files = getAllFiles(spellsDir);
} catch (e) {
console.warn('[spell-parser] No ObsidianNotes/spells directory found or unreadable.');
return docs;
}
for (const file of files) {
const text = readFileSync(file, 'utf8');
const match = text.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) continue;
const [_, frontmatterRaw, bodyRaw] = match;
let frontmatter: any;
try {
frontmatter = yaml.load(frontmatterRaw);
} catch (e) {
if (ctx.strict) throw new Error(`[spell-parser] Invalid YAML in ${file}: ${e}`);
continue;
}
const payload = { ...frontmatter, description: bodyRaw.trim() };
const nameMatch = payload.description.match(/^#\s+(.+)$/m);
const spellName = nameMatch ? nameMatch[1].trim() : basename(file, '.md');
payload.description = payload.description.replace(/^#\s+.+$/m, '').trim();
const parsed = SpellSystemSchema.safeParse(payload);
if (!parsed.success) {
if (ctx.strict) throw new Error(`[spell-parser] Validation failed for "${file}": ${parsed.error.message}`);
continue;
}
const baseId = basename(file, '.md');
let pack = 'spells-academic';
const relativePath = file.substring(spellsDir.length).replace(/\\/g, '/');
if (relativePath.includes('/general/')) pack = 'spells-general';
else if (relativePath.includes('/sacred/')) pack = 'spells-sacred';
else if (relativePath.includes('/eldritch/')) pack = 'spells-eldritch';
else if (relativePath.includes('/crimson/')) pack = 'spells-crimson';
else if (relativePath.includes('/blood/')) pack = 'spells-blood';
docs.push({
id: baseId,
name: spellName,
documentType: 'Item',
subType: 'spell',
pack,
source: { book: parsed.data.sourceBook, chapter: 'Spells', line: 1 },
system: parsed.data,
description: payload.description,
});
}
return docs;
};
+250
View File
@@ -0,0 +1,250 @@
/**
* Talent parser. Talents in HbM are formatted as plain-text headers:
*
* Talent Name
* Wymagania: req1, req2 … (optional; single line)
* <description paragraphs>
* ________________
*
* They live in:
* - Podręcznik Gry, "Rozdział IV - Talenty"
* - Bestiariusz, "Rozdział III - Talenty" (NPC-only talents)
*
* The walker can't help us here — we segment by `________________`
* separators within the talent chapter and look for a "Wymagania:" line.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { slugify } from './helpers';
const SEPARATOR = /^_{3,}$/;
const REQUIREMENTS_PREFIX = /^(?:\*\*|\*)?Wymagania:(?:\*\*|\*)?\s*(.+)$/i;
interface TalentSource {
book: SourceBookId;
file: string;
/** Inclusive line range (1-based) of the talent chapter. */
startMarker: RegExp;
endMarker: RegExp;
/** Pack id to write into. */
pack: string;
/** Optional source-module flag stamped onto each talent. */
sourceModule?: 'arcanum-sanguinis' | 'abyss-curse' | 'crimson-cult' | null;
}
const TALENT_SOURCES: TalentSource[] = [
{
book: 'podrecznik-gry',
file: 'ObsidianNotes/rules/00. Podręcznik Gry.md',
startMarker: /^Rozdział IV - Talenty\s*$/,
endMarker: /^Rozdział V\b/,
pack: 'talents',
sourceModule: null,
},
{
book: 'bestiariusz',
file: 'ObsidianNotes/rules/06. Bestiariusz.md',
startMarker: /^Rozdział III - Talenty\s*$/,
endMarker: /^Rozdział IV\b/,
pack: 'talents-npc',
sourceModule: null,
},
{
// Arcanum Sanguinis: now lives in ObsidianNotes/rules/04. Arcanum Sanguinis.md.
// Headings may be prefixed with `## ` or `### `.
book: 'arcanum-sanguinis',
file: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md',
startMarker: /^#{0,4}\s*Rozdział IV - Talenty\s*$/,
endMarker: /^#{0,4}\s*(Rozdział V\b|Aneks\b)/,
pack: 'talents-blood',
sourceModule: 'arcanum-sanguinis',
},
{
// Klątwa Otchłani: now lives in ObsidianNotes/rules/02. Klątwa Otchłani.md.
book: 'klatwa-otchlani',
file: 'ObsidianNotes/rules/02. Klątwa Otchłani.md',
startMarker: /^#{0,4}\s*Rozdział II - Talenty\s*$/,
endMarker: /^#{0,4}\s*Rozdział III\b/,
pack: 'talents-eldritch',
sourceModule: 'abyss-curse',
},
];
/**
* Talent chapters are noisy — split into segments by separator lines, then
* within each segment find consecutive talent blocks. A talent block is:
* - first non-empty line = name (short, no trailing punctuation, no colon)
* - optional `Wymagania: ...` line
* - subsequent lines until next name candidate or end of segment = description
*
* Heuristic for name detection: line is short (< 60 chars), no trailing `.`,
* not a bullet, not a header (`Rozdział`, `Aneks`).
*/
export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
for (const source of TALENT_SOURCES) {
const path = resolve(ctx.repoRoot, source.file);
let lines: string[] = [];
try {
lines = readFileSync(path, 'utf8').split(/\r?\n/);
} catch {
continue;
}
const startIdx = lines.findIndex((l) => source.startMarker.test(l.trim()));
if (startIdx < 0) continue;
const endIdx = lines.findIndex((l, i) => i > startIdx && source.endMarker.test(l.trim()));
const slice = lines.slice(startIdx + 1, endIdx > 0 ? endIdx : lines.length);
const hasMarkdownHeaders = slice.some((l) => l.trim().startsWith('### '));
// Walk the slice looking for talent blocks.
let i = 0;
while (i < slice.length) {
// Skip blanks and separators.
while (i < slice.length && (slice[i].trim() === '' || SEPARATOR.test(slice[i].trim()))) i++;
if (i >= slice.length) break;
const rawLine = slice[i].trim();
// Stop heuristic — bail out of obvious chapter changes.
if (/^Rozdział\b/.test(rawLine) || /^Aneks\b/.test(rawLine) || /^#{1,6}\s+(?:Rozdział|Aneks)\b/i.test(rawLine)) break;
let isName = false;
let nameLine = '';
if (hasMarkdownHeaders) {
if (rawLine.startsWith('### ')) {
isName = true;
nameLine = rawLine.replace(/^###\s+/, '').trim();
}
} else {
nameLine = rawLine.replace(/^#{1,6}\s+/, '').trim();
if (isPlausibleName(nameLine)) {
isName = true;
}
}
if (!isName) {
// Can't read this — skip the line and continue.
i++;
continue;
}
i++;
// Optional requirements line.
let requirements = '';
if (i < slice.length) {
const m = slice[i].trim().match(REQUIREMENTS_PREFIX);
if (m) {
requirements = m[1].trim();
i++;
}
}
// Description until next plausible name OR separator OR chapter.
const descLines: string[] = [];
while (i < slice.length) {
const cur = slice[i].trim();
if (SEPARATOR.test(cur)) {
i++; // consume separator and end this talent
break;
}
if (/^Rozdział\b/.test(cur) || /^Aneks\b/.test(cur) || /^#{1,6}\s+(?:Rozdział|Aneks)\b/i.test(cur)) break;
if (hasMarkdownHeaders) {
if (cur.startsWith('### ')) {
break;
}
} else {
// If we've already captured at least one description line and the
// current line looks like a talent header (plausible name; next line
// is Wymagania, blank, or another short line), treat as next talent.
const cleanCur = cur.replace(/^#{1,6}\s+/, '').trim();
if (descLines.length > 0 && cur && isPlausibleName(cleanCur)) {
const next = i + 1 < slice.length ? slice[i + 1].trim() : '';
if (REQUIREMENTS_PREFIX.test(next) || next === '' || isProseOrReq(next)) {
break;
}
}
}
descLines.push(slice[i]);
i++;
}
let finalName = nameLine;
if (nameLine === 'Czuły Zmysł') {
finalName = 'Czuły Zmysł (Zmysł)';
}
const baseSlug = slugify(finalName);
const id = ctx.idOverrides[baseSlug] ?? baseSlug;
const description = descLines.join('\n').trim();
// Detect "(Dziedzina)" / "(Bóstwo)" / similar parameterised talents.
const multiSelect = /\((Dziedzina|Bóstwo|Zmysł|Atrybut|Umiejętność)\)$/i.test(finalName);
docs.push({
id,
name: finalName,
documentType: 'Item',
subType: 'talent',
pack: source.pack,
source: { book: source.book, chapter: 'Talenty', line: startIdx + 1 },
system: {
requirements: parseRequirements(requirements, ctx),
multiSelect,
cost: '',
damageReductionBonus: 0,
description,
effect: '',
},
description,
flags: source.sourceModule ? { sourceModule: source.sourceModule } : undefined,
});
}
}
return docs;
};
/** A talent name is short, doesn't end with `.`, no `:`, not a bullet, no digits-only. */
function isPlausibleName(line: string): boolean {
if (!line || line.length > 80) return false;
if (line.endsWith('.') || line.endsWith(',')) return false;
if (line.includes(':')) return false;
if (/^[*\-•]/.test(line)) return false;
if (/^\d+$/.test(line)) return false;
if (/^Wymagania\b/i.test(line)) return false;
// Must have at least one capital letter (talent names are Title Case).
if (!/[A-ZŻŹĆĄŚĘŁÓŃ]/.test(line)) return false;
return true;
}
function isProseOrReq(line: string): boolean {
if (!line) return false;
if (/^Wymagania\b/i.test(line)) return true;
// Proseish: long, ends with punctuation, contains lowercase mid-sentence words.
return line.length > 60 || /[.!?]$/.test(line);
}
function parseRequirements(text: string, _ctx: ParserContext): Record<string, unknown> {
// Keep raw + crude extraction. Detailed parsing can come later.
if (!text) {
return { race: '', attribute: '', skill: '', talent: '', title: '', discipline: '' };
}
return {
race: extractParen(text, /Rasa\s*\(([^)]+)\)/i),
attribute: '',
skill: '',
talent: '',
title: extractParen(text, /Tytuł\s*\(([^)]+)\)/i),
discipline: extractParen(text, /Dziedzina\s+Magii\s*\(([^)]+)\)/i),
raw: text,
};
}
function extractParen(text: string, re: RegExp): string {
const m = text.match(re);
return m ? m[1].trim() : '';
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Shared parser types — what each parser emits before being handed off
* to the pack builder.
*
* Every parsed document has a stable id (slug) and a sourceBook ref so
* we can deduplicate, lint, and re-link references later.
*/
export type SourceBookId =
| 'podrecznik-gry'
| 'ksiega-magii'
| 'bestiariusz'
| 'przewodnik'
| 'arcanum-sanguinis'
| 'crimson-cult'
| 'klatwa-otchlani'
| 'zlote-stal-magia';
export interface ParsedDoc {
/** Compendium-stable slug (kebab-case). */
id: string;
/** Display name (Polish, as in book). */
name: string;
/** Foundry document type — `Item` or `Actor`. */
documentType: 'Item' | 'Actor';
/** Foundry sub-type for the data model (e.g. `spell`, `talent`). */
subType: string;
/** Pack id this doc belongs to (e.g. `spells-general`). */
pack: string;
/** Source book + chapter for traceability. */
source: { book: SourceBookId; chapter?: string; line?: number };
/** The `system.*` payload that will be written into the pack. */
system: Record<string, unknown>;
/** Optional rich-text description (markdown → HTML). */
description?: string;
/** Optional extra flags merged into `flags['hbm-rpg-v3']`. */
flags?: Record<string, unknown>;
/** Non-fatal parser warnings (missing fields, ambiguous values, etc.). */
warnings?: string[];
}
export interface ParserContext {
/** Absolute path to repository root (so parsers can resolve `_books/`). */
repoRoot: string;
/** Strict mode aborts on first warning; default `false`. */
strict?: boolean;
/** ID overrides loaded from `_id-overrides.json`. */
idOverrides: Record<string, string>;
/** Label → constant mapping (Polish UI label → English internal id). */
labelMappings: {
schools: Record<string, string>;
disciplines: Record<string, string>;
deities: Record<string, string>;
symbols: Record<string, string>;
attributes: Record<string, string>;
skills: Record<string, string>;
};
}
export type ParserFn = (ctx: ParserContext) => Promise<ParsedDoc[]>;