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:
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user