1fba0892b9
- talent-parser.ts, discipline-parser.ts, race-parser.ts: rebuilt to read from the vault's post-bab5a62 per-topic-file layout (rulebook monoliths were split into per-chapter subfolders; discipline/race writeups moved to their canonical disciplines/ and races/ folders). All three parsers were previously producing zero or broken output for their packs. - constants.ts, actor-character.ts, actor-npc.ts, rest.ts: mana is now a daily pool (Poziom Mocy x3) restored via rest tiers, not a per-round budget; Zywotnosc is 2x(Cialo+Umysl+Dusza), not 3x; rest recovery amounts updated to match Odetchniecie/Krotki Odpoczynek/Dlugi Odpoczynek. - combat.ts: removed the now-obsolete per-round mana reset hook. - actor-npc.ts: Initiative now includes Refleks/Spostrzegawczosc skill bonuses (was missing entirely); Magia attribute constrained to valid 1k10 character-creation values. - fields.ts: added `choices` support to makeIntField for the above. - spell-cast.ts: overcast test now uses the correct T-threshold tiers and triangular S = E*(E+1)/2 required-successes formula (Zasada Nadczarowywania Zaklec). Not implemented (flagged as new features, out of scope for a sync fix): mana-exhaustion/overcast-failure penalty tables, overcast bonus taper past step 2, shield-breaking damage mechanic, elixir-weapon-coating system. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
248 lines
8.0 KiB
TypeScript
248 lines
8.0 KiB
TypeScript
/**
|
|
* Talent parser. Since the `bab5a62` vault split, every talent lives in its
|
|
* own file (one per talent) under a book's "Talenty" chapter folder:
|
|
*
|
|
* rules/00. Podręcznik Gry/Rozdział IV - Talenty/<Name>.md
|
|
* rules/06. Bestiariusz/Rozdział III - Talenty/<Name>.md
|
|
* rules/04. Arcanum Sanguinis/Rozdział IV - Talenty/<Name>.md
|
|
* rules/02. Klątwa Otchłani/Rozdział II - Talenty/<Name>.md
|
|
*
|
|
* Each file is: YAML frontmatter, an optional `Wymagania: ...` /
|
|
* `**Wymagania**: ...` line anywhere in the body, and the rest is
|
|
* description prose. The file's own name is the talent name.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
import { resolve, extname, basename } from 'node:path';
|
|
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
|
|
import { slugify } from './helpers';
|
|
|
|
const REQUIREMENTS_LINE = /^(?:\*\*|\*)?Wymagania(?:\*\*|\*)?\s*:\s*(.+)$/i;
|
|
const FRONTMATTER_FENCE = /^---\s*$/;
|
|
|
|
interface TalentFolderSource {
|
|
book: SourceBookId;
|
|
/** Folder path relative to repoRoot, one talent per .md file. */
|
|
folder: string;
|
|
/** 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_FOLDER_SOURCES: TalentFolderSource[] = [
|
|
{
|
|
book: 'podrecznik-gry',
|
|
folder: 'ObsidianNotes/rules/00. Podręcznik Gry/Rozdział IV - Talenty',
|
|
pack: 'talents',
|
|
sourceModule: null,
|
|
},
|
|
{
|
|
book: 'bestiariusz',
|
|
folder: 'ObsidianNotes/rules/06. Bestiariusz/Rozdział III - Talenty',
|
|
pack: 'talents-npc',
|
|
sourceModule: null,
|
|
},
|
|
{
|
|
book: 'arcanum-sanguinis',
|
|
folder: 'ObsidianNotes/rules/04. Arcanum Sanguinis/Rozdział IV - Talenty',
|
|
pack: 'talents-blood',
|
|
sourceModule: 'arcanum-sanguinis',
|
|
},
|
|
{
|
|
book: 'klatwa-otchlani',
|
|
folder: 'ObsidianNotes/rules/02. Klątwa Otchłani/Rozdział II - Talenty',
|
|
pack: 'talents-eldritch',
|
|
sourceModule: 'abyss-curse',
|
|
},
|
|
];
|
|
|
|
/** Skip YAML frontmatter (--- ... ---) at file start. Returns start line index. */
|
|
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;
|
|
}
|
|
|
|
/** Pull the (first) `Wymagania:` line out of a talent file body; everything else is description. */
|
|
function parseTalentBody(path: string): { requirements: string; description: string } {
|
|
const lines = readFileSync(path, 'utf8').split(/\r?\n/);
|
|
const body = lines.slice(skipFrontmatter(lines));
|
|
|
|
let requirements = '';
|
|
const descLines: string[] = [];
|
|
for (const raw of body) {
|
|
const m = requirements === '' ? raw.trim().match(REQUIREMENTS_LINE) : null;
|
|
if (m) {
|
|
requirements = m[1].trim();
|
|
continue;
|
|
}
|
|
descLines.push(raw);
|
|
}
|
|
|
|
return { requirements, description: descLines.join('\n').replace(/\n{3,}/g, '\n\n').trim() };
|
|
}
|
|
|
|
export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
|
|
const docs: ParsedDoc[] = [];
|
|
|
|
for (const source of TALENT_FOLDER_SOURCES) {
|
|
const dirPath = resolve(ctx.repoRoot, source.folder);
|
|
if (!existsSync(dirPath)) {
|
|
console.warn(`[talent-parser] folder not found: ${source.folder}`);
|
|
continue;
|
|
}
|
|
|
|
const files = readdirSync(dirPath).filter((f) => extname(f) === '.md');
|
|
for (const file of files) {
|
|
let finalName = basename(file, '.md');
|
|
if (finalName === 'Czuły Zmysł') {
|
|
finalName = 'Czuły Zmysł (Zmysł)';
|
|
}
|
|
|
|
const { requirements, description } = parseTalentBody(resolve(dirPath, file));
|
|
const baseSlug = slugify(finalName);
|
|
const id = ctx.idOverrides[baseSlug] ?? baseSlug;
|
|
|
|
// 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' },
|
|
system: {
|
|
requirements: parseRequirements(requirements, ctx),
|
|
multiSelect,
|
|
cost: '',
|
|
damageReductionBonus: 0,
|
|
description,
|
|
effect: '',
|
|
},
|
|
description,
|
|
flags: source.sourceModule ? { sourceModule: source.sourceModule } : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Parse discipline-specific talents from Wybór Dziedziny Magii
|
|
docs.push(...(await parseDisciplineTalents(ctx)));
|
|
|
|
return docs;
|
|
};
|
|
|
|
/**
|
|
* The 10 "discipline choice" talents live inline as headings inside the
|
|
* character-creation "Wybór Dziedziny Magii - <Discipline>.md" files.
|
|
* Heading level is inconsistent across files (#### to ######), so match
|
|
* against a name whitelist rather than a fixed heading depth.
|
|
*/
|
|
const DISCIPLINE_TALENT_FOLDER = 'ObsidianNotes/rules/00. Podręcznik Gry/Rozdział II - Tworzenie Postaci';
|
|
const DISCIPLINE_CHOICE_FILES = [
|
|
'Wybór Dziedziny Magii - Alchemia.md',
|
|
'Wybór Dziedziny Magii - Botanika.md',
|
|
'Wybór Dziedziny Magii - Magia Iluzji.md',
|
|
'Wybór Dziedziny Magii - Magia Sakralna.md',
|
|
'Wybór Dziedziny Magii - Magia Żywiołów.md',
|
|
'Wybór Dziedziny Magii - Rzemiosło Artefaktów.md',
|
|
'Wybór Dziedziny Magii - Wiedźmia Magia.md',
|
|
'Wybór Dziedziny Magii - Źródło Mocy.md',
|
|
];
|
|
|
|
const DISCIPLINE_TALENT_NAMES = [
|
|
'Podejrzliwość Wobec Zmian',
|
|
'Nadzwyczajna Odporność',
|
|
'Magiczna Flora',
|
|
'Miłosierdzie',
|
|
'Błogosławieni Egzorcyści',
|
|
'Jedność z Żywiołem',
|
|
'Tajemna Technologia',
|
|
'Pierwotna Energia',
|
|
'Niezwykła Intuicja',
|
|
'Arytmetyczna Koncentracja',
|
|
];
|
|
|
|
const HEADING_LINE = /^#{1,6}\s+(.+)$/;
|
|
|
|
async function parseDisciplineTalents(ctx: ParserContext): Promise<ParsedDoc[]> {
|
|
const docs: ParsedDoc[] = [];
|
|
const cleanNames = DISCIPLINE_TALENT_NAMES.map((n) => slugify(n));
|
|
|
|
for (const file of DISCIPLINE_CHOICE_FILES) {
|
|
const path = resolve(ctx.repoRoot, DISCIPLINE_TALENT_FOLDER, file);
|
|
let lines: string[] = [];
|
|
try {
|
|
lines = readFileSync(path, 'utf8').split(/\r?\n/);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const body = lines.slice(skipFrontmatter(lines));
|
|
|
|
for (let i = 0; i < body.length; i++) {
|
|
const headingMatch = body[i].trim().match(HEADING_LINE);
|
|
if (!headingMatch) continue;
|
|
const talentIndex = cleanNames.indexOf(slugify(headingMatch[1].trim()));
|
|
if (talentIndex < 0) continue;
|
|
|
|
const name = DISCIPLINE_TALENT_NAMES[talentIndex];
|
|
const descLines: string[] = [];
|
|
let j = i + 1;
|
|
while (j < body.length && !HEADING_LINE.test(body[j].trim())) {
|
|
descLines.push(body[j]);
|
|
j++;
|
|
}
|
|
const description = descLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
const baseSlug = slugify(name);
|
|
const id = ctx.idOverrides[baseSlug] ?? baseSlug;
|
|
|
|
docs.push({
|
|
id,
|
|
name,
|
|
documentType: 'Item',
|
|
subType: 'talent',
|
|
pack: 'talents',
|
|
source: { book: 'podrecznik-gry', chapter: 'Wybór Dziedziny Magii' },
|
|
system: {
|
|
requirements: { race: '', attribute: '', skill: '', talent: '', title: '', discipline: '' },
|
|
multiSelect: false,
|
|
cost: '',
|
|
damageReductionBonus: 0,
|
|
description,
|
|
effect: '',
|
|
},
|
|
description,
|
|
});
|
|
|
|
i = j - 1;
|
|
}
|
|
}
|
|
|
|
return docs;
|
|
}
|
|
|
|
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() : '';
|
|
}
|