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
+92 -43
View File
@@ -5,59 +5,109 @@ import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { slugify } from './helpers';
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']),
school: z.string(),
deity: z.string().default(''),
sourceBook: z.string(),
manaCost: z.number().int().min(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),
isSuperspell: z.boolean().default(false),
requiresGroupCast: z.boolean().default(false),
minCasters: z.number().int().min(1).default(1),
nonCombatOnly: z.boolean().default(false),
damageBase: z.string().default(''),
damageType: z.string().default(''),
verbal: z.boolean().default(false),
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),
statusEffects: z.array(z.string()).default([]),
saveAttribute: z.string().default(''),
saveSkill: z.string().default(''),
triggers: z.array(z.object({ event: z.string(), effect: z.string() })).default([]),
components: z.object({
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(''),
overcast: z.array(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[] = []) {
const files = readdirSync(dirPath);
@@ -100,13 +150,12 @@ export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedD
continue;
}
const payload = { ...frontmatter, description: bodyRaw.trim() };
const nameMatch = payload.description.match(/^#\s+(.+)$/m);
const body = bodyRaw.trim();
const nameMatch = body.match(/^#\s+(.+)$/m);
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 (ctx.strict) throw new Error(`[spell-parser] Validation failed for "${file}": ${parsed.error.message}`);
continue;
@@ -129,8 +178,8 @@ export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedD
subType: 'spell',
pack,
source: { book: parsed.data.sourceBook, chapter: 'Spells', line: 1 },
system: parsed.data,
description: payload.description,
system: { ...toSystem(parsed.data), description },
description,
});
}