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:
+8
-9
@@ -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
@@ -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,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';
|
||||||
|
|||||||
@@ -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
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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[] }),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}}"
|
||||||
|
|||||||
@@ -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}}"
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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|}}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user