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
+16 -2
View File
@@ -1,5 +1,6 @@
import type { SystemId } from '@/lib/rules';
import type { RulesetClass } from '@/lib/ruleset/normalize';
import { normalizeMpmbSpell, type MpmbSpell } from './mpmb';
import type {
Monster,
Spell,
@@ -30,8 +31,21 @@ export async function loadMonsters(): Promise<Monster[]> {
export async function loadSpells(): Promise<Spell[]> {
if (!spellsCache) {
const mod = await import('@/data/srd/spells-srd.json');
spellsCache = mod.default as unknown as Spell[];
const [srdMod, mpmbMod] = await Promise.all([
import('@/data/srd/spells-srd.json'),
import('@/data/srd/mpmb-spells.json'),
]);
const srd = (srdMod.default as unknown as Spell[]).map((s) => ({ ...s, source: s.source ?? 'SRD' }));
// Merge non-SRD MPMB spells (XGtE/TCE/etc.), deduped by name — SRD wins on overlap.
const have = new Set(srd.map((s) => s.name.trim().toLowerCase()));
const mpmbRaw = mpmbMod.default as Record<string, MpmbSpell>;
const extra: Spell[] = [];
for (const raw of Object.values(mpmbRaw)) {
if (have.has((raw.name ?? '').trim().toLowerCase())) continue;
const s = normalizeMpmbSpell(raw);
if (s) extra.push(s);
}
spellsCache = [...srd, ...extra].sort((a, b) => a.name.localeCompare(b.name));
}
return spellsCache;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { normalizeMpmbSpell, sourceLabel, mpmbPrimarySource } from './mpmb';
describe('normalizeMpmbSpell', () => {
it('maps an XGtE cantrip to the Open5e spell shape', () => {
const s = normalizeMpmbSpell({
name: 'Toll the Dead',
source: [{ source: 'X', page: 169 }, { source: 'UA:SS', page: 4 }],
level: 0,
school: 'Necro',
time: '1 a',
range: '60 ft',
components: 'V,S',
duration: 'Instantaneous',
save: 'Wis',
classes: ['cleric', 'warlock', 'wizard'],
description: '1 crea save or 1d12 Necrotic dmg',
})!;
expect(s).toMatchObject({
slug: 'toll-the-dead',
name: 'Toll the Dead',
level_int: 0,
level: 'Cantrip',
school: 'Necromancy',
casting_time: '1 action',
range: '60 ft',
components: 'V,S',
source: 'XGtE', // primary (first) source code mapped
dnd_class: 'Cleric, Warlock, Wizard',
});
expect(s.requires_concentration).toBe(false);
});
it('flags concentration from the duration', () => {
const s = normalizeMpmbSpell({ name: 'Wall of Light', level: 5, school: 'Evoc', duration: 'Conc, 10 min', source: [{ source: 'X', page: 170 }] })!;
expect(s.requires_concentration).toBe(true);
expect(s.concentration).toBe('yes');
expect(s.level).toBe('Level 5');
});
it('passes unknown source codes through; maps known ones', () => {
expect(sourceLabel('T')).toBe('TCE');
expect(sourceLabel('ZZZ')).toBe('ZZZ');
expect(mpmbPrimarySource({ source: [{ source: 'X' }, { source: 'UA' }] })).toBe('X');
});
it('returns null without a name', () => {
expect(normalizeMpmbSpell({ name: '' })).toBeNull();
});
});
+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)),
};
}
+2
View File
@@ -71,6 +71,8 @@ export interface Spell {
components?: string;
material?: string;
dnd_class?: string;
/** sourcebook label (e.g. 'PHB', 'XGtE', 'TCE'); '' / undefined = SRD. */
source?: string;
}
export interface MagicItem {