Files
hbm-foundry-system/scripts/parsers/spell-parser.ts
T
octoturgeandClaude Sonnet 5.5 6b8d93f772 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>
2026-10-17 12:35:16 +02:00

188 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolve, basename } from 'node:path';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { z } from 'zod';
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { slugify } from './helpers';
import yaml from 'js-yaml';
/**
* 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),
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([]),
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);
for (const file of files) {
const fullPath = resolve(dirPath, file);
if (statSync(fullPath).isDirectory()) {
arrayOfFiles = getAllFiles(fullPath, arrayOfFiles);
} else if (file.endsWith('.md')) {
arrayOfFiles.push(fullPath);
}
}
return arrayOfFiles;
}
export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
const docs: ParsedDoc[] = [];
const spellsDir = resolve(ctx.repoRoot, 'ObsidianNotes', 'spells');
let files: string[] = [];
try {
files = getAllFiles(spellsDir);
} catch (e) {
console.warn('[spell-parser] No ObsidianNotes/spells directory found or unreadable.');
return docs;
}
for (const file of files) {
const text = readFileSync(file, 'utf8');
const match = text.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) continue;
const [_, frontmatterRaw, bodyRaw] = match;
let frontmatter: any;
try {
frontmatter = yaml.load(frontmatterRaw);
} catch (e) {
if (ctx.strict) throw new Error(`[spell-parser] Invalid YAML in ${file}: ${e}`);
continue;
}
const body = bodyRaw.trim();
const nameMatch = body.match(/^#\s+(.+)$/m);
const spellName = nameMatch ? nameMatch[1].trim() : basename(file, '.md');
const description = body.replace(/^#\s+.+$/m, '').trim();
const parsed = SpellNoteSchema.safeParse(frontmatter);
if (!parsed.success) {
if (ctx.strict) throw new Error(`[spell-parser] Validation failed for "${file}": ${parsed.error.message}`);
continue;
}
const baseId = basename(file, '.md');
let pack = 'spells-academic';
const relativePath = file.substring(spellsDir.length).replace(/\\/g, '/');
if (relativePath.includes('/general/')) pack = 'spells-general';
else if (relativePath.includes('/sacred/')) pack = 'spells-sacred';
else if (relativePath.includes('/eldritch/')) pack = 'spells-eldritch';
else if (relativePath.includes('/crimson/')) pack = 'spells-crimson';
else if (relativePath.includes('/blood/')) pack = 'spells-blood';
docs.push({
id: baseId,
name: spellName,
documentType: 'Item',
subType: 'spell',
pack,
source: { book: parsed.data.sourceBook, chapter: 'Spells', line: 1 },
system: { ...toSystem(parsed.data), description },
description,
});
}
return docs;
};