Phase 2: wire MPMB spell data in (XGtE/TCE + more sources)

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>
This commit is contained in:
2026-06-10 01:10:30 +02:00
parent 11a9e87100
commit 7775851bbe
8 changed files with 2575 additions and 212 deletions
+76
View File
@@ -0,0 +1,76 @@
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)),
};
}