diff --git a/scripts/parsers/discipline-parser.ts b/scripts/parsers/discipline-parser.ts index efae68e..bdb49b9 100644 --- a/scripts/parsers/discipline-parser.ts +++ b/scripts/parsers/discipline-parser.ts @@ -1,47 +1,56 @@ /** * 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). + * `DISCIPLINE_SEEDS`; we emit one stub Item per discipline with name, + * source book, and a description pulled from the vault's canonical + * `ObsidianNotes/disciplines/` folder (see `descriptionFile`/`fallbackFile` + * below). * - * 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. + * Before `bab5a62` these descriptions were read out of the monolithic + * rulebook files (`rules/01.*`, `rules/02.*`, `rules/00.*`) by matching a + * heading against the discipline's Polish name. That split replaced the + * rulebooks' discipline write-ups with links to `disciplines/` (their + * canonical home per the changelog: "disciplines vs disciplines/"), so we + * now read the canonical files directly instead of chasing a link. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types'; -import { normalizeKey, slugify } from './helpers'; +import { slugify } from './helpers'; interface DisciplineSeed { id: string; polishName: string; pack: string; book: SourceBookId; + /** Path relative to ObsidianNotes/disciplines/ - the most specific write-up. */ + descriptionFile: string; + /** Parent discipline file to fall back to when descriptionFile is an empty stub. */ + fallbackFile?: string; } 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' }, + { id: 'alchemyTransmutation', polishName: 'Alchemia - Transmutacja', pack: 'disciplines', book: 'podrecznik-gry', descriptionFile: 'Alchemia/Poddziedzina - Transmutacja.md', fallbackFile: 'Alchemia.md' }, + { id: 'alchemyBrewing', polishName: 'Alchemia - Warzenie Eliksirów', pack: 'disciplines', book: 'podrecznik-gry', descriptionFile: 'Alchemia/Poddziedzina - Warzenie Eliksirów.md', fallbackFile: 'Alchemia.md' }, + { id: 'botany', polishName: 'Botanika', pack: 'disciplines', book: 'podrecznik-gry', descriptionFile: 'Botanika.md' }, + { id: 'elementsAir', polishName: 'Magia Żywiołów (Powietrze)', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Magia Żywiołów/Poddziedzina - Magia Powietrza.md', fallbackFile: 'Magia Żywiołów.md' }, + { id: 'elementsWater', polishName: 'Magia Żywiołów (Woda)', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Magia Żywiołów/Poddziedzina - Magia Wody.md', fallbackFile: 'Magia Żywiołów.md' }, + { id: 'elementsFire', polishName: 'Magia Żywiołów (Ogień)', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Magia Żywiołów/Poddziedzina - Magia Ognia.md', fallbackFile: 'Magia Żywiołów.md' }, + { id: 'elementsEarth', polishName: 'Magia Żywiołów (Ziemia)', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Magia Żywiołów/Poddziedzina - Magia Ziemi.md', fallbackFile: 'Magia Żywiołów.md' }, + { id: 'artifice', polishName: 'Rzemiosło Artefaktów', pack: 'disciplines', book: 'podrecznik-gry', descriptionFile: 'Rzemiosło Artefaktów.md' }, + { id: 'golemancy', polishName: 'Golemancja', pack: 'disciplines', book: 'podrecznik-gry', descriptionFile: 'Golemancja.md' }, + { id: 'runes', polishName: 'Magia Runiczna', pack: 'disciplines', book: 'podrecznik-gry', descriptionFile: 'Magia Runiczna.md' }, + { id: 'manaSourceMage', polishName: 'Źródło Mocy', pack: 'disciplines', book: 'podrecznik-gry', descriptionFile: 'Źródło Mocy.md' }, + { id: 'illusion', polishName: 'Magia Iluzji', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Magia Iluzji.md' }, + { id: 'sacred', polishName: 'Magia Sakralna', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Magia Sakralna/Poddziedzina - Magia Sakralna.md', fallbackFile: 'Magia Sakralna.md' }, + { id: 'sacredExorcism', polishName: 'Magia Sakralna - Egzorcyzmy', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Magia Sakralna/Poddziedzina - Egzorcyzmy.md', fallbackFile: 'Magia Sakralna.md' }, + { id: 'witch', polishName: 'Wiedźmia Magia', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Wiedźmia Magia.md' }, + { id: 'necromancy', polishName: 'Nekromancja', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Nekromancja.md' }, + { id: 'blood', polishName: 'Magia Krwi', pack: 'disciplines-forbidden', book: 'arcanum-sanguinis', descriptionFile: 'Magia Krwi.md' }, + { id: 'crimson', polishName: 'Magia Szkarłatu', pack: 'disciplines-forbidden', book: 'crimson-cult', descriptionFile: 'Magia Szkarłatu.md' }, + { id: 'abyssAspects', polishName: 'Magia Otchłani - Magia Aspektów', pack: 'disciplines-forbidden', book: 'klatwa-otchlani', descriptionFile: 'Magia Otchłani/Poddziedzina - Magia Aspektów.md', fallbackFile: 'Magia Otchłani.md' }, + { id: 'abyssPrimal', polishName: 'Magia Otchłani - Pierwotna Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani', descriptionFile: 'Magia Otchłani/Poddziedzina - Pierwotna Magia.md', fallbackFile: 'Magia Otchłani.md' }, + { id: 'wildWitch', polishName: 'Dzika Wiedźmia Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani', descriptionFile: 'Dzika Wiedźmia Magia.md' }, ]; const SUGGESTED_ATTRIBUTE: Record = { @@ -55,15 +64,6 @@ const SUGGESTED_SKILLS: Record = { 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; @@ -73,40 +73,60 @@ function skipFrontmatter(lines: string[]): number { 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]); +/** + * Pull a description out of a `disciplines/` file: prefer the prose under + * a `## Definicja` or `## Wstęp` heading (the two intro-section labels the + * canonical write-ups use); fall back to the first prose paragraph before + * any heading (covers Poddziedzina sub-files, which usually skip straight + * to prose with no intro heading at all). Returns '' for frontmatter-only + * stub files. + */ +function extractDescription(body: string[]): string { + const HEADING = /^#{1,6}\s+(.+)$/; + const isNoise = (line: string) => + line === '' || + /^spis tre[śs]ci$/i.test(line) || // literal "Spis treści" TOC label + /^[-_*]{3,}$/.test(line) || // horizontal rule + /^[-*]\s*\[/.test(line); // TOC bullet, incl. tab-indented sub-bullets (already trimmed) - 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(' '); - } + const introIdx = body.findIndex((l) => /^#{1,6}\s*(Definicja|Wstęp)\s*$/i.test(l.trim())); + if (introIdx >= 0) { + const buf: string[] = []; + for (let i = introIdx + 1; i < body.length; i++) { + const cur = body[i].trim(); + if (HEADING.test(cur)) break; + if (isNoise(cur)) continue; + buf.push(cur); } + if (buf.length > 0) return buf.join(' '); } + + const buf: string[] = []; + for (const raw of body) { + const cur = raw.trim(); + if (HEADING.test(cur)) break; + if (isNoise(cur)) continue; + buf.push(cur); + } + return buf.join(' '); +} + +function readDisciplineFile(repoRoot: string, relPath: string): string { + const path = resolve(repoRoot, 'ObsidianNotes/disciplines', relPath); + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch { + return ''; + } + const lines = text.split(/\r?\n/); + return extractDescription(lines.slice(skipFrontmatter(lines))); +} + +function lookupDescription(repoRoot: string, seed: DisciplineSeed): string { + const primary = readDisciplineFile(repoRoot, seed.descriptionFile); + if (primary) return primary; + if (seed.fallbackFile) return readDisciplineFile(repoRoot, seed.fallbackFile); return ''; } @@ -114,7 +134,7 @@ export const parseDisciplines: ParserFn = async (ctx: ParserContext): Promise.md + * + * Only 5 races are player-selectable at character creation (Człowiek, Elf, + * Feles, Lamia, Anioł/Anielica) - Krasnolud, Demon and Malferianin have no + * such file and are NPC/plot-only races with no point-buy stats to parse. + * + * Flavor description comes from the vault's canonical `races/.md` + * write-up's "Krótki opis" section (their canonical home per the changelog's + * "disciplines vs disciplines/" pattern - character-creation files only + * carry a short player-facing blurb, not the full lore). + * + * Format of each Wybór Rasy file (unchanged from the pre-split monolith, + * just relocated to one file per race): * * * Cechy Rasowe: * * Dostępne Dziedziny Magii: * * - * * * * N Punktów Atrybutów * * Darmowe Talenty: * * - * * * * 1 Punkt Umiejętności w * * 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'; +import { slugify } from './helpers'; -const SEPARATOR = /^_{3,}$/; const RACE_FEATURES_HEADER = /^Cechy Rasowe:\s*$/; +const FRONTMATTER_FENCE = /^---\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', -]); +interface RaceSource { + name: string; + /** Relative to `rules/00. Podręcznik Gry/Rozdział II - Tworzenie Postaci/`. */ + mechanicalFile: string; + /** Relative to `ObsidianNotes/races/`. */ + descriptionFile: string; +} + +const CHARACTER_CREATION_DIR = 'ObsidianNotes/rules/00. Podręcznik Gry/Rozdział II - Tworzenie Postaci'; + +const RACE_SOURCES: RaceSource[] = [ + { name: 'Człowiek', mechanicalFile: 'Wybór Rasy - Człowiek.md', descriptionFile: 'Człowiek.md' }, + { name: 'Elf', mechanicalFile: 'Wybór Rasy - Elf.md', descriptionFile: 'Elf.md' }, + { name: 'Feles', mechanicalFile: 'Wybór Rasy - Feles.md', descriptionFile: 'Feles.md' }, + { name: 'Lamia', mechanicalFile: 'Wybór Rasy - Lamia.md', descriptionFile: 'Lamia.md' }, + { name: 'Anioł/Anielica', mechanicalFile: 'Wybór Rasy - Anioł-Anielica.md', descriptionFile: 'Anioł.md' }, +]; + +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; +} + +/** Pulls the "## Krótki opis" section out of a canonical `races/` write-up. */ +function readShortDescription(repoRoot: string, descriptionFile: string): string { + let lines: string[]; + try { + lines = readFileSync(resolve(repoRoot, 'ObsidianNotes/races', descriptionFile), 'utf8').split(/\r?\n/); + } catch { + return ''; + } + const body = lines.slice(skipFrontmatter(lines)); + const startIdx = body.findIndex((l) => /^#{1,6}\s*Krótki opis\s*$/i.test(l.trim())); + if (startIdx < 0) return ''; + const buf: string[] = []; + for (let i = startIdx + 1; i < body.length; i++) { + const cur = body[i].trim(); + if (/^#{1,6}\s+/.test(cur)) break; + if (cur === '') continue; + buf.push(cur); + } + return buf.join(' '); +} export const parseRaces: ParserFn = async (ctx: ParserContext): Promise => { 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--; + for (const source of RACE_SOURCES) { + const path = resolve(ctx.repoRoot, CHARACTER_CREATION_DIR, source.mechanicalFile); + let lines: string[]; + try { + lines = readFileSync(path, 'utf8').split(/\r?\n/); + } catch { + console.warn(`[race-parser] file not found: ${source.mechanicalFile}`); + continue; } - if (!nameLine) continue; + const body = lines.slice(skipFrontmatter(lines)); - // 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 headerIdx = body.findIndex((l) => RACE_FEATURES_HEADER.test(l.trim())); + if (headerIdx < 0) { + console.warn(`[race-parser] no "Cechy Rasowe:" block in ${source.mechanicalFile}`); + continue; } + const bulletLines = body.slice(headerIdx + 1).map((l) => l.trimEnd()); const parsed = parseRaceFeatures(bulletLines); - const baseSlug = slugify(stripVariant(nameLine)); + const description = readShortDescription(ctx.repoRoot, source.descriptionFile); + const baseSlug = slugify(source.name); const id = ctx.idOverrides[baseSlug] ?? baseSlug; docs.push({ id, - name: stripVariant(nameLine), + name: source.name, documentType: 'Item', subType: 'race', pack: 'races', - source: { book: 'podrecznik-gry', chapter: 'Tworzenie Postaci', line: nameIdx + 1 }, + source: { book: 'podrecznik-gry', chapter: 'Tworzenie Postaci' }, system: { availableDisciplines: parsed.disciplines, attributePoints: parsed.attributePoints, skillPoints: parsed.skillPoints, freeTalents: parsed.freeTalents, racialAbilities: [], - physicalDescription, - description: physicalDescription, + physicalDescription: description, + description, }, - description: physicalDescription, + description, }); } 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; @@ -167,7 +176,7 @@ function parseRaceFeatures(lines: string[]): ParsedRaceFeatures { continue; } - // Indented child bullet ` * xxx` (Google Docs export uses tabs/spaces). + // Indented child bullet ` * xxx` (leading tabs/spaces). const child = line.match(/^\s+\*\s+(.+)$/); if (!child) continue; const text = child[1].trim(); diff --git a/scripts/parsers/talent-parser.ts b/scripts/parsers/talent-parser.ts index d9b885a..c44fbb9 100644 --- a/scripts/parsers/talent-parser.ts +++ b/scripts/parsers/talent-parser.ts @@ -1,185 +1,110 @@ /** - * Talent parser. Talents in HbM are formatted as plain-text headers: + * Talent parser. Since the `bab5a62` vault split, every talent lives in its + * own file (one per talent) under a book's "Talenty" chapter folder: * - * Talent Name - * Wymagania: req1, req2 … (optional; single line) - * - * ________________ + * rules/00. Podręcznik Gry/Rozdział IV - Talenty/.md + * rules/06. Bestiariusz/Rozdział III - Talenty/.md + * rules/04. Arcanum Sanguinis/Rozdział IV - Talenty/.md + * rules/02. Klątwa Otchłani/Rozdział II - Talenty/.md * - * 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. + * 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 } from 'node:fs'; -import { resolve } from 'node:path'; +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 SEPARATOR = /^_{3,}$/; -const REQUIREMENTS_PREFIX = /^(?:\*\*|\*)?Wymagania:(?:\*\*|\*)?\s*(.+)$/i; +const REQUIREMENTS_LINE = /^(?:\*\*|\*)?Wymagania(?:\*\*|\*)?\s*:\s*(.+)$/i; +const FRONTMATTER_FENCE = /^---\s*$/; -interface TalentSource { +interface TalentFolderSource { book: SourceBookId; - file: string; - /** Inclusive line range (1-based) of the talent chapter. */ - startMarker: RegExp; - endMarker: RegExp; + /** 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_SOURCES: TalentSource[] = [ +const TALENT_FOLDER_SOURCES: TalentFolderSource[] = [ { book: 'podrecznik-gry', - file: 'ObsidianNotes/rules/00. Podręcznik Gry.md', - startMarker: /^Rozdział IV - Talenty\s*$/, - endMarker: /^Rozdział V\b/, + folder: 'ObsidianNotes/rules/00. Podręcznik Gry/Rozdział IV - Talenty', pack: 'talents', sourceModule: null, }, { book: 'bestiariusz', - file: 'ObsidianNotes/rules/06. Bestiariusz.md', - startMarker: /^Rozdział III - Talenty\s*$/, - endMarker: /^Rozdział IV\b/, + folder: 'ObsidianNotes/rules/06. Bestiariusz/Rozdział III - Talenty', 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)/, + folder: 'ObsidianNotes/rules/04. Arcanum Sanguinis/Rozdział IV - Talenty', 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/, + folder: 'ObsidianNotes/rules/02. Klątwa Otchłani/Rozdział II - Talenty', 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`). - */ +/** 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 => { 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 { + 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 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ł') { + 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; - 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); @@ -190,7 +115,7 @@ export const parseTalents: ParserFn = async (ctx: ParserContext): Promise.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 { const docs: ParsedDoc[] = []; - const path = resolve(ctx.repoRoot, 'ObsidianNotes/rules/00. Podr\u0119cznik Gry.md'); - let lines: string[] = []; - try { - lines = readFileSync(path, 'utf8').split(/\r?\n/); - } catch { - return []; - } + const cleanNames = DISCIPLINE_TALENT_NAMES.map((n) => slugify(n)); - const startIdx = lines.findIndex((l) => /^\s*Wyb[o\u00f3\u0143A3\ufffd]r Dziedziny Magii\s*$/i.test(l)); - if (startIdx < 0) return []; - const endIdx = lines.findIndex((l, i) => i > startIdx && /^\s*Umiej[e\u0119\u0118\ufffd]tno[s\u015b\u015a\ufffd]ci i Talenty\s*$/i.test(l)); - const slice = lines.slice(startIdx + 1, endIdx > 0 ? endIdx : lines.length); + 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)); - const talentNames = [ - 'Podejrzliwo\u015b\u0107 Wobec Zmian', - 'Nadzwyczajna Odporno\u015b\u0107', - 'Magiczna Flora', - 'Mi\u0142osierdzie', - 'B\u0142ogos\u0142awieni Egzorcy\u015bci', - 'Jedno\u015b\u0107 z \u017bywio\u0142em', - 'Tajemna Technologia', - 'Pierwotna Energia', - 'Niezwyk\u0142a Intuicja', - 'Arytmetyczna Koncentracja' - ]; + 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 cleanNames = talentNames.map(n => slugify(n)); - - for (let i = 0; i < slice.length; i++) { - const rawLine = slice[i].trim(); - if (!rawLine) continue; - - const slug = slugify(rawLine); - const talentIndex = cleanNames.indexOf(slug); - - if (talentIndex !== -1) { - const name = talentNames[talentIndex]; + const name = DISCIPLINE_TALENT_NAMES[talentIndex]; const descLines: string[] = []; let j = i + 1; - while (j < slice.length) { - const nextLine = slice[j].trim(); - if (SEPARATOR.test(nextLine) || /^Rozdzia/i.test(nextLine) || /^___/.test(nextLine)) { - break; - } - const nextSlug = slugify(nextLine); - if (cleanNames.includes(nextSlug)) { - break; - } - // Stop if we hit a main section like Alchemia, Botanika, Magia Sakralna etc. - const disciplineHeaders = [ - 'Alchemia', 'Transmutacja', 'Warzenie Eliksirów', 'Botanika', - 'Magia Sakralna', 'Egzorcyzmy', 'Magia Żywiołów', 'Rzemiosło Artefaktów', - 'Źródło Mocy', 'Magia Iluzji', 'Wiedźmia Magia' - ]; - if (disciplineHeaders.some(h => slugify(h) === nextSlug)) { - break; - } - - descLines.push(slice[j]); + while (j < body.length && !HEADING_LINE.test(body[j].trim())) { + descLines.push(body[j]); j++; } - const description = descLines.join('\n').trim(); + const description = descLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); const baseSlug = slugify(name); const id = ctx.idOverrides[baseSlug] ?? baseSlug; @@ -284,7 +206,7 @@ async function parseDisciplineTalents(ctx: ParserContext): Promise documentType: 'Item', subType: 'talent', pack: 'talents', - source: { book: 'podrecznik-gry', chapter: 'Wyb\u00f3r Dziedziny Magii', line: startIdx + i + 2 }, + source: { book: 'podrecznik-gry', chapter: 'Wybór Dziedziny Magii' }, system: { requirements: { race: '', attribute: '', skill: '', talent: '', title: '', discipline: '' }, multiSelect: false, @@ -303,26 +225,6 @@ async function parseDisciplineTalents(ctx: ParserContext): Promise 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 { // Keep raw + crude extraction. Detailed parsing can come later. if (!text) { diff --git a/src/constants.ts b/src/constants.ts index 4406e5b..0e3540c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -79,28 +79,30 @@ export const SKILL_KEYS = Object.freeze(Object.keys(SKILLS)) as readonly string[ /** * Magic Power Level lookup table (level 0..10 → I..X). - * Drives dice pool size, max mana per single spell, and total mana per round. + * Drives dice pool size, max mana per single spell, and the daily mana pool + * ("Ilość Many" - there is no more per-round mana limit, see `rules/00. + * Podręcznik Gry/Rozdział II - Tworzenie Postaci/Atrybuty.md`). */ export interface MagicPowerEntry { level: number; // 0..10 label: string; // '0' | 'I' | 'II' | ... | 'X' dicePool: number; // dice added when casting maxPerSpell: number; // max mana spent on one spell - manaPerRound: number; // mana budget per combat round + manaPerDay: number; // daily mana pool ("Ilość Many") } export const MAGIC_POWER_TABLE: readonly MagicPowerEntry[] = Object.freeze([ - { level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerRound: 20 }, - { level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerRound: 12 }, - { level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerRound: 10 }, - { level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerRound: 7 }, - { level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerRound: 8 }, - { level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerRound: 6 }, - { level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerRound: 6 }, - { level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerRound: 4 }, - { level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerRound: 3 }, - { level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerRound: 2 }, - { level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerRound: 1 }, + { level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerDay: 60 }, + { level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerDay: 36 }, + { level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerDay: 30 }, + { level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerDay: 21 }, + { level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerDay: 24 }, + { level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerDay: 18 }, + { level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerDay: 18 }, + { level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerDay: 12 }, + { level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerDay: 9 }, + { level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerDay: 6 }, + { level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerDay: 3 }, ]); export function getMagicPowerEntry(level: number): MagicPowerEntry { diff --git a/src/data/actor-character.ts b/src/data/actor-character.ts index 41a6ef3..01987b4 100644 --- a/src/data/actor-character.ts +++ b/src/data/actor-character.ts @@ -12,10 +12,10 @@ import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from * Player Character data model. * * Derived stats (computed in prepareDerivedData): - * - attributes.health.max = 3 * (body + mind + soul) + * - attributes.health.max = 2 * (body + mind + soul) * - attributes.zeal.max = ceil(soul / 2) * - attributes.initiative = mind + skills.reflex.value + skills.perception.value - * - attributes.mana.max / .maxPerSpell / magic.dicePool - from MAGIC_POWER_TABLE + * - attributes.mana.max (daily pool) / .maxPerSpell / magic.dicePool - from MAGIC_POWER_TABLE */ export class CharacterData extends foundry.abstract.TypeDataModel { static defineSchema() { @@ -163,7 +163,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel { const a = sys.attributes; - a.health.max = 3 * (a.body.value + a.mind.value + a.soul.value); + a.health.max = 2 * (a.body.value + a.mind.value + a.soul.value); if (a.health.value > a.health.max) a.health.value = a.health.max; a.zeal.max = Math.ceil(a.soul.value / 2); @@ -190,7 +190,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel { const mp = getMagicPowerEntry(a.magic.actual); a.magic.dicePool = mp.dicePool; - a.mana.max = mp.manaPerRound; + a.mana.max = mp.manaPerDay; a.mana.maxPerSpell = mp.maxPerSpell; if (a.mana.value > a.mana.max) a.mana.value = a.mana.max; diff --git a/src/data/actor-npc.ts b/src/data/actor-npc.ts index 6d8c7d5..8077236 100644 --- a/src/data/actor-npc.ts +++ b/src/data/actor-npc.ts @@ -46,7 +46,8 @@ export class NpcData extends foundry.abstract.TypeDataModel { mind: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }), soul: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }), magic: new f.SchemaField({ - value: makeIntField(0, { min: 0, max: 10 }), + // Valid 1k10 character-creation values only (Atrybuty.md). + value: makeIntField(0, { choices: [0, 1, 2, 3, 4, 6] }), actual: makeIntField(0, { min: 0, max: 10 }), // derived }), mana: new f.SchemaField({ @@ -112,6 +113,7 @@ export class NpcData extends foundry.abstract.TypeDataModel { magicalShield: { value: number; max: number }; initiative: { value: number; bonus: number }; }; + skills: Record; }; const a = sys.attributes; @@ -134,7 +136,7 @@ export class NpcData extends foundry.abstract.TypeDataModel { // Derive NPC mana from MAGIC_POWER_TABLE const mp = getMagicPowerEntry(a.magic.actual); - a.mana.max = mp.manaPerRound; + a.mana.max = mp.manaPerDay; a.mana.maxPerSpell = mp.maxPerSpell; if (a.mana.value > a.mana.max) a.mana.value = a.mana.max; @@ -154,6 +156,8 @@ export class NpcData extends foundry.abstract.TypeDataModel { a.initiative.value = a.mind.value + + (sys.skills.reflex?.value ?? 0) + + (sys.skills.perception?.value ?? 0) + initiativeBonus + (a.initiative.bonus ?? 0); diff --git a/src/data/fields.ts b/src/data/fields.ts index da816ab..3c7f6d6 100644 --- a/src/data/fields.ts +++ b/src/data/fields.ts @@ -7,7 +7,7 @@ // Foundry exposes fields globally; declared via fvtt-types. export const fields = (): typeof foundry.data.fields => foundry.data.fields; -export function makeIntField(initial = 0, options: Partial<{ min: number; max: number; nullable: boolean }> = {}) { +export function makeIntField(initial = 0, options: Partial<{ min: number; max: number; nullable: boolean; choices: readonly number[] }> = {}) { const f = fields(); return new f.NumberField({ required: true, @@ -16,6 +16,7 @@ export function makeIntField(initial = 0, options: Partial<{ min: number; max: n initial, min: options.min, max: options.max, + choices: options.choices as number[] | undefined, }); } diff --git a/src/logic/combat.ts b/src/logic/combat.ts index ee3e84d..57c6c2f 100644 --- a/src/logic/combat.ts +++ b/src/logic/combat.ts @@ -1,6 +1,9 @@ /** - * Combat hooks - handle per-round Mana reset, per-turn Zeal regen, - * and condition-driven turn behavior (skip / damage tick / death save). + * Combat hooks - handle per-turn Zeal regen and condition-driven turn + * behavior (skip / damage tick / death save). + * + * Mana is a daily pool (see `constants.ts` MAGIC_POWER_TABLE), not a + * per-round budget, so it is intentionally NOT reset on `combatRound`. */ import { applyDamage } from './damage'; @@ -18,13 +21,7 @@ function hasStatus(actor: any, id: string): boolean { } export function registerCombatHooks(): void { - Hooks.on('combatRound', async (combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => { - for (const combatant of combat.combatants) { - const actor = combatant.actor; - if (!actor || actor.type !== 'character') continue; - const sys = actor.system as { attributes: { mana: { max: number; value: number } } }; - await actor.update({ 'system.attributes.mana.value': sys.attributes.mana.max }); - } + Hooks.on('combatRound', async (_combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => { ChatMessage.create({ content: `${game.i18n.localize('HBM.combat.newRound')}`, whisper: ChatMessage.getWhisperRecipients('GM'), diff --git a/src/logic/rest.ts b/src/logic/rest.ts index 1b2b4a5..7293e73 100644 --- a/src/logic/rest.ts +++ b/src/logic/rest.ts @@ -1,9 +1,13 @@ /** * Rest logic. * - * Short Rest (Krótki Odpoczynek): - * - Restore HP equal to actor.body.value (capped at max). - * - Restore mana to max-per-spell? - no: short rest does NOT restore mana. + * Breather (Odetchnięcie, 10 min): + * - Restore 1d6 + Wytrzymałość skill value HP (capped at max). + * - Restore 1d6 mana (capped at daily pool max). + * + * Short Rest (Krótki Odpoczynek, 1h+): + * - Restore half of max HP. + * - Restore one third of the daily mana pool. * - Holders of `Nadzwyczajna Odporność` (alchemy passive) restore 1 elixir tolerance. * * Long Rest (Długi Odpoczynek): @@ -66,8 +70,13 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise 0) { + update['system.attributes.mana.value'] = manaBefore + manaGain; + manaRestored = manaGain; + } } const endurance = actor.system.skills?.endurance?.value ?? 0; const roll = new Roll('1d6 + @endurance', { endurance }); @@ -80,10 +89,13 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise 0) { + update['system.attributes.mana.value'] = manaBefore + manaGain; + manaRestored = manaGain; + } } - const healAmount = Math.ceil((a.health?.max ?? 0) / 3); + const healAmount = Math.ceil((a.health?.max ?? 0) / 2); const heal = Math.min(healAmount, (a.health?.max ?? 0) - hpBefore); if (heal > 0) { update['system.attributes.health.value'] = hpBefore + heal; diff --git a/src/logic/spell-cast.ts b/src/logic/spell-cast.ts index b2a3fa5..0df846f 100644 --- a/src/logic/spell-cast.ts +++ b/src/logic/spell-cast.ts @@ -250,11 +250,13 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp await baseRoll.evaluate(); await baseRoll.toMessage({ flavor, speaker: opts.speaker }); - // Overcast - extra TS test if manaSpent exceeds maxPerSpell. + // Overcast beyond Maksymalny Koszt Zaklęcia - extra TS test. + // T depends on the spell's BASE cost; S grows triangularly with the excess + // (E) over maxPerSpell: S = E * (E + 1) / 2 (Zasada Nadczarowywania Zaklęć). if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) { const excess = manaSpent - a.mana.maxPerSpell; - const tThreshold = Math.min(6, Math.max(2, baseCost)); - const ySuccesses = Math.min(10, Math.max(1, excess)); + const tThreshold = baseCost <= 2 ? 4 : baseCost <= 4 ? 5 : 6; + const ySuccesses = (excess * (excess + 1)) / 2; const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`; const overcastRoll = HbmTSRoll.fromParams({ pool,