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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user