Files
ttrpg_manager/scripts/parse_mpmb.py
T
NilsBriggen 7775851bbe 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>
2026-06-10 01:10:30 +02:00

142 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Parse MPMB all_WotC_published.js into structured JSON files"""
import re, json, os
INPUT = os.environ.get("MPMB_INPUT", "/home/nilsb/Downloads/all_WotC_published.js")
# Output path (the app reads from src/data/srd). Override with MPMB_OUT for a dry run.
OUT_DIR = os.environ.get("MPMB_OUT", "/home/nilsb/Documents/Projects/TTRPG_manager/src/data/srd")
def parse_mpmb(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Find all list assignments: ListName["key"] = { ... };
# Strategy: extract each list type by finding assignment patterns
lists = {}
list_names = [
"SourceList", "MagicItemsList", "SpellsList", "ClassList",
"RaceList", "FeatsList", "BackgroundList", "BackgroundFeatureList",
"CreatureList", "WeaponsList", "AmmoList", "GearList", "CompanionList"
]
for list_name in list_names:
entries = {}
# Pattern: ListName["key"] = { ... };
# Match multi-line objects by counting braces
pattern = re.escape(list_name) + r'\["([^"]+)"\]\s*=\s*\{'
for match in re.finditer(pattern, content):
key = match.group(1)
start = match.end() - 1 # position of opening {
# Find matching closing brace
depth = 0
i = start
in_string = False
string_char = None
while i < len(content):
c = content[i]
if in_string:
if c == '\\':
i += 2
continue
if c == string_char:
in_string = False
i += 1
continue
if c in ('"', "'"):
in_string = True
string_char = c
elif c == '{':
depth += 1
elif c == '}':
depth -= 1
if depth == 0:
obj_str = content[start:i+1]
break
elif c == '/' and i+1 < len(content) and content[i+1] in ('/', '*'):
# Skip comments
if content[i+1] == '/':
while i < len(content) and content[i] != '\n':
i += 1
continue
elif content[i+1] == '*':
i += 2
while i < len(content) and not (content[i] == '*' and content[i+1] == '/'):
i += 1
i += 2
continue
i += 1
else:
continue # no closing brace found
# Store raw object text (can't safely eval, but we can extract key fields)
entries[key] = obj_str
if entries:
lists[list_name] = entries
print(f" {list_name}: {len(entries)} entries")
return lists
def safe_extract_fields(obj_str):
"""Extract common fields from a JS object literal string."""
fields = {}
patterns = {
'name': r'name\s*:\s*["\']([^"\']+)["\']',
'type': r'type\s*:\s*["\']([^"\']+)["\']',
'rarity': r'rarity\s*:\s*["\']([^"\']+)["\']',
'source': r'source\s*:\s*(\[\[.*?\]\])',
'description': r'description\s*:\s*["\']([^"]+)["\']',
'weight': r'weight\s*:\s*(-?\d+)',
'prerequisite': r'prerequisite\s*:\s*["\']([^"\']+)["\']',
# --- spell fields (additive; only present on SpellsList entries) ---
'level': r'\blevel\s*:\s*(\d+)',
'school': r'\bschool\s*:\s*["\']([^"\']+)["\']',
'time': r'\btime\s*:\s*["\']([^"\']+)["\']',
'range': r'\brange\s*:\s*["\']([^"\']+)["\']',
'components': r'\bcomponents\s*:\s*["\']([^"\']+)["\']',
'duration': r'\bduration\s*:\s*["\']([^"\']+)["\']',
'save': r'\bsave\s*:\s*["\']([^"\']+)["\']',
'ritual': r'\britual\s*:\s*(true|false)',
'classes': r'\bclasses\s*:\s*(\[[^\]]*\])',
}
for field, pattern in patterns.items():
m = re.search(pattern, obj_str)
if m:
val = m.group(1)
if field == 'weight':
val = int(val)
elif field == 'level':
val = int(val)
elif field == 'ritual':
val = (val == 'true')
elif field == 'classes':
# ["cleric","warlock"] -> list of class names
val = re.findall(r'["\']([^"\']+)["\']', val)
elif field == 'source':
# Parse [[SourceName, page], [SourceName2, page2]]
sources = re.findall(r'\["([^"]+)",?\s*(\d+)?\]', val)
val = [{"source": s[0], "page": int(s[1]) if s[1] else None} for s in sources]
fields[field] = val
return fields
if __name__ == "__main__":
print("Parsing MPMB data file...")
lists = parse_mpmb(INPUT)
os.makedirs(OUT_DIR, exist_ok=True)
# Save raw entry counts
for name, entries in lists.items():
# Extract key fields for each entry
extracted = {}
for key, obj_str in entries.items():
extracted[key] = safe_extract_fields(obj_str)
outname = f"mpmb-{name.lower().replace('list','')}.json"
outpath = os.path.join(OUT_DIR, outname)
with open(outpath, 'w') as f:
json.dump(extracted, f, indent=2)
print(f" Saved {outname}: {len(extracted)} entries")
print("\nDone! Files saved to", OUT_DIR)