Initial commit
This commit is contained in:
@@ -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[]>;
|
||||
Reference in New Issue
Block a user