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:
+2407
-205
File diff suppressed because it is too large
Load Diff
@@ -185,13 +185,14 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
{
|
||||
id: '5e-spells', system: '5e', label: 'Spells', linkAs: 'spell',
|
||||
load: () => loadSpells() as unknown as Promise<Entry[]>,
|
||||
searchKeys: ['name', 'school'],
|
||||
meta: (e) => { const l = (e as unknown as Spell).level_int; return l === 0 ? 'Cantrip' : `Lv ${l ?? '?'}`; },
|
||||
searchKeys: ['name', 'school', 'source'],
|
||||
meta: (e) => { const s = e as unknown as Spell; const l = s.level_int; const lvl = l === 0 ? 'Cantrip' : `Lv ${l ?? '?'}`; return s.source && s.source !== 'SRD' ? `${lvl} · ${s.source}` : lvl; },
|
||||
detail: (e) => <Spell5eDetail entry={e as unknown as Spell} />,
|
||||
numericSort: { label: 'Level', get: (e) => Number(e.level_int) },
|
||||
filters: [
|
||||
{ key: 'level', label: 'Level', kind: 'eq', get: (e) => e.level_int as number, options: (d) => numericOptions(d, (e) => e.level_int, (n) => (n === 0 ? 'Cantrip' : `Level ${n}`)) },
|
||||
{ key: 'school', label: 'School', kind: 'eq', get: (e) => e.school as string, options: (d) => distinctStrings(d, (e) => e.school) },
|
||||
{ key: 'source', label: 'Source', kind: 'eq', get: (e) => e.source as string, options: (d) => distinctStrings(d, (e) => e.source) },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user