7775851bbe
The app loaded SRD-only spells; the parsed MPMB spell file was shallow (names only) and
unused. Now:
- parse_mpmb.py extracts the rich spell fields (level, school, casting time, range,
components, duration, save, ritual, classes) and writes to src/data/srd.
- New compendium/mpmb.ts normalizes MPMB spells to the Open5e Spell shape (school/time
expansion, source-code → label) — pure, unit-tested.
- loadSpells merges 204 non-SRD MPMB spells (95 XGtE, 21 TCE, + Eberron/Theros/Strixhaven/
Fizban's/etc.) into the 321 SRD spells (525 total), deduped by name.
- Spell type gains a field; the compendium spell list shows it ('Cantrip · XGtE')
and adds a Source filter.
Verified in-app (Toll the Dead → 'Cantrip · XGtE'). 333 tests green, build OK.
Only mpmb-spells.json regenerated; feats/others untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
import type { Spell } from './types';
|
|
|
|
/** A parsed MPMB spell entry (scripts/parse_mpmb.py output). */
|
|
export interface MpmbSpell {
|
|
name: string;
|
|
source?: { source: string; page: number | null }[];
|
|
level?: number;
|
|
school?: string;
|
|
time?: string;
|
|
range?: string;
|
|
components?: string;
|
|
duration?: string;
|
|
save?: string;
|
|
ritual?: boolean;
|
|
classes?: string[];
|
|
description?: string;
|
|
}
|
|
|
|
// MPMB sourcebook codes → readable labels. Unknown codes pass through unchanged.
|
|
const SOURCE_LABEL: Record<string, string> = {
|
|
P: 'PHB', X: 'XGtE', T: 'TCE', V: 'VGtM', M: 'MToF', MM: 'MM',
|
|
E: 'Eberron', 'E:RLW': 'Eberron', WGtE: 'Wayfinder', MOT: 'Theros',
|
|
G: 'GGtR', S: 'SCAG', SCC: 'Strixhaven', FToD: "Fizban's", BoMT: 'BoMT',
|
|
AcqInc: 'Acq. Inc.', RotF: 'Rime', 'S:AiS': 'Spelljammer', 'P:AitM': 'Planescape',
|
|
LLoK: 'Lost Lab', W: 'Witchlight', DMG: 'DMG',
|
|
};
|
|
|
|
const SCHOOL: Record<string, string> = {
|
|
abjur: 'Abjuration', conj: 'Conjuration', div: 'Divination', ench: 'Enchantment',
|
|
evoc: 'Evocation', illus: 'Illusion', necro: 'Necromancy', trans: 'Transmutation',
|
|
};
|
|
|
|
const TIME: Record<string, string> = {
|
|
'1 a': '1 action', '1 bns': '1 bonus action', '1 rea': '1 reaction',
|
|
'1 min': '1 minute', '10 min': '10 minutes', '1 h': '1 hour', '8 h': '8 hours', '24 h': '24 hours',
|
|
};
|
|
|
|
/** The primary (canonical, first-listed) source code of a parsed MPMB entry. */
|
|
export function mpmbPrimarySource(raw: { source?: { source: string }[] }): string {
|
|
return raw.source?.[0]?.source ?? '';
|
|
}
|
|
|
|
/** Readable label for an MPMB source code (e.g. 'X' -> 'XGtE'). */
|
|
export function sourceLabel(code: string): string {
|
|
return SOURCE_LABEL[code] ?? code;
|
|
}
|
|
|
|
function slugify(name: string): string {
|
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
/** Normalize a parsed MPMB spell into the app's Open5e-shaped Spell. */
|
|
export function normalizeMpmbSpell(raw: MpmbSpell): Spell | null {
|
|
if (!raw.name) return null;
|
|
const level = typeof raw.level === 'number' ? raw.level : 0;
|
|
const conc = /conc/i.test(raw.duration ?? '');
|
|
const school = raw.school ? SCHOOL[raw.school.toLowerCase()] ?? raw.school : '';
|
|
return {
|
|
slug: slugify(raw.name),
|
|
name: raw.name,
|
|
desc: raw.description ?? '',
|
|
level_int: level,
|
|
level: level === 0 ? 'Cantrip' : `Level ${level}`,
|
|
school,
|
|
casting_time: raw.time ? TIME[raw.time] ?? raw.time : '',
|
|
range: raw.range ?? '',
|
|
duration: raw.duration ?? '',
|
|
concentration: conc ? 'yes' : 'no',
|
|
requires_concentration: conc,
|
|
ritual: raw.ritual ? 'yes' : 'no',
|
|
can_be_cast_as_ritual: !!raw.ritual,
|
|
components: raw.components ?? '',
|
|
dnd_class: (raw.classes ?? []).map((c) => c.charAt(0).toUpperCase() + c.slice(1)).join(', '),
|
|
source: sourceLabel(mpmbPrimarySource(raw)),
|
|
};
|
|
}
|