1 Commits

Author SHA1 Message Date
octoturge 1fba0892b9 Sync parsers and game mechanics with vault changes (2026-08-18 to 08-23)
- 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>
2026-08-23 01:15:38 +02:00
10 changed files with 345 additions and 396 deletions
+84 -64
View File
@@ -1,47 +1,56 @@
/** /**
* Discipline parser. Disciplines are identified by the entries in * Discipline parser. Disciplines are identified by the entries in
* `_label-mappings.json#disciplines`; we emit one stub Item per discipline * `DISCIPLINE_SEEDS`; we emit one stub Item per discipline with name,
* with name, source book, and an empty description (filled in later). * 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 * Before `bab5a62` these descriptions were read out of the monolithic
* discipline header in Podręcznik Gry - we capture the next non-empty line * rulebook files (`rules/01.*`, `rules/02.*`, `rules/00.*`) by matching a
* if it looks like a short ability description. * 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 { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types'; import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { normalizeKey, slugify } from './helpers'; import { slugify } from './helpers';
interface DisciplineSeed { interface DisciplineSeed {
id: string; id: string;
polishName: string; polishName: string;
pack: string; pack: string;
book: SourceBookId; 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[] = [ const DISCIPLINE_SEEDS: DisciplineSeed[] = [
{ id: 'alchemyTransmutation', polishName: 'Alchemia - Transmutacja', pack: 'disciplines', book: 'podrecznik-gry' }, { 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' }, { 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' }, { id: 'botany', polishName: 'Botanika', pack: 'disciplines', book: 'podrecznik-gry', descriptionFile: 'Botanika.md' },
{ id: 'elementsAir', polishName: 'Magia Żywiołów (Powietrze)', pack: 'disciplines', book: 'ksiega-magii' }, { 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' }, { 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' }, { 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' }, { 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' }, { 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' }, { id: 'golemancy', polishName: 'Golemancja', pack: 'disciplines', book: 'podrecznik-gry', descriptionFile: 'Golemancja.md' },
{ id: 'runes', polishName: 'Magia Runiczna', pack: 'disciplines', book: 'podrecznik-gry' }, { 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' }, { 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' }, { id: 'illusion', polishName: 'Magia Iluzji', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Magia Iluzji.md' },
{ id: 'sacred', polishName: 'Magia Sakralna', pack: 'disciplines', book: 'ksiega-magii' }, { 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' }, { 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' }, { 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' }, { id: 'necromancy', polishName: 'Nekromancja', pack: 'disciplines', book: 'ksiega-magii', descriptionFile: 'Nekromancja.md' },
{ id: 'blood', polishName: 'Magia Krwi', pack: 'disciplines-forbidden', book: 'arcanum-sanguinis' }, { 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' }, { 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' }, { 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' }, { 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' }, { id: 'wildWitch', polishName: 'Dzika Wiedźmia Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani', descriptionFile: 'Dzika Wiedźmia Magia.md' },
]; ];
const SUGGESTED_ATTRIBUTE: Record<string, string> = { const SUGGESTED_ATTRIBUTE: Record<string, string> = {
@@ -55,15 +64,6 @@ const SUGGESTED_SKILLS: Record<string, string[]> = {
blood: ['magicalAbilities'], 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. */ /** Skip YAML frontmatter (--- ... ---) at file start. */
function skipFrontmatter(lines: string[]): number { function skipFrontmatter(lines: string[]): number {
if (lines.length === 0 || !/^---\s*$/.test(lines[0].trim())) return 0; if (lines.length === 0 || !/^---\s*$/.test(lines[0].trim())) return 0;
@@ -73,40 +73,60 @@ function skipFrontmatter(lines: string[]): number {
return 0; return 0;
} }
function lookupDescription(repoRoot: string, polishName: string): string { /**
// Try split-book sources first (disciplines from Księga Magii or Klątwa Otchłani * Pull a description out of a `disciplines/` file: prefer the prose under
// now live in rules/01.* and rules/02.*). * a `## Definicja` or `## Wstęp` heading (the two intro-section labels the
const splitSources = [ * canonical write-ups use); fall back to the first prose paragraph before
resolve(repoRoot, 'ObsidianNotes/rules/01. Księga Magii.md'), * any heading (covers Poddziedzina sub-files, which usually skip straight
resolve(repoRoot, 'ObsidianNotes/rules/02. Klątwa Otchłani.md'), * to prose with no intro heading at all). Returns '' for frontmatter-only
resolve(repoRoot, 'ObsidianNotes/rules/00. Podręcznik Gry.md'), * stub files.
]; */
const target = normalizeKey(polishName.split('-')[0]); 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) { const introIdx = body.findIndex((l) => /^#{1,6}\s*(Definicja|Wstęp)\s*$/i.test(l.trim()));
let text = ''; if (introIdx >= 0) {
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[] = []; const buf: string[] = [];
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) { for (let i = introIdx + 1; i < body.length; i++) {
const cur = lines[j].trim(); const cur = body[i].trim();
if (cur === '' && buf.length > 0) break; if (HEADING.test(cur)) break;
if (cur && !/^_{3,}$/.test(cur) && !/^#{1,6}\s/.test(cur)) buf.push(cur); if (isNoise(cur)) continue;
buf.push(cur);
} }
if (buf.length > 0) return buf.join(' '); 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 ''; return '';
} }
@@ -114,7 +134,7 @@ export const parseDisciplines: ParserFn = async (ctx: ParserContext): Promise<Pa
const docs: ParsedDoc[] = []; const docs: ParsedDoc[] = [];
for (const seed of DISCIPLINE_SEEDS) { for (const seed of DISCIPLINE_SEEDS) {
const baseSlug = ctx.idOverrides[slugify(seed.polishName)] ?? seed.id; const baseSlug = ctx.idOverrides[slugify(seed.polishName)] ?? seed.id;
const description = lookupDescription(ctx.repoRoot, seed.polishName); const description = lookupDescription(ctx.repoRoot, seed);
docs.push({ docs.push({
id: baseSlug, id: baseSlug,
name: seed.polishName, name: seed.polishName,
+86 -77
View File
@@ -1,129 +1,138 @@
/** /**
* Race parser. Races in HbM live in Podręcznik Gry, Rozdział II. * Race parser. Since the `bab5a62` vault split, the playable races' Cechy
* Rasowe (mechanical stats: attribute points, available disciplines, free
* talents) each live in their own character-creation file:
* *
* Format: * rules/00. Podręcznik Gry/Rozdział II - Tworzenie Postaci/Wybór Rasy - <Name>.md
* Race Name (optional /Variant) *
* 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/<Name>.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):
* <flavor paragraphs> * <flavor paragraphs>
* *
* Cechy Rasowe: * Cechy Rasowe:
* * Dostępne Dziedziny Magii: * * Dostępne Dziedziny Magii:
* * <discipline 1> * * <discipline 1>
* * <discipline 2>
* * N Punktów Atrybutów * * N Punktów Atrybutów
* * Darmowe Talenty: * * Darmowe Talenty:
* * <talent 1> * * <talent 1>
* * <talent 2>
* * 1 Punkt Umiejętności w <skill> * * 1 Punkt Umiejętności w <skill>
* * N Punktów Umiejętności * * N Punktów Umiejętności
* ________________
*/ */
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn } from './types'; 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 RACE_FEATURES_HEADER = /^Cechy Rasowe:\s*$/;
const FRONTMATTER_FENCE = /^---\s*$/;
const SOURCE_FILE = '_books/HbM RPG v3 - Podręcznik Gry.md'; interface RaceSource {
const KNOWN_RACES = new Set([ name: string;
'człowiek', /** Relative to `rules/00. Podręcznik Gry/Rozdział II - Tworzenie Postaci/`. */
'elf', mechanicalFile: string;
'feles (kotowate)', /** Relative to `ObsidianNotes/races/`. */
'feles', descriptionFile: string;
'lamia (naga)', }
'lamia',
'anioł/anielica', const CHARACTER_CREATION_DIR = 'ObsidianNotes/rules/00. Podręcznik Gry/Rozdział II - Tworzenie Postaci';
'anioł',
'krasnolud', const RACE_SOURCES: RaceSource[] = [
'demon', { name: 'Człowiek', mechanicalFile: 'Wybór Rasy - Człowiek.md', descriptionFile: 'Człowiek.md' },
'malferianin', { 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<ParsedDoc[]> => { export const parseRaces: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = []; const docs: ParsedDoc[] = [];
const path = resolve(ctx.repoRoot, SOURCE_FILE);
let lines: string[] = []; for (const source of RACE_SOURCES) {
const path = resolve(ctx.repoRoot, CHARACTER_CREATION_DIR, source.mechanicalFile);
let lines: string[];
try { try {
lines = readFileSync(path, 'utf8').split(/\r?\n/); lines = readFileSync(path, 'utf8').split(/\r?\n/);
} catch { } catch {
return docs; console.warn(`[race-parser] file not found: ${source.mechanicalFile}`);
} continue;
}
// Find each `Cechy Rasowe:` block; the race name is the most recent const body = lines.slice(skipFrontmatter(lines));
// non-empty, non-separator line above the block that matches a known race.
for (let i = 0; i < lines.length; i++) { const headerIdx = body.findIndex((l) => RACE_FEATURES_HEADER.test(l.trim()));
if (!RACE_FEATURES_HEADER.test(lines[i].trim())) continue; if (headerIdx < 0) {
console.warn(`[race-parser] no "Cechy Rasowe:" block in ${source.mechanicalFile}`);
// Walk up looking for the race name + flavor paragraphs. continue;
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 bulletLines = body.slice(headerIdx + 1).map((l) => l.trimEnd());
const parsed = parseRaceFeatures(bulletLines); 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; const id = ctx.idOverrides[baseSlug] ?? baseSlug;
docs.push({ docs.push({
id, id,
name: stripVariant(nameLine), name: source.name,
documentType: 'Item', documentType: 'Item',
subType: 'race', subType: 'race',
pack: 'races', pack: 'races',
source: { book: 'podrecznik-gry', chapter: 'Tworzenie Postaci', line: nameIdx + 1 }, source: { book: 'podrecznik-gry', chapter: 'Tworzenie Postaci' },
system: { system: {
availableDisciplines: parsed.disciplines, availableDisciplines: parsed.disciplines,
attributePoints: parsed.attributePoints, attributePoints: parsed.attributePoints,
skillPoints: parsed.skillPoints, skillPoints: parsed.skillPoints,
freeTalents: parsed.freeTalents, freeTalents: parsed.freeTalents,
racialAbilities: [], racialAbilities: [],
physicalDescription, physicalDescription: description,
description: physicalDescription, description,
}, },
description: physicalDescription, description,
}); });
} }
return docs; return docs;
}; };
function stripVariant(name: string): string {
// "Anioł/Anielica" → "Anioł"; "Feles (Kotowate)" → "Feles"
return name.split('/')[0].split('(')[0].trim();
}
interface ParsedRaceFeatures { interface ParsedRaceFeatures {
disciplines: string[]; disciplines: string[];
attributePoints: number; attributePoints: number;
@@ -167,7 +176,7 @@ function parseRaceFeatures(lines: string[]): ParsedRaceFeatures {
continue; continue;
} }
// Indented child bullet ` * xxx` (Google Docs export uses tabs/spaces). // Indented child bullet ` * xxx` (leading tabs/spaces).
const child = line.match(/^\s+\*\s+(.+)$/); const child = line.match(/^\s+\*\s+(.+)$/);
if (!child) continue; if (!child) continue;
const text = child[1].trim(); const text = child[1].trim();
+109 -207
View File
@@ -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 * rules/00. Podręcznik Gry/Rozdział IV - Talenty/<Name>.md
* Wymagania: req1, req2 … (optional; single line) * rules/06. Bestiariusz/Rozdział III - Talenty/<Name>.md
* <description paragraphs> * rules/04. Arcanum Sanguinis/Rozdział IV - Talenty/<Name>.md
* ________________ * rules/02. Klątwa Otchłani/Rozdział II - Talenty/<Name>.md
* *
* They live in: * Each file is: YAML frontmatter, an optional `Wymagania: ...` /
* - Podręcznik Gry, "Rozdział IV - Talenty" * `**Wymagania**: ...` line anywhere in the body, and the rest is
* - Bestiariusz, "Rozdział III - Talenty" (NPC-only talents) * description prose. The file's own name is the talent name.
*
* 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 { readFileSync, readdirSync, existsSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve, extname, basename } from 'node:path';
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types'; import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { slugify } from './helpers'; import { slugify } from './helpers';
const SEPARATOR = /^_{3,}$/; const REQUIREMENTS_LINE = /^(?:\*\*|\*)?Wymagania(?:\*\*|\*)?\s*:\s*(.+)$/i;
const REQUIREMENTS_PREFIX = /^(?:\*\*|\*)?Wymagania:(?:\*\*|\*)?\s*(.+)$/i; const FRONTMATTER_FENCE = /^---\s*$/;
interface TalentSource { interface TalentFolderSource {
book: SourceBookId; book: SourceBookId;
file: string; /** Folder path relative to repoRoot, one talent per .md file. */
/** Inclusive line range (1-based) of the talent chapter. */ folder: string;
startMarker: RegExp;
endMarker: RegExp;
/** Pack id to write into. */ /** Pack id to write into. */
pack: string; pack: string;
/** Optional source-module flag stamped onto each talent. */ /** Optional source-module flag stamped onto each talent. */
sourceModule?: 'arcanum-sanguinis' | 'abyss-curse' | 'crimson-cult' | null; sourceModule?: 'arcanum-sanguinis' | 'abyss-curse' | 'crimson-cult' | null;
} }
const TALENT_SOURCES: TalentSource[] = [ const TALENT_FOLDER_SOURCES: TalentFolderSource[] = [
{ {
book: 'podrecznik-gry', book: 'podrecznik-gry',
file: 'ObsidianNotes/rules/00. Podręcznik Gry.md', folder: 'ObsidianNotes/rules/00. Podręcznik Gry/Rozdział IV - Talenty',
startMarker: /^Rozdział IV - Talenty\s*$/,
endMarker: /^Rozdział V\b/,
pack: 'talents', pack: 'talents',
sourceModule: null, sourceModule: null,
}, },
{ {
book: 'bestiariusz', book: 'bestiariusz',
file: 'ObsidianNotes/rules/06. Bestiariusz.md', folder: 'ObsidianNotes/rules/06. Bestiariusz/Rozdział III - Talenty',
startMarker: /^Rozdział III - Talenty\s*$/,
endMarker: /^Rozdział IV\b/,
pack: 'talents-npc', pack: 'talents-npc',
sourceModule: null, sourceModule: null,
}, },
{ {
// Arcanum Sanguinis: now lives in ObsidianNotes/rules/04. Arcanum Sanguinis.md.
// Headings may be prefixed with `## ` or `### `.
book: 'arcanum-sanguinis', book: 'arcanum-sanguinis',
file: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md', folder: 'ObsidianNotes/rules/04. Arcanum Sanguinis/Rozdział IV - Talenty',
startMarker: /^#{0,4}\s*Rozdział IV - Talenty\s*$/,
endMarker: /^#{0,4}\s*(Rozdział V\b|Aneks\b)/,
pack: 'talents-blood', pack: 'talents-blood',
sourceModule: 'arcanum-sanguinis', sourceModule: 'arcanum-sanguinis',
}, },
{ {
// Klątwa Otchłani: now lives in ObsidianNotes/rules/02. Klątwa Otchłani.md.
book: 'klatwa-otchlani', book: 'klatwa-otchlani',
file: 'ObsidianNotes/rules/02. Klątwa Otchłani.md', folder: 'ObsidianNotes/rules/02. Klątwa Otchłani/Rozdział II - Talenty',
startMarker: /^#{0,4}\s*Rozdział II - Talenty\s*$/,
endMarker: /^#{0,4}\s*Rozdział III\b/,
pack: 'talents-eldritch', pack: 'talents-eldritch',
sourceModule: 'abyss-curse', sourceModule: 'abyss-curse',
}, },
]; ];
/** /** Skip YAML frontmatter (--- ... ---) at file start. Returns start line index. */
* Talent chapters are noisy - split into segments by separator lines, then function skipFrontmatter(lines: string[]): number {
* within each segment find consecutive talent blocks. A talent block is: if (lines.length === 0 || !FRONTMATTER_FENCE.test(lines[0].trim())) return 0;
* - first non-empty line = name (short, no trailing punctuation, no colon) for (let i = 1; i < lines.length; i++) {
* - optional `Wymagania: ...` line if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1;
* - subsequent lines until next name candidate or end of segment = description }
* return 0;
* Heuristic for name detection: line is short (< 60 chars), no trailing `.`, }
* not a bullet, not a header (`Rozdział`, `Aneks`).
*/ /** 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[]> => { export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = []; const docs: ParsedDoc[] = [];
for (const source of TALENT_SOURCES) { for (const source of TALENT_FOLDER_SOURCES) {
const path = resolve(ctx.repoRoot, source.file); const dirPath = resolve(ctx.repoRoot, source.folder);
let lines: string[] = []; if (!existsSync(dirPath)) {
try { console.warn(`[talent-parser] folder not found: ${source.folder}`);
lines = readFileSync(path, 'utf8').split(/\r?\n/);
} catch {
continue; continue;
} }
const startIdx = lines.findIndex((l) => source.startMarker.test(l.trim())); const files = readdirSync(dirPath).filter((f) => extname(f) === '.md');
if (startIdx < 0) continue; for (const file of files) {
const endIdx = lines.findIndex((l, i) => i > startIdx && source.endMarker.test(l.trim())); let finalName = basename(file, '.md');
const slice = lines.slice(startIdx + 1, endIdx > 0 ? endIdx : lines.length); if (finalName === 'Czuły Zmysł') {
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ł)'; finalName = 'Czuły Zmysł (Zmysł)';
} }
const { requirements, description } = parseTalentBody(resolve(dirPath, file));
const baseSlug = slugify(finalName); const baseSlug = slugify(finalName);
const id = ctx.idOverrides[baseSlug] ?? baseSlug; const id = ctx.idOverrides[baseSlug] ?? baseSlug;
const description = descLines.join('\n').trim();
// Detect "(Dziedzina)" / "(Bóstwo)" / similar parameterised talents. // Detect "(Dziedzina)" / "(Bóstwo)" / similar parameterised talents.
const multiSelect = /\((Dziedzina|Bóstwo|Zmysł|Atrybut|Umiejętność)\)$/i.test(finalName); const multiSelect = /\((Dziedzina|Bóstwo|Zmysł|Atrybut|Umiejętność)\)$/i.test(finalName);
@@ -190,7 +115,7 @@ export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<Parsed
documentType: 'Item', documentType: 'Item',
subType: 'talent', subType: 'talent',
pack: source.pack, pack: source.pack,
source: { book: source.book, chapter: 'Talenty', line: startIdx + 1 }, source: { book: source.book, chapter: 'Talenty' },
system: { system: {
requirements: parseRequirements(requirements, ctx), requirements: parseRequirements(requirements, ctx),
multiSelect, multiSelect,
@@ -211,70 +136,67 @@ export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<Parsed
return docs; 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[]> { async function parseDisciplineTalents(ctx: ParserContext): Promise<ParsedDoc[]> {
const docs: ParsedDoc[] = []; const docs: ParsedDoc[] = [];
const path = resolve(ctx.repoRoot, 'ObsidianNotes/rules/00. Podr\u0119cznik Gry.md'); 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[] = []; let lines: string[] = [];
try { try {
lines = readFileSync(path, 'utf8').split(/\r?\n/); lines = readFileSync(path, 'utf8').split(/\r?\n/);
} catch { } catch {
return []; continue;
} }
const body = lines.slice(skipFrontmatter(lines));
const startIdx = lines.findIndex((l) => /^\s*Wyb[o\u00f3\u0143A3\ufffd]r Dziedziny Magii\s*$/i.test(l)); for (let i = 0; i < body.length; i++) {
if (startIdx < 0) return []; const headingMatch = body[i].trim().match(HEADING_LINE);
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)); if (!headingMatch) continue;
const slice = lines.slice(startIdx + 1, endIdx > 0 ? endIdx : lines.length); const talentIndex = cleanNames.indexOf(slugify(headingMatch[1].trim()));
if (talentIndex < 0) continue;
const talentNames = [ const name = DISCIPLINE_TALENT_NAMES[talentIndex];
'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'
];
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 descLines: string[] = []; const descLines: string[] = [];
let j = i + 1; let j = i + 1;
while (j < slice.length) { while (j < body.length && !HEADING_LINE.test(body[j].trim())) {
const nextLine = slice[j].trim(); descLines.push(body[j]);
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]);
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 baseSlug = slugify(name);
const id = ctx.idOverrides[baseSlug] ?? baseSlug; const id = ctx.idOverrides[baseSlug] ?? baseSlug;
@@ -284,7 +206,7 @@ async function parseDisciplineTalents(ctx: ParserContext): Promise<ParsedDoc[]>
documentType: 'Item', documentType: 'Item',
subType: 'talent', subType: 'talent',
pack: 'talents', 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: { system: {
requirements: { race: '', attribute: '', skill: '', talent: '', title: '', discipline: '' }, requirements: { race: '', attribute: '', skill: '', talent: '', title: '', discipline: '' },
multiSelect: false, multiSelect: false,
@@ -303,26 +225,6 @@ async function parseDisciplineTalents(ctx: ParserContext): Promise<ParsedDoc[]>
return docs; 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> { function parseRequirements(text: string, _ctx: ParserContext): Record<string, unknown> {
// Keep raw + crude extraction. Detailed parsing can come later. // Keep raw + crude extraction. Detailed parsing can come later.
if (!text) { if (!text) {
+15 -13
View File
@@ -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). * 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 { export interface MagicPowerEntry {
level: number; // 0..10 level: number; // 0..10
label: string; // '0' | 'I' | 'II' | ... | 'X' label: string; // '0' | 'I' | 'II' | ... | 'X'
dicePool: number; // dice added when casting dicePool: number; // dice added when casting
maxPerSpell: number; // max mana spent on one spell 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([ export const MAGIC_POWER_TABLE: readonly MagicPowerEntry[] = Object.freeze([
{ level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerRound: 20 }, { level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerDay: 60 },
{ level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerRound: 12 }, { level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerDay: 36 },
{ level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerRound: 10 }, { level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerDay: 30 },
{ level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerRound: 7 }, { level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerDay: 21 },
{ level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerRound: 8 }, { level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerDay: 24 },
{ level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerRound: 6 }, { level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerDay: 18 },
{ level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerRound: 6 }, { level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerDay: 18 },
{ level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerRound: 4 }, { level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerDay: 12 },
{ level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerRound: 3 }, { level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerDay: 9 },
{ level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerRound: 2 }, { level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerDay: 6 },
{ level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerRound: 1 }, { level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerDay: 3 },
]); ]);
export function getMagicPowerEntry(level: number): MagicPowerEntry { export function getMagicPowerEntry(level: number): MagicPowerEntry {
+4 -4
View File
@@ -12,10 +12,10 @@ import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from
* Player Character data model. * Player Character data model.
* *
* Derived stats (computed in prepareDerivedData): * 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.zeal.max = ceil(soul / 2)
* - attributes.initiative = mind + skills.reflex.value + skills.perception.value * - 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 { export class CharacterData extends foundry.abstract.TypeDataModel {
static defineSchema() { static defineSchema() {
@@ -163,7 +163,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
const a = sys.attributes; 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; if (a.health.value > a.health.max) a.health.value = a.health.max;
a.zeal.max = Math.ceil(a.soul.value / 2); 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); const mp = getMagicPowerEntry(a.magic.actual);
a.magic.dicePool = mp.dicePool; a.magic.dicePool = mp.dicePool;
a.mana.max = mp.manaPerRound; a.mana.max = mp.manaPerDay;
a.mana.maxPerSpell = mp.maxPerSpell; a.mana.maxPerSpell = mp.maxPerSpell;
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max; if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
+6 -2
View File
@@ -46,7 +46,8 @@ export class NpcData extends foundry.abstract.TypeDataModel {
mind: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }), mind: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
soul: 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({ 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 actual: makeIntField(0, { min: 0, max: 10 }), // derived
}), }),
mana: new f.SchemaField({ mana: new f.SchemaField({
@@ -112,6 +113,7 @@ export class NpcData extends foundry.abstract.TypeDataModel {
magicalShield: { value: number; max: number }; magicalShield: { value: number; max: number };
initiative: { value: number; bonus: number }; initiative: { value: number; bonus: number };
}; };
skills: Record<string, { value: number }>;
}; };
const a = sys.attributes; const a = sys.attributes;
@@ -134,7 +136,7 @@ export class NpcData extends foundry.abstract.TypeDataModel {
// Derive NPC mana from MAGIC_POWER_TABLE // Derive NPC mana from MAGIC_POWER_TABLE
const mp = getMagicPowerEntry(a.magic.actual); const mp = getMagicPowerEntry(a.magic.actual);
a.mana.max = mp.manaPerRound; a.mana.max = mp.manaPerDay;
a.mana.maxPerSpell = mp.maxPerSpell; a.mana.maxPerSpell = mp.maxPerSpell;
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max; 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.initiative.value =
a.mind.value + a.mind.value +
(sys.skills.reflex?.value ?? 0) +
(sys.skills.perception?.value ?? 0) +
initiativeBonus + initiativeBonus +
(a.initiative.bonus ?? 0); (a.initiative.bonus ?? 0);
+2 -1
View File
@@ -7,7 +7,7 @@
// Foundry exposes fields globally; declared via fvtt-types. // Foundry exposes fields globally; declared via fvtt-types.
export const fields = (): typeof foundry.data.fields => foundry.data.fields; 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(); const f = fields();
return new f.NumberField({ return new f.NumberField({
required: true, required: true,
@@ -16,6 +16,7 @@ export function makeIntField(initial = 0, options: Partial<{ min: number; max: n
initial, initial,
min: options.min, min: options.min,
max: options.max, max: options.max,
choices: options.choices as number[] | undefined,
}); });
} }
+6 -9
View File
@@ -1,6 +1,9 @@
/** /**
* Combat hooks - handle per-round Mana reset, per-turn Zeal regen, * Combat hooks - handle per-turn Zeal regen and condition-driven turn
* and condition-driven turn behavior (skip / damage tick / death save). * 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'; import { applyDamage } from './damage';
@@ -18,13 +21,7 @@ function hasStatus(actor: any, id: string): boolean {
} }
export function registerCombatHooks(): void { export function registerCombatHooks(): void {
Hooks.on('combatRound', async (combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => { 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 });
}
ChatMessage.create({ ChatMessage.create({
content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`, content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`,
whisper: ChatMessage.getWhisperRecipients('GM'), whisper: ChatMessage.getWhisperRecipients('GM'),
+20 -8
View File
@@ -1,9 +1,13 @@
/** /**
* Rest logic. * Rest logic.
* *
* Short Rest (Krótki Odpoczynek): * Breather (Odetchnięcie, 10 min):
* - Restore HP equal to actor.body.value (capped at max). * - Restore 1d6 + Wytrzymałość skill value HP (capped at max).
* - Restore mana to max-per-spell? - no: short rest does NOT restore mana. * - 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. * - Holders of `Nadzwyczajna Odporność` (alchemy passive) restore 1 elixir tolerance.
* *
* Long Rest (Długi Odpoczynek): * Long Rest (Długi Odpoczynek):
@@ -66,8 +70,13 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
if (kind === 'breather') { if (kind === 'breather') {
if (a.mana?.max != null && manaBefore < a.mana.max) { if (a.mana?.max != null && manaBefore < a.mana.max) {
update['system.attributes.mana.value'] = a.mana.max; const manaRoll = new Roll('1d6');
manaRestored = a.mana.max - manaBefore; await manaRoll.evaluate();
const manaGain = Math.min(manaRoll.total, a.mana.max - manaBefore);
if (manaGain > 0) {
update['system.attributes.mana.value'] = manaBefore + manaGain;
manaRestored = manaGain;
}
} }
const endurance = actor.system.skills?.endurance?.value ?? 0; const endurance = actor.system.skills?.endurance?.value ?? 0;
const roll = new Roll('1d6 + @endurance', { endurance }); const roll = new Roll('1d6 + @endurance', { endurance });
@@ -80,10 +89,13 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
} }
} else if (kind === 'short') { } else if (kind === 'short') {
if (a.mana?.max != null && manaBefore < a.mana.max) { if (a.mana?.max != null && manaBefore < a.mana.max) {
update['system.attributes.mana.value'] = a.mana.max; const manaGain = Math.min(Math.ceil(a.mana.max / 3), a.mana.max - manaBefore);
manaRestored = a.mana.max - manaBefore; if (manaGain > 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); const heal = Math.min(healAmount, (a.health?.max ?? 0) - hpBefore);
if (heal > 0) { if (heal > 0) {
update['system.attributes.health.value'] = hpBefore + heal; update['system.attributes.health.value'] = hpBefore + heal;
+5 -3
View File
@@ -250,11 +250,13 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
await baseRoll.evaluate(); await baseRoll.evaluate();
await baseRoll.toMessage({ flavor, speaker: opts.speaker }); 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) { if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) {
const excess = manaSpent - a.mana.maxPerSpell; const excess = manaSpent - a.mana.maxPerSpell;
const tThreshold = Math.min(6, Math.max(2, baseCost)); const tThreshold = baseCost <= 2 ? 4 : baseCost <= 4 ? 5 : 6;
const ySuccesses = Math.min(10, Math.max(1, excess)); const ySuccesses = (excess * (excess + 1)) / 2;
const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`; const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`;
const overcastRoll = HbmTSRoll.fromParams({ const overcastRoll = HbmTSRoll.fromParams({
pool, pool,