feat: flat spell frontmatter, full source-book titles, drop circle

Parser reads human-friendly flat keys (range, targets, castingTime, overcast
list, threshold/successes, ...) and expands them to SpellData; fixes
[object Object] range, empty targets/casting time and blank overcast rows.
SOURCE_BOOKS ids now match vault slugs and show full book titles.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 12:35:16 +02:00
co-authored by Claude Sonnet 5.5
parent 5f8e62ba02
commit 6b8d93f772
14 changed files with 165 additions and 77 deletions
+8 -9
View File
@@ -105,7 +105,6 @@
"spell": { "spell": {
"label": "Spell", "label": "Spell",
"cast": "Cast spell", "cast": "Cast spell",
"circle": "Circle",
"school": "School", "school": "School",
"discipline": "Discipline", "discipline": "Discipline",
"difficulty": "Spell Threshold", "difficulty": "Spell Threshold",
@@ -200,14 +199,14 @@
"eros": "Eros" "eros": "Eros"
}, },
"sourceBook": { "sourceBook": {
"core-rules": "PG", "podrecznik-gry": "Podręcznik Gry",
"magic-book": "KM", "ksiega-magii": "Księga Magii",
"arcanum-sanguinis": "AS", "arcanum-sanguinis": "Arcanum Sanguinis",
"crimson-cult": "CS", "crimson-cult": "Szkarłatny Kult",
"abyss-curse": "KO", "klatwa-otchlani": "Klątwa Otchłani",
"humanity-guide": "PLM", "przewodnik": "Przewodnik",
"bestiary": "B", "bestiariusz": "Bestiariusz",
"gold-steel-magic": "ZSM" "zlote-stal-magia": "Złoto i Stal: Magia"
}, },
"aoeShape": { "aoeShape": {
"point": "Point", "point": "Point",
+8 -9
View File
@@ -105,7 +105,6 @@
"spell": { "spell": {
"label": "Zaklęcie", "label": "Zaklęcie",
"cast": "Rzuć zaklęcie", "cast": "Rzuć zaklęcie",
"circle": "Krąg",
"school": "Szkoła", "school": "Szkoła",
"discipline": "Dziedzina", "discipline": "Dziedzina",
"difficulty": "Próg zaklęcia", "difficulty": "Próg zaklęcia",
@@ -200,14 +199,14 @@
"eros": "Eros" "eros": "Eros"
}, },
"sourceBook": { "sourceBook": {
"core-rules": "PG", "podrecznik-gry": "Podręcznik Gry",
"magic-book": "KM", "ksiega-magii": "Księga Magii",
"arcanum-sanguinis": "AS", "arcanum-sanguinis": "Arcanum Sanguinis",
"crimson-cult": "CS", "crimson-cult": "Szkarłatny Kult",
"abyss-curse": "KO", "klatwa-otchlani": "Klątwa Otchłani",
"humanity-guide": "PLM", "przewodnik": "Przewodnik",
"bestiary": "B", "bestiariusz": "Bestiariusz",
"gold-steel-magic": "ZSM" "zlote-stal-magia": "Złoto i Stal: Magia"
}, },
"aoeShape": { "aoeShape": {
"point": "Punkt", "point": "Punkt",
+1
View File
@@ -1,3 +1,4 @@
// DEPRECATED: one-off extractor that writes the old nested spell frontmatter. The vault now uses the flat format read by scripts/parsers/spell-parser.ts - do not run this over ObsidianNotes/spells.
import { resolve, dirname } from 'node:path'; import { resolve, dirname } from 'node:path';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
+92 -43
View File
@@ -5,59 +5,109 @@ import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { slugify } from './helpers'; import { slugify } from './helpers';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
/** Looser zod schema - mirrors `SpellData` defaults; build pipeline converts to system payload. */ /**
const SpellSystemSchema = z.object({ * Flat, human-editable vault frontmatter for a spell. Nothing is nested;
* optional keys can simply be left out. `toSystem()` expands it into the
* (partly nested) Foundry `SpellData` shape.
*/
const SpellNoteSchema = z.object({
castingMode: z.enum(['standard', 'sacred', 'witch', 'blood']), castingMode: z.enum(['standard', 'sacred', 'witch', 'blood']),
school: z.string(), school: z.string(),
deity: z.string().default(''), deity: z.string().default(''),
sourceBook: z.string(), sourceBook: z.string(),
manaCost: z.number().int().min(0), manaCost: z.number().int().min(0),
bloodCost: z.number().int().min(0).default(0), bloodCost: z.number().int().min(0).default(0),
threshold: z.number().int().min(1).max(6),
successes: z.number().int().min(1),
complexityLevel: z.number().int().min(0).default(0), complexityLevel: z.number().int().min(0).default(0),
isSuperspell: z.boolean().default(false), isSuperspell: z.boolean().default(false),
requiresGroupCast: z.boolean().default(false), requiresGroupCast: z.boolean().default(false),
minCasters: z.number().int().min(1).default(1), minCasters: z.number().int().min(1).default(1),
nonCombatOnly: z.boolean().default(false), nonCombatOnly: z.boolean().default(false),
damageBase: z.string().default(''), verbal: z.boolean().default(false),
damageType: z.string().default(''), somatic: z.boolean().default(false),
material: z.coerce.string().default(''),
symbols: z.array(z.string()).default([]),
requiresRace: z.array(z.string()).default([]),
requiresTalent: z.array(z.string()).default([]),
requiresDiscipline: z.array(z.string()).default([]),
castingTime: z.coerce.string().default('Instant'),
range: z.coerce.string().default(''),
targets: z.coerce.string().default(''),
duration: z.coerce.string().default(''),
damageBase: z.coerce.string().default(''),
damageType: z.string().default('magical'),
ignoresArmor: z.boolean().default(false), ignoresArmor: z.boolean().default(false),
statusEffects: z.array(z.string()).default([]), statusEffects: z.array(z.string()).default([]),
saveAttribute: z.string().default(''), saveAttribute: z.string().default(''),
saveSkill: z.string().default(''), saveSkill: z.string().default(''),
triggers: z.array(z.object({ event: z.string(), effect: z.string() })).default([]), triggers: z.array(z.object({ event: z.string(), effect: z.string() })).default([]),
components: z.object({ overcast: z.array(z.string()).default([]),
verbal: z.boolean().default(false),
somatic: z.boolean().default(false),
material: z.string().default(''),
symbols: z.array(z.string()).default([]),
}),
requirements: z.object({
race: z.array(z.string()).default([]),
talent: z.array(z.string()).default([]),
discipline: z.array(z.string()).default([]),
}),
difficulty: z.object({
threshold: z.number().int().min(1).max(6),
successes: z.number().int().min(1),
}),
range: z.object({
kind: z.string(),
value: z.number().optional(),
unit: z.string().optional(),
text: z.string().optional(),
}),
duration: z.string().default(''),
castingTime: z.string().default(''),
target: z.string().default(''),
areaOfEffect: z
.object({ shape: z.string(), x: z.number().optional(), y: z.number().optional(), unit: z.string().optional() })
.nullable()
.default(null),
overcastOptions: z
.array(z.object({ text: z.string(), repeatable: z.boolean().default(true), cost: z.number().int().default(0) }))
.default([]),
description: z.string().default(''),
}); });
type SpellNote = z.infer<typeof SpellNoteSchema>;
type Aoe = { shape: string; x: number; y: number; unit: string };
/** Derive the structured area of effect from the free-text `targets` line. */
export function deriveAreaOfEffect(targets: string): Aoe {
const t = targets.replace(/\s+/g, ' ');
let m = t.match(/kwadrat o boku (\d+)\s*m/i);
if (m) return { shape: 'square', x: Number(m[1]), y: Number(m[1]), unit: 'm' };
m = t.match(/prostokąt (\d+)\s*m\s*[x×]\s*(\d+)\s*m/i);
if (m) return { shape: 'rectangle', x: Number(m[1]), y: Number(m[2]), unit: 'm' };
m = t.match(/(?:sfera|kula) o średnicy (\d+)\s*m/i);
if (m) return { shape: 'sphere', x: Number(m[1]) / 2, y: 0, unit: 'm' };
m = t.match(/(?:sfera|kula|kopuła) o promieniu (\d+)\s*m/i);
if (m) return { shape: 'sphere', x: Number(m[1]), y: 0, unit: 'm' };
if (/^linia/i.test(t)) return { shape: 'line', x: 0, y: 0, unit: 'm' };
return { shape: 'point', x: 0, y: 0, unit: 'm' };
}
/** Casting time in whole rounds / minutes, for the sheet's numeric fields. */
export function deriveCastingTime(text: string): { rounds: number; minutes: number } {
let m = text.match(/(\d+)\s*godzin/i);
if (m) return { rounds: 0, minutes: Number(m[1]) * 60 };
m = text.match(/(\d+)\s*minut/i);
if (m) return { rounds: 0, minutes: Number(m[1]) };
m = text.match(/(\d+)\s*rund/i);
if (m) return { rounds: Number(m[1]), minutes: 0 };
return { rounds: 0, minutes: 0 };
}
function toSystem(n: SpellNote): Record<string, unknown> {
const time = deriveCastingTime(n.castingTime);
return {
school: n.school,
deity: n.deity,
sourceBook: n.sourceBook,
castingMode: n.castingMode,
manaCost: n.manaCost,
bloodCost: n.bloodCost,
difficulty: { threshold: n.threshold, successes: n.successes },
components: { verbal: n.verbal, somatic: n.somatic, material: n.material, symbols: n.symbols },
castingTime: n.castingTime,
castingTimeRounds: time.rounds,
castingTimeMinutes: time.minutes,
range: n.range,
targets: n.targets,
duration: n.duration,
areaOfEffect: deriveAreaOfEffect(n.targets),
damageBase: n.damageBase,
damageType: n.damageType || 'magical',
ignoresArmor: n.ignoresArmor,
statusEffects: n.statusEffects,
saveAttribute: n.saveAttribute,
saveSkill: n.saveSkill,
requirements: { race: n.requiresRace, talent: n.requiresTalent, discipline: n.requiresDiscipline },
isSuperspell: n.isSuperspell,
requiresGroupCast: n.requiresGroupCast,
minCasters: n.minCasters,
nonCombatOnly: n.nonCombatOnly,
overcastOptions: n.overcast.map((description) => ({ description, manaPerStep: 1 })),
triggers: n.triggers,
complexityLevel: n.complexityLevel,
};
}
function getAllFiles(dirPath: string, arrayOfFiles: string[] = []) { function getAllFiles(dirPath: string, arrayOfFiles: string[] = []) {
const files = readdirSync(dirPath); const files = readdirSync(dirPath);
@@ -100,13 +150,12 @@ export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedD
continue; continue;
} }
const payload = { ...frontmatter, description: bodyRaw.trim() }; const body = bodyRaw.trim();
const nameMatch = body.match(/^#\s+(.+)$/m);
const nameMatch = payload.description.match(/^#\s+(.+)$/m);
const spellName = nameMatch ? nameMatch[1].trim() : basename(file, '.md'); const spellName = nameMatch ? nameMatch[1].trim() : basename(file, '.md');
payload.description = payload.description.replace(/^#\s+.+$/m, '').trim(); const description = body.replace(/^#\s+.+$/m, '').trim();
const parsed = SpellSystemSchema.safeParse(payload); const parsed = SpellNoteSchema.safeParse(frontmatter);
if (!parsed.success) { if (!parsed.success) {
if (ctx.strict) throw new Error(`[spell-parser] Validation failed for "${file}": ${parsed.error.message}`); if (ctx.strict) throw new Error(`[spell-parser] Validation failed for "${file}": ${parsed.error.message}`);
continue; continue;
@@ -129,8 +178,8 @@ export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedD
subType: 'spell', subType: 'spell',
pack, pack,
source: { book: parsed.data.sourceBook, chapter: 'Spells', line: 1 }, source: { book: parsed.data.sourceBook, chapter: 'Spells', line: 1 },
system: parsed.data, system: { ...toSystem(parsed.data), description },
description: payload.description, description,
}); });
} }
+3 -3
View File
@@ -160,9 +160,9 @@ export const WITCH_SYMBOLS: readonly string[] = Object.freeze([
/** Source-book provenance for compendium content. */ /** Source-book provenance for compendium content. */
export const SOURCE_BOOKS = [ export const SOURCE_BOOKS = [
'core-rules', 'magic-book', 'arcanum-sanguinis', 'podrecznik-gry', 'ksiega-magii', 'arcanum-sanguinis',
'crimson-cult', 'abyss-curse', 'humanity-guide', 'crimson-cult', 'klatwa-otchlani', 'przewodnik',
'bestiary', 'gold-steel-magic', 'bestiariusz', 'zlote-stal-magia',
] as const; ] as const;
export type SourceBook = (typeof SOURCE_BOOKS)[number]; export type SourceBook = (typeof SOURCE_BOOKS)[number];
-1
View File
@@ -23,7 +23,6 @@ export class SpellData extends foundry.abstract.TypeDataModel {
const f = fields(); const f = fields();
return { return {
// Identification // Identification
circle: makeIntField(1, { min: 0, max: 9 }),
school: makeStringField('', { blank: true, choices: SPELL_SCHOOLS as unknown as readonly string[] }), school: makeStringField('', { blank: true, choices: SPELL_SCHOOLS as unknown as readonly string[] }),
discipline: makeStringField(''), discipline: makeStringField(''),
deity: makeStringField('', { blank: true, choices: SACRED_DEITIES as unknown as readonly string[] }), deity: makeStringField('', { blank: true, choices: SACRED_DEITIES as unknown as readonly string[] }),
-1
View File
@@ -230,7 +230,6 @@ async function renderCastCard(
rolls: rollsHtml, rolls: rollsHtml,
mode, mode,
school: spell.system.school ?? '', school: spell.system.school ?? '',
circle: (spell.system as any).circle ?? 0,
outcome: { success }, outcome: { success },
costs, costs,
damage, damage,
+1 -1
View File
@@ -78,7 +78,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
.map(([school, items]) => ({ .map(([school, items]) => ({
school, school,
label: game.i18n.localize(`HBM.spellSchool.${school}`), label: game.i18n.localize(`HBM.spellSchool.${school}`),
items: items.sort((a: any, b: any) => (a.system?.circle ?? 0) - (b.system?.circle ?? 0)), items: items.sort((a: any, b: any) => a.name.localeCompare(b.name, 'pl')),
})) }))
.sort((a, b) => a.label.localeCompare(b.label, 'pl')); .sort((a, b) => a.label.localeCompare(b.label, 'pl'));
+1 -1
View File
@@ -49,7 +49,7 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const ctx = (await super._prepareContext(options)) as Record<string, unknown>; const ctx = (await super._prepareContext(options)) as Record<string, unknown>;
const actor = (this as unknown as { actor: { system: any; items: any[] } }).actor; const actor = (this as unknown as { actor: { system: any; items: any[] } }).actor;
const spells = actor.items.filter((it: any) => it.type === 'spell') const spells = actor.items.filter((it: any) => it.type === 'spell')
.sort((a: any, b: any) => (a.system?.circle ?? 0) - (b.system?.circle ?? 0) || a.name.localeCompare(b.name, 'pl')); .sort((a: any, b: any) => a.name.localeCompare(b.name, 'pl'));
const actorEffects = [...((actor as any).effects ?? [])].map((ef: any) => ({ const actorEffects = [...((actor as any).effects ?? [])].map((ef: any) => ({
id: ef.id, id: ef.id,
name: ef.name, name: ef.name,
+2 -3
View File
@@ -275,14 +275,13 @@
title="Wymagana rasa">🔒</span>{{/if}} title="Wymagana rasa">🔒</span>{{/if}}
</span> </span>
<span class="item-meta"> <span class="item-meta">
K{{spell.system.circle}} {{localize (concat 'HBM.spell.castingMode.' spell.system.castingMode)}}
· {{localize (concat 'HBM.spell.castingMode.' spell.system.castingMode)}}
· {{localize 'HBM.resources.mana'}}: {{spell.system.manaCost}} · {{localize 'HBM.resources.mana'}}: {{spell.system.manaCost}}
{{#if (eq spell.system.castingMode 'sacred')}}{{#if spell.system.deity}} · {{#if (eq spell.system.castingMode 'sacred')}}{{#if spell.system.deity}} ·
{{spell.system.deity}}{{/if}}{{/if}} {{spell.system.deity}}{{/if}}{{/if}}
{{#if (eq spell.system.castingMode 'blood')}} · krew: {{spell.system.bloodCost}}{{/if}} {{#if (eq spell.system.castingMode 'blood')}} · krew: {{spell.system.bloodCost}}{{/if}}
{{#if spell.system.sourceBook}} · <span {{#if spell.system.sourceBook}} · <span
class="source-pip">{{spell.system.sourceBook}}</span>{{/if}} class="source-pip">{{localize (concat 'HBM.sourceBook.' spell.system.sourceBook)}}</span>{{/if}}
</span> </span>
<div class="item-controls"> <div class="item-controls">
<button type="button" class="cast-btn" data-action="castSpell" data-item-id="{{spell.id}}" <button type="button" class="cast-btn" data-action="castSpell" data-item-id="{{spell.id}}"
+1 -2
View File
@@ -206,8 +206,7 @@
<div class="row spell-row"> <div class="row spell-row">
<span class="badge mode mode-{{sp.system.castingMode}}" <span class="badge mode mode-{{sp.system.castingMode}}"
title="{{localize (concat 'HBM.spell.castingMode.' sp.system.castingMode)}}">{{sp.system.castingMode}}</span> title="{{localize (concat 'HBM.spell.castingMode.' sp.system.castingMode)}}">{{sp.system.castingMode}}</span>
<span class="spell-name"><strong>{{sp.name}}</strong>{{#if sp.system.circle}} <span class="spell-name"><strong>{{sp.name}}</strong></span>
<small>K{{sp.system.circle}}</small>{{/if}}</span>
<button type="button" data-action="castSpell" data-item-id="{{sp.id}}" <button type="button" data-action="castSpell" data-item-id="{{sp.id}}"
title="{{localize 'HBM.spell.cast'}}">✦ {{localize 'HBM.spell.cast'}}</button> title="{{localize 'HBM.spell.cast'}}">✦ {{localize 'HBM.spell.cast'}}</button>
<button type="button" data-action="editItem" data-item-id="{{sp.id}}" <button type="button" data-action="editItem" data-item-id="{{sp.id}}"
-1
View File
@@ -4,7 +4,6 @@
<div class="hbm-card-meta"> <div class="hbm-card-meta">
{{#if mode}}<span class="badge mode mode-{{mode}}">{{localize (concat 'HBM.spell.castingMode.' mode)}}</span>{{/if}} {{#if mode}}<span class="badge mode mode-{{mode}}">{{localize (concat 'HBM.spell.castingMode.' mode)}}</span>{{/if}}
{{#if school}}<span class="badge school">{{localize (concat 'HBM.spellSchool.' school)}}</span>{{/if}} {{#if school}}<span class="badge school">{{localize (concat 'HBM.spellSchool.' school)}}</span>{{/if}}
{{#if circle}}<span class="badge circle">{{localize 'HBM.spell.circle'}} {{circle}}</span>{{/if}}
{{#if (or (eq mode "sacred") (eq mode "prayer"))}} {{#if (or (eq mode "sacred") (eq mode "prayer"))}}
<span class="badge warning">{{localize 'HBM.spellCast.cannotBeUnraveled'}}</span> <span class="badge warning">{{localize 'HBM.spellCast.cannotBeUnraveled'}}</span>
{{/if}} {{/if}}
-3
View File
@@ -4,9 +4,6 @@
<fieldset> <fieldset>
<legend>{{localize 'HBM.spell.label'}}</legend> <legend>{{localize 'HBM.spell.label'}}</legend>
<div class="row"> <div class="row">
<label>{{localize 'HBM.spell.circle'}}:
<input type="number" name="system.circle" value="{{system.circle}}" min="0" max="9" />
</label>
<label>{{localize 'HBM.spell.school'}}: <label>{{localize 'HBM.spell.school'}}:
<select name="system.school"> <select name="system.school">
{{#each @root.choices.schools as |label key|}} {{#each @root.choices.schools as |label key|}}
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { resolve } from 'node:path';
import { parseSpells, deriveAreaOfEffect, deriveCastingTime } from '../../scripts/parsers/spell-parser';
import { SOURCE_BOOKS } from '../../src/constants';
describe('spell-parser', () => {
it('expands the flat vault frontmatter into a Foundry-loadable system payload', async () => {
const ctx = { repoRoot: resolve(__dirname, '../../../..'), idOverrides: {}, strict: true } as any;
const docs = await parseSpells(ctx);
expect(docs.length).toBeGreaterThan(100);
const mm = docs.find((d) => d.id === 'magic-missile')!;
const s = mm.system as any;
expect(mm.name).toBe('Magiczny Pocisk');
expect(s.sourceBook).toBe('ksiega-magii');
expect(s.castingTime).toBe('Instant');
expect(s.range).toBe('15 m');
expect(s.targets).toBe('1');
expect(s.damageBase).toBe('magicalAbilities/2');
expect(s.damageType).toBe('magical');
expect(s.difficulty).toEqual({ threshold: 4, successes: 1 });
expect(s.overcastOptions).toEqual([{ description: '+1 punkt Obrażeń', manaPerStep: 1 }]);
expect(s).not.toHaveProperty('circle');
for (const d of docs) {
const sys = d.system as any;
// No object leaks into string fields (the "[object Object]" bug).
for (const k of ['range', 'targets', 'duration', 'castingTime', 'damageBase']) {
expect(typeof sys[k], `${d.id}.${k}`).toBe('string');
}
for (const o of sys.overcastOptions) expect(o.description, d.id).toBeTruthy();
expect(SOURCE_BOOKS as readonly string[], d.id).toContain(sys.sourceBook);
}
});
it('derives area of effect from the targets line', () => {
expect(deriveAreaOfEffect('Kwadrat o boku 5 m')).toMatchObject({ shape: 'square', x: 5, y: 5 });
expect(deriveAreaOfEffect('Prostokąt 3 m x 8 m. Dłuższy bok')).toMatchObject({ shape: 'rectangle', x: 3, y: 8 });
expect(deriveAreaOfEffect('Sfera o średnicy 100 m')).toMatchObject({ shape: 'sphere', x: 50 });
expect(deriveAreaOfEffect('1')).toMatchObject({ shape: 'point', x: 0 });
});
it('derives casting time numbers', () => {
expect(deriveCastingTime('Instant')).toEqual({ rounds: 0, minutes: 0 });
expect(deriveCastingTime('2 godziny')).toEqual({ rounds: 0, minutes: 120 });
expect(deriveCastingTime('10 minut')).toEqual({ rounds: 0, minutes: 10 });
});
});