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>
This commit is contained in:
2026-08-23 01:15:38 +02:00
parent 0fe9dd02d6
commit 1fba0892b9
10 changed files with 345 additions and 396 deletions
+88 -68
View File
@@ -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<string, string> = {
@@ -55,15 +64,6 @@ const SUGGESTED_SKILLS: Record<string, string[]> = {
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<Pa
const docs: ParsedDoc[] = [];
for (const seed of DISCIPLINE_SEEDS) {
const baseSlug = ctx.idOverrides[slugify(seed.polishName)] ?? seed.id;
const description = lookupDescription(ctx.repoRoot, seed.polishName);
const description = lookupDescription(ctx.repoRoot, seed);
docs.push({
id: baseSlug,
name: seed.polishName,