Build MVP: campaigns, characters, combat, dice, compendium

- Rules abstraction (5e + pf2e) behind one interface, fully tested
- Pure combat engine: turn-order safe across add/remove/reorder/init-change
- Dexie storage with real cascade deletes + Zod validation on write
- Seedable dice engine with notation parser
- Lazy SRD compendium (code-split), bestiary -> combat
- Strict TS, 54 unit tests, Playwright e2e smoke (all green)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:09:42 +02:00
parent fe84dc365d
commit 1a9e5e2c18
93 changed files with 412052 additions and 9 deletions
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Fetch comprehensive D&D 5e data from Open5e v1 API and PF2e from GitHub"""
import json, time, os, sys, requests
from urllib.parse import urljoin
OUT_DIR = os.path.join(os.path.dirname(__file__), "src", "assets", "srd")
BASE_V1 = "https://api.open5e.com/v1/"
def fetch_paginated(endpoint, max_pages=None):
"""Fetch all pages from an Open5e v1 endpoint."""
results = []
url = urljoin(BASE_V1, f"{endpoint}/?format=json&limit=500")
page = 0
while url:
page += 1
print(f" Fetching {endpoint} page {page}... ({len(results)} items so far)")
resp = requests.get(url, headers={"User-Agent": "TTRPG-Manager/1.0"}, timeout=30)
if resp.status_code != 200:
print(f" Error: {resp.status_code}")
break
data = resp.json()
results.extend(data.get("results", []))
url = data.get("next")
if max_pages and page >= max_pages:
break
time.sleep(0.3)
return results
def fetch_all():
os.makedirs(OUT_DIR, exist_ok=True)
# Only fetch SRD/OGL content (document__slug = 'wotc-srd' or 'srd')
endpoints = {
"monsters": "monsters",
"spells": "spells",
"magicitems": "magicitems",
"weapons": "weapons",
}
for name, endpoint in endpoints.items():
print(f"\n=== Fetching {name} ===")
items = fetch_paginated(endpoint)
if not items:
print(f" No items found for {name}, trying alternate...")
continue
# Filter to SRD content only
srd_items = []
for item in items:
doc = item.get("document__slug", "")
if doc in ("wotc-srd", "srd", "wotc", "5e-srd", "o5e"):
srd_items.append(item)
print(f" Total: {len(items)}, SRD: {len(srd_items)}")
# Save full file
outpath = os.path.join(OUT_DIR, f"{name}-full.json")
with open(outpath, "w") as f:
json.dump(items, f, indent=2)
print(f" Saved {len(items)} to {outpath}")
# Save SRD-only file
if srd_items and len(srd_items) < len(items):
outpath_srd = os.path.join(OUT_DIR, f"{name}-srd.json")
with open(outpath_srd, "w") as f:
json.dump(srd_items, f, indent=2)
print(f" Saved {len(srd_items)} SRD to {outpath_srd}")
if __name__ == "__main__":
fetch_all()
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Parse MPMB all_WotC_published.js into structured JSON files"""
import re, json, os
INPUT = "/home/nilsb/Downloads/all_WotC_published.js"
OUT_DIR = "/home/nilsb/Documents/Projects/TTRPG_manager/src/assets/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*["\']([^"\']+)["\']',
}
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 == '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)