Implement the 030-bulk-import-sources feature that adds a one-click bulk import button to load all bestiary sources at once, with real-time progress feedback in the side panel and a toast notification when the panel is closed, plus completion/failure reporting with auto-dismiss on success and persistent display on partial failure, while also hardening the bestiary normalizer to handle variable stat blocks (spell summons with special AC/HP/CR) and skip malformed monster entries gracefully

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-10 23:29:34 +01:00
parent c323adc343
commit 94d125d9c4
14 changed files with 850 additions and 106 deletions

View File

@@ -17,8 +17,8 @@ interface RawMonster {
size: string[];
type: string | { type: string; tags?: string[]; swarmSize?: string };
alignment?: string[];
ac: (number | { ac: number; from?: string[] })[];
hp: { average: number; formula: string };
ac: (number | { ac: number; from?: string[] } | { special: string })[];
hp: { average?: number; formula?: string; special?: string };
speed: Record<
string,
number | { number: number; condition?: string } | boolean
@@ -38,7 +38,7 @@ interface RawMonster {
vulnerable?: (string | { special: string })[];
conditionImmune?: string[];
languages?: string[];
cr: string | { cr: string };
cr?: string | { cr: string };
trait?: RawEntry[];
action?: RawEntry[];
bonus?: RawEntry[];
@@ -140,7 +140,12 @@ function formatType(
let result = baseType;
if (type.tags && type.tags.length > 0) {
result += ` (${type.tags.map(capitalize).join(", ")})`;
const tagStrs = type.tags
.filter((t): t is string => typeof t === "string")
.map(capitalize);
if (tagStrs.length > 0) {
result += ` (${tagStrs.join(", ")})`;
}
}
if (type.swarmSize) {
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
@@ -161,6 +166,14 @@ function extractAc(ac: RawMonster["ac"]): {
if (typeof first === "number") {
return { value: first };
}
if ("special" in first) {
// Variable AC (e.g. spell summons) — parse leading number if possible
const match = first.special.match(/^(\d+)/);
return {
value: match ? Number(match[1]) : 0,
source: first.special,
};
}
return {
value: first.ac,
source: first.from ? stripTags(first.from.join(", ")) : undefined,
@@ -339,7 +352,8 @@ function normalizeLegendary(
};
}
function extractCr(cr: string | { cr: string }): string {
function extractCr(cr: string | { cr: string } | undefined): string {
if (cr === undefined) return "—";
return typeof cr === "string" ? cr : cr.cr;
}
@@ -355,60 +369,81 @@ function makeCreatureId(source: string, name: string): CreatureId {
* Normalizes raw 5etools bestiary JSON into domain Creature[].
*/
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
// Filter out _copy entries — these reference another source's monster
// and lack their own stats (ac, hp, cr, etc.)
const monsters = raw.monster.filter(
// Filter out _copy entries (reference another source's monster) and
// monsters missing required fields (ac, hp, size, type)
const monsters = raw.monster.filter((m) => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
(m) => !(m as any)._copy,
);
return monsters.map((m) => {
const crStr = extractCr(m.cr);
const ac = extractAc(m.ac);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
size: formatSize(m.size),
type: formatType(m.type),
alignment: formatAlignment(m.alignment),
ac: ac.value,
acSource: ac.source,
hp: { average: m.hp.average, formula: m.hp.formula },
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
dex: m.dex,
con: m.con,
int: m.int,
wis: m.wis,
cha: m.cha,
},
cr: crStr,
initiativeProficiency: m.initiative?.proficiency ?? 0,
proficiencyBonus: proficiencyBonus(crStr),
passive: m.passive,
savingThrows: formatSaves(m.save),
skills: formatSkills(m.skill),
resist: formatDamageList(m.resist),
immune: formatDamageList(m.immune),
vulnerable: formatDamageList(m.vulnerable),
conditionImmune: formatConditionImmunities(m.conditionImmune),
senses:
m.senses && m.senses.length > 0
? m.senses.map((s) => stripTags(s)).join(", ")
: undefined,
languages:
m.languages && m.languages.length > 0
? m.languages.join(", ")
: undefined,
traits: normalizeTraits(m.trait),
actions: normalizeTraits(m.action),
bonusActions: normalizeTraits(m.bonus),
reactions: normalizeTraits(m.reaction),
legendaryActions: normalizeLegendary(m.legendary, m),
spellcasting: normalizeSpellcasting(m.spellcasting),
};
if ((m as any)._copy) return false;
return (
Array.isArray(m.ac) &&
m.ac.length > 0 &&
m.hp !== undefined &&
Array.isArray(m.size) &&
m.size.length > 0 &&
m.type !== undefined
);
});
const creatures: Creature[] = [];
for (const m of monsters) {
try {
creatures.push(normalizeMonster(m));
} catch {
// Skip monsters with unexpected data shapes
}
}
return creatures;
}
function normalizeMonster(m: RawMonster): Creature {
const crStr = extractCr(m.cr);
const ac = extractAc(m.ac);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
size: formatSize(m.size),
type: formatType(m.type),
alignment: formatAlignment(m.alignment),
ac: ac.value,
acSource: ac.source,
hp: {
average: m.hp.average ?? 0,
formula: m.hp.formula ?? m.hp.special ?? "",
},
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
dex: m.dex,
con: m.con,
int: m.int,
wis: m.wis,
cha: m.cha,
},
cr: crStr,
initiativeProficiency: m.initiative?.proficiency ?? 0,
proficiencyBonus: proficiencyBonus(crStr),
passive: m.passive,
savingThrows: formatSaves(m.save),
skills: formatSkills(m.skill),
resist: formatDamageList(m.resist),
immune: formatDamageList(m.immune),
vulnerable: formatDamageList(m.vulnerable),
conditionImmune: formatConditionImmunities(m.conditionImmune),
senses:
m.senses && m.senses.length > 0
? m.senses.map((s) => stripTags(s)).join(", ")
: undefined,
languages:
m.languages && m.languages.length > 0
? m.languages.join(", ")
: undefined,
traits: normalizeTraits(m.trait),
actions: normalizeTraits(m.action),
bonusActions: normalizeTraits(m.bonus),
reactions: normalizeTraits(m.reaction),
legendaryActions: normalizeLegendary(m.legendary, m),
spellcasting: normalizeSpellcasting(m.spellcasting),
};
}