pf2e-spellbook 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +196 -0
- package/data/legacy_aliases.js +38 -0
- package/data/overrides.js +276 -0
- package/data/spells.generated.js +57844 -0
- package/dist/icon.svg +8 -0
- package/dist/index.html +60088 -0
- package/dist/manifest.webmanifest +14 -0
- package/dist/sw.js +55 -0
- package/notice.md +248 -0
- package/package.json +28 -0
- package/src/classes.js +312 -0
- package/src/engine.js +1111 -0
- package/src/icon.svg +8 -0
- package/src/manifest.webmanifest +14 -0
- package/src/styles.css +353 -0
- package/src/sw.js +55 -0
- package/src/template.html +154 -0
- package/tools/build.py +62 -0
- package/tools/build_spells.py +391 -0
- package/tools/migrate_cleric_overrides.py +102 -0
- package/tools/test.mjs +536 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
build_spells.py — convert the Foundry VTT `pf2e` system spell JSON into the
|
|
4
|
+
compact data model used by the PF2e spellbook app.
|
|
5
|
+
|
|
6
|
+
Source data: https://github.com/foundryvtt/pf2e (game content under OGL/ORC).
|
|
7
|
+
Get it with a sparse, shallow clone (only the spells pack, ~a few MB):
|
|
8
|
+
|
|
9
|
+
git clone --depth 1 --filter=blob:none --sparse \\
|
|
10
|
+
https://github.com/foundryvtt/pf2e.git pf2e-data
|
|
11
|
+
cd pf2e-data && git sparse-checkout set packs/pf2e/spells
|
|
12
|
+
|
|
13
|
+
Then:
|
|
14
|
+
|
|
15
|
+
python3 tools/build_spells.py --src /path/to/pf2e-data/packs/pf2e/spells \\
|
|
16
|
+
--out data/spells.generated.js
|
|
17
|
+
|
|
18
|
+
Output is a JS file declaring `const GENERATED_SPELLS = [ ... ];`
|
|
19
|
+
Each entry: {slug, name, rank, type, traditions, rarity, traits, classTrait,
|
|
20
|
+
actions, range, area, targets, duration, sustained, save,
|
|
21
|
+
description, heightened}
|
|
22
|
+
Descriptions are cleaned plain text; newlines are meaningful (rendered as <br>).
|
|
23
|
+
"""
|
|
24
|
+
import argparse
|
|
25
|
+
import datetime
|
|
26
|
+
import glob
|
|
27
|
+
import html
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def src_commit(src):
|
|
36
|
+
"""Best-effort short git commit of the Foundry data clone, for the stamp."""
|
|
37
|
+
try:
|
|
38
|
+
out = subprocess.check_output(
|
|
39
|
+
["git", "-C", src, "rev-parse", "--short", "HEAD"],
|
|
40
|
+
stderr=subprocess.DEVNULL)
|
|
41
|
+
return out.decode().strip()
|
|
42
|
+
except Exception:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# PF2e class names that appear as traits on focus spells.
|
|
46
|
+
CLASS_TRAITS = {
|
|
47
|
+
"bard", "champion", "cleric", "druid", "monk", "oracle", "psychic",
|
|
48
|
+
"ranger", "sorcerer", "summoner", "witch", "wizard", "magus", "kineticist",
|
|
49
|
+
"fighter", "rogue", "barbarian", "investigator", "swashbuckler", "thaumaturge",
|
|
50
|
+
"alchemist", "animist", "exemplar", "guardian", "runesmith", "commander",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Source books whose copyright line could not be verified against the Archives of
|
|
54
|
+
# Nethys licenses page. Spells from these are excluded from the build so that
|
|
55
|
+
# every source shipped in the data is credited in the OGL/ORC notice (notice.md).
|
|
56
|
+
# Remove a title from this set once its attribution becomes available.
|
|
57
|
+
EXCLUDED_SOURCES = {
|
|
58
|
+
"Pathfinder #150: Broken Promises",
|
|
59
|
+
"Pathfinder #171: Hurricane's Howl",
|
|
60
|
+
"Pathfinder Society Scenario #2-22: Breaking the Storm: Excising Ruination",
|
|
61
|
+
"Pathfinder Blog: The Waters of Stone Ring Pond",
|
|
62
|
+
"Pathfinder Adventure Path: Hellbreakers",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Inline Foundry markup -> readable text
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
GLYPH = {"1": "◆", "2": "◆◆", "3": "◆◆◆",
|
|
69
|
+
"r": "⤳ reaction", "f": "◇ free"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _glyph(content):
|
|
73
|
+
c = content.strip().lower()
|
|
74
|
+
if c in GLYPH:
|
|
75
|
+
return GLYPH[c]
|
|
76
|
+
return content
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _template(inner):
|
|
80
|
+
# e.g. "emanation|distance:30" or "line|distance:60|width:5"
|
|
81
|
+
parts = inner.split("|")
|
|
82
|
+
ttype = parts[0]
|
|
83
|
+
dist = width = None
|
|
84
|
+
for p in parts[1:]:
|
|
85
|
+
if p.startswith("distance:"):
|
|
86
|
+
dist = p.split(":", 1)[1]
|
|
87
|
+
elif p.startswith("width:"):
|
|
88
|
+
width = p.split(":", 1)[1]
|
|
89
|
+
if dist and ttype == "line" and width:
|
|
90
|
+
return f"{dist}-foot line ({width} ft wide)"
|
|
91
|
+
if dist:
|
|
92
|
+
return f"{dist}-foot {ttype}"
|
|
93
|
+
return ttype
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve_vars(s):
|
|
97
|
+
# Foundry interpolation -> readable text (for spells, item level == rank)
|
|
98
|
+
s = re.sub(r"@(?:item|spell)\.(?:level|rank)", "spell rank", s)
|
|
99
|
+
s = re.sub(r"@actor\.level", "your level", s)
|
|
100
|
+
return s
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _damage(inner):
|
|
104
|
+
# e.g. "2d6[fire]", "(@item.level)d6[fire]", "@item.rank|options:x[cold]"
|
|
105
|
+
inner = inner.split(",")[0]
|
|
106
|
+
mtype = re.search(r"\[([^\]]+)\]", inner)
|
|
107
|
+
dtype = mtype.group(1).split(",")[0].strip() if mtype else ""
|
|
108
|
+
formula = inner[:mtype.start()] if mtype else inner
|
|
109
|
+
formula = formula.split("|")[0] # drop |options:...
|
|
110
|
+
formula = _resolve_vars(formula).strip()
|
|
111
|
+
return f"{formula} {dtype}".strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _check(inner):
|
|
115
|
+
# e.g. "fortitude|dc:20" -> "Fortitude check"
|
|
116
|
+
stat = inner.split("|")[0].split(":")[-1]
|
|
117
|
+
return f"{stat.capitalize()} check"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _uuid_label(inner):
|
|
121
|
+
# No explicit {label}: derive from the last path segment.
|
|
122
|
+
seg = inner.split(".")[-1]
|
|
123
|
+
seg = re.sub(r"^(spell|feat|item|action)s?[-_]?", "", seg, flags=re.I)
|
|
124
|
+
return seg.replace("-", " ").replace("_", " ").strip()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def clean_html(raw):
|
|
128
|
+
if not raw:
|
|
129
|
+
return ""
|
|
130
|
+
s = raw
|
|
131
|
+
# action glyphs
|
|
132
|
+
s = re.sub(r'<span class="action-glyph">(.*?)</span>',
|
|
133
|
+
lambda m: _glyph(m.group(1)), s, flags=re.S)
|
|
134
|
+
# @Template / @Damage / @Check / @UUID (with or without {label})
|
|
135
|
+
s = re.sub(r"@Template\[([^\]]+)\]", lambda m: _template(m.group(1)), s)
|
|
136
|
+
# @Damage may nest one level of brackets: @Damage[(@item.level)d6[fire]]{label}
|
|
137
|
+
dmg = r"@Damage\[((?:[^\[\]]|\[[^\]]*\])*)\](?:\{([^}]+)\})?"
|
|
138
|
+
s = re.sub(dmg, lambda m: m.group(2) or _damage(m.group(1)), s)
|
|
139
|
+
s = re.sub(r"@Check\[([^\]]*)\]\{([^}]+)\}", r"\2", s)
|
|
140
|
+
s = re.sub(r"@Check\[([^\]]*)\]", lambda m: _check(m.group(1)), s)
|
|
141
|
+
s = re.sub(r"@UUID\[[^\]]+\]\{([^}]+)\}", r"\1", s)
|
|
142
|
+
s = re.sub(r"@UUID\[([^\]]+)\]", lambda m: _uuid_label(m.group(1)), s)
|
|
143
|
+
# any other @Foo[...]{label} / @Foo[...]
|
|
144
|
+
s = re.sub(r"@[A-Za-z]+\[[^\]]*\]\{([^}]+)\}", r"\1", s)
|
|
145
|
+
s = re.sub(r"@[A-Za-z]+\[[^\]]*\]", "", s)
|
|
146
|
+
# block-level tags -> newlines / bullets
|
|
147
|
+
s = re.sub(r"<hr\s*/?>", "\n", s)
|
|
148
|
+
s = re.sub(r"<li[^>]*>", "\n• ", s)
|
|
149
|
+
s = re.sub(r"</li>", "", s)
|
|
150
|
+
s = re.sub(r"<br\s*/?>", "\n", s)
|
|
151
|
+
s = re.sub(r"<p[^>]*>", "\n\n", s)
|
|
152
|
+
s = re.sub(r"</(p|ul|ol|div|table|tr)>", "\n", s)
|
|
153
|
+
# strip every remaining tag (incl. <strong>, <em>, <table>, etc.)
|
|
154
|
+
s = re.sub(r"<[^>]+>", "", s)
|
|
155
|
+
# safety net: resolve / drop any stray Foundry interpolations
|
|
156
|
+
s = _resolve_vars(s)
|
|
157
|
+
s = re.sub(r"@(?:item|actor|target|spell)\.[\w.]+(?:\*\d+)?", "spell rank", s)
|
|
158
|
+
s = html.unescape(s)
|
|
159
|
+
# whitespace tidy
|
|
160
|
+
s = re.sub(r"[ \t]+", " ", s)
|
|
161
|
+
s = re.sub(r" *\n *", "\n", s)
|
|
162
|
+
s = re.sub(r"\n{3,}", "\n\n", s)
|
|
163
|
+
return s.strip()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
HEIGHTEN_RE = re.compile(r"Heightened\s*\(")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def split_heightened(text):
|
|
170
|
+
"""Separate the trailing 'Heightened (...)' block from the main description."""
|
|
171
|
+
m = HEIGHTEN_RE.search(text)
|
|
172
|
+
if not m:
|
|
173
|
+
return text.strip(), None
|
|
174
|
+
main = text[:m.start()].strip()
|
|
175
|
+
rest = text[m.start():].strip()
|
|
176
|
+
# drop the literal word 'Heightened' (the app adds its own label)
|
|
177
|
+
rest = HEIGHTEN_RE.sub("(", rest)
|
|
178
|
+
rest = re.sub(r"\n+", " ", rest)
|
|
179
|
+
rest = re.sub(r"\s{2,}", " ", rest).strip()
|
|
180
|
+
return main, rest or None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Field formatting
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
def fmt_area(area):
|
|
187
|
+
if not area:
|
|
188
|
+
return None
|
|
189
|
+
t = area.get("type")
|
|
190
|
+
v = area.get("value")
|
|
191
|
+
if v is None:
|
|
192
|
+
return t
|
|
193
|
+
return f"{v}-foot {t}" if t else f"{v} feet"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def fmt_save(defense):
|
|
197
|
+
if not defense:
|
|
198
|
+
return None
|
|
199
|
+
save = defense.get("save")
|
|
200
|
+
if not save:
|
|
201
|
+
return None
|
|
202
|
+
stat = (save.get("statistic") or "").capitalize()
|
|
203
|
+
if not stat:
|
|
204
|
+
return None
|
|
205
|
+
return f"basic {stat}" if save.get("basic") else stat
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def norm_actions(time_val):
|
|
209
|
+
if time_val is None:
|
|
210
|
+
return None
|
|
211
|
+
return str(time_val).strip()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def class_trait_of(traits):
|
|
215
|
+
for t in traits:
|
|
216
|
+
if t in CLASS_TRAITS:
|
|
217
|
+
return t
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def damage_fields(sys_):
|
|
222
|
+
"""Base damage dice + interval-heightening add, aligned by index, so the app
|
|
223
|
+
can show the real dice at the cast rank (e.g. Fireball rank 5 -> 10d6)."""
|
|
224
|
+
dmg = sys_.get("damage") or {}
|
|
225
|
+
keys = sorted(dmg.keys(), key=lambda x: int(x) if str(x).isdigit() else 0)
|
|
226
|
+
h = sys_.get("heightening") or {}
|
|
227
|
+
hd = (h.get("damage") or {}) if h.get("type") == "interval" else {}
|
|
228
|
+
base, add, any_add = [], [], False
|
|
229
|
+
for k in keys:
|
|
230
|
+
e = dmg[k]
|
|
231
|
+
if not isinstance(e, dict) or not e.get("formula"):
|
|
232
|
+
continue
|
|
233
|
+
kinds = e.get("kinds") or []
|
|
234
|
+
base.append({"formula": e["formula"], "type": e.get("type"),
|
|
235
|
+
"healing": ("healing" in kinds)})
|
|
236
|
+
a = hd.get(k)
|
|
237
|
+
add.append(a)
|
|
238
|
+
if a:
|
|
239
|
+
any_add = True
|
|
240
|
+
heighten = None
|
|
241
|
+
if base and h.get("type") == "interval" and any_add:
|
|
242
|
+
heighten = {"interval": h.get("interval", 1) or 1, "add": add}
|
|
243
|
+
return (base or None), heighten
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def ritual_fields(sys_):
|
|
247
|
+
r = sys_.get("ritual")
|
|
248
|
+
if not r:
|
|
249
|
+
return False, None, None
|
|
250
|
+
primary = (r.get("primary") or {}).get("check")
|
|
251
|
+
sec = r.get("secondary") or {}
|
|
252
|
+
return True, primary, sec.get("checks")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Per-file transform
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
def transform(path, is_focus=False, is_ritual=False):
|
|
259
|
+
with open(path, encoding="utf-8") as fh:
|
|
260
|
+
doc = json.load(fh)
|
|
261
|
+
if doc.get("type") != "spell":
|
|
262
|
+
return None
|
|
263
|
+
sys_ = doc["system"]
|
|
264
|
+
traits_obj = sys_.get("traits", {})
|
|
265
|
+
traits = list(traits_obj.get("value", []))
|
|
266
|
+
is_cantrip = "cantrip" in traits
|
|
267
|
+
level = (sys_.get("level") or {}).get("value", 1)
|
|
268
|
+
rank = 0 if is_cantrip else int(level)
|
|
269
|
+
|
|
270
|
+
desc_raw = (sys_.get("description") or {}).get("value", "")
|
|
271
|
+
description, heightened = split_heightened(clean_html(desc_raw))
|
|
272
|
+
if not description.strip():
|
|
273
|
+
description = "(This entry's full text isn't included here — see the rulebook.)"
|
|
274
|
+
|
|
275
|
+
dur = sys_.get("duration") or {}
|
|
276
|
+
area = sys_.get("area")
|
|
277
|
+
target = (sys_.get("target") or {}).get("value") or None
|
|
278
|
+
rng = (sys_.get("range") or {}).get("value") or None
|
|
279
|
+
base_dmg, heighten = damage_fields(sys_)
|
|
280
|
+
ritual, rit_primary, rit_secondary = ritual_fields(sys_)
|
|
281
|
+
pub = sys_.get("publication") or {}
|
|
282
|
+
if (pub.get("title") or "").strip() in EXCLUDED_SOURCES:
|
|
283
|
+
return None # unattributable source — excluded to keep the OGL/ORC notice complete
|
|
284
|
+
|
|
285
|
+
rec = {
|
|
286
|
+
"slug": os.path.splitext(os.path.basename(path))[0],
|
|
287
|
+
"name": doc.get("name", ""),
|
|
288
|
+
"rank": rank,
|
|
289
|
+
"type": "cantrip" if is_cantrip else "spell",
|
|
290
|
+
"focus": is_focus,
|
|
291
|
+
"ritual": ritual or is_ritual,
|
|
292
|
+
"traditions": list(traits_obj.get("traditions", [])),
|
|
293
|
+
"rarity": traits_obj.get("rarity", "common"),
|
|
294
|
+
"traits": traits,
|
|
295
|
+
"classTrait": class_trait_of(traits) if is_focus else None,
|
|
296
|
+
"actions": norm_actions((sys_.get("time") or {}).get("value")),
|
|
297
|
+
"range": rng,
|
|
298
|
+
"area": fmt_area(area),
|
|
299
|
+
"targets": target,
|
|
300
|
+
"duration": (dur.get("value") or None),
|
|
301
|
+
"sustained": bool(dur.get("sustained")),
|
|
302
|
+
"save": fmt_save(sys_.get("defense")),
|
|
303
|
+
"damage": base_dmg,
|
|
304
|
+
"heighten": heighten,
|
|
305
|
+
"description": description,
|
|
306
|
+
"heightened": heightened,
|
|
307
|
+
# transient: aggregated into GENERATED_SOURCES, stripped before output
|
|
308
|
+
"_pub": {"title": (pub.get("title") or "").strip(),
|
|
309
|
+
"license": (pub.get("license") or "").strip()},
|
|
310
|
+
}
|
|
311
|
+
if rec["ritual"]:
|
|
312
|
+
rec["ritualPrimary"] = rit_primary
|
|
313
|
+
rec["ritualSecondary"] = rit_secondary
|
|
314
|
+
return rec
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def main():
|
|
318
|
+
ap = argparse.ArgumentParser()
|
|
319
|
+
ap.add_argument("--src", required=True,
|
|
320
|
+
help="path to Foundry packs/pf2e/spells directory")
|
|
321
|
+
ap.add_argument("--out", required=True, help="output .js path")
|
|
322
|
+
ap.add_argument("--include-focus", action="store_true", default=True)
|
|
323
|
+
args = ap.parse_args()
|
|
324
|
+
|
|
325
|
+
spell_files = glob.glob(os.path.join(args.src, "spells", "**", "*.json"),
|
|
326
|
+
recursive=True)
|
|
327
|
+
focus_files = glob.glob(os.path.join(args.src, "focus", "*.json")) \
|
|
328
|
+
if args.include_focus else []
|
|
329
|
+
ritual_files = glob.glob(os.path.join(args.src, "rituals", "*.json"))
|
|
330
|
+
|
|
331
|
+
out = []
|
|
332
|
+
skipped = 0
|
|
333
|
+
|
|
334
|
+
def process(files, **kw):
|
|
335
|
+
nonlocal skipped
|
|
336
|
+
for p in sorted(files):
|
|
337
|
+
if os.path.basename(p).startswith("_"):
|
|
338
|
+
continue
|
|
339
|
+
rec = transform(p, **kw)
|
|
340
|
+
if rec:
|
|
341
|
+
out.append(rec)
|
|
342
|
+
else:
|
|
343
|
+
skipped += 1
|
|
344
|
+
|
|
345
|
+
process(spell_files)
|
|
346
|
+
process(focus_files, is_focus=True)
|
|
347
|
+
process(ritual_files, is_ritual=True)
|
|
348
|
+
|
|
349
|
+
out.sort(key=lambda s: (s["ritual"], s["rank"], s["name"].lower()))
|
|
350
|
+
|
|
351
|
+
# Aggregate source books for the in-app Credits / attribution list, then
|
|
352
|
+
# strip the transient per-spell publication field from the shipped data.
|
|
353
|
+
from collections import Counter
|
|
354
|
+
pubc = Counter()
|
|
355
|
+
for s in out:
|
|
356
|
+
p = s.pop("_pub", None) or {}
|
|
357
|
+
title = p.get("title")
|
|
358
|
+
if title:
|
|
359
|
+
pubc[(title, p.get("license") or "Unknown")] += 1
|
|
360
|
+
sources = [{"title": t, "license": lic, "count": c}
|
|
361
|
+
for (t, lic), c in pubc.items()]
|
|
362
|
+
sources.sort(key=lambda x: (x["license"], -x["count"], x["title"]))
|
|
363
|
+
|
|
364
|
+
meta = {
|
|
365
|
+
"generated": datetime.date.today().isoformat(),
|
|
366
|
+
"source": "foundryvtt/pf2e",
|
|
367
|
+
"sourceCommit": src_commit(args.src),
|
|
368
|
+
"count": len(out),
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
|
|
372
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
373
|
+
fh.write("/* AUTO-GENERATED by tools/build_spells.py — do not edit. */\n")
|
|
374
|
+
fh.write("/* Source: foundryvtt/pf2e spell data (Paizo content, OGL/ORC). */\n")
|
|
375
|
+
fh.write("const GENERATED_META = " + json.dumps(meta) + ";\n")
|
|
376
|
+
fh.write("const GENERATED_SOURCES = "
|
|
377
|
+
+ json.dumps(sources, ensure_ascii=False) + ";\n")
|
|
378
|
+
fh.write("const GENERATED_SPELLS = ")
|
|
379
|
+
json.dump(out, fh, ensure_ascii=False, indent=0,
|
|
380
|
+
separators=(",", ":"))
|
|
381
|
+
fh.write(";\n")
|
|
382
|
+
|
|
383
|
+
n_focus = sum(1 for s in out if s["focus"])
|
|
384
|
+
n_rit = sum(1 for s in out if s["ritual"])
|
|
385
|
+
print(f"wrote {len(out)} entries ({n_focus} focus, {n_rit} rituals, "
|
|
386
|
+
f"{len(out)-n_focus-n_rit} regular) to {args.out}; skipped {skipped}; "
|
|
387
|
+
f"commit {meta['sourceCommit']}", file=sys.stderr)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
main()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
One-time migration: lift the hand-written, plain-English Cleric spell
|
|
4
|
+
descriptions out of the original `clerictest.html` and emit them as
|
|
5
|
+
`data/overrides.js`, keyed by the generated spell slug.
|
|
6
|
+
|
|
7
|
+
After this runs, `data/overrides.js` is maintained by hand: any slug present
|
|
8
|
+
there shadows the auto-generated description/heightened text (the "hybrid").
|
|
9
|
+
|
|
10
|
+
python3 tools/migrate_cleric_overrides.py \\
|
|
11
|
+
--src ../clerictest.html \\
|
|
12
|
+
--generated data/spells.generated.js \\
|
|
13
|
+
--out data/overrides.js
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
STR = r'"((?:[^"\\]|\\.)*)"' # a JS double-quoted string with escapes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def unescape_js(s):
|
|
24
|
+
return s.encode("utf-8").decode("unicode_escape") \
|
|
25
|
+
if "\\u" in s else s.replace('\\"', '"').replace("\\\\", "\\")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_curated(html_path):
|
|
29
|
+
text = open(html_path, encoding="utf-8").read()
|
|
30
|
+
# the SPELLS array — each spell object is one line: {name:"...",...}
|
|
31
|
+
block = text[text.index("const SPELLS = "):]
|
|
32
|
+
block = block[:block.index("\n];")]
|
|
33
|
+
spells = []
|
|
34
|
+
for line in block.splitlines():
|
|
35
|
+
line = line.strip()
|
|
36
|
+
if not line.startswith("{name:"):
|
|
37
|
+
continue
|
|
38
|
+
name = re.search(r"name:" + STR, line)
|
|
39
|
+
desc = re.search(r"description:" + STR, line)
|
|
40
|
+
height = re.search(r"heightened:(null|" + STR + ")", line)
|
|
41
|
+
if not name:
|
|
42
|
+
continue
|
|
43
|
+
spells.append({
|
|
44
|
+
"name": unescape_js(name.group(1)),
|
|
45
|
+
"description": unescape_js(desc.group(1)) if desc else None,
|
|
46
|
+
"heightened": (None if (not height or height.group(1) == "null")
|
|
47
|
+
else unescape_js(height.group(2))),
|
|
48
|
+
})
|
|
49
|
+
return spells
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_slug_map(generated_path):
|
|
53
|
+
txt = open(generated_path, encoding="utf-8").read()
|
|
54
|
+
txt = txt[txt.index("["):txt.rindex("]") + 1]
|
|
55
|
+
gen = json.loads(txt)
|
|
56
|
+
by_name = {}
|
|
57
|
+
for s in gen:
|
|
58
|
+
by_name[s["name"].strip().lower()] = s["slug"]
|
|
59
|
+
return by_name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main():
|
|
63
|
+
ap = argparse.ArgumentParser()
|
|
64
|
+
ap.add_argument("--src", required=True)
|
|
65
|
+
ap.add_argument("--generated", required=True)
|
|
66
|
+
ap.add_argument("--out", required=True)
|
|
67
|
+
args = ap.parse_args()
|
|
68
|
+
|
|
69
|
+
curated = parse_curated(args.src)
|
|
70
|
+
slug_of = load_slug_map(args.generated)
|
|
71
|
+
|
|
72
|
+
overrides = {}
|
|
73
|
+
unmatched = []
|
|
74
|
+
for c in curated:
|
|
75
|
+
slug = slug_of.get(c["name"].strip().lower())
|
|
76
|
+
if not slug:
|
|
77
|
+
unmatched.append(c["name"])
|
|
78
|
+
continue
|
|
79
|
+
entry = {"description": c["description"]}
|
|
80
|
+
if c["heightened"]:
|
|
81
|
+
entry["heightened"] = c["heightened"]
|
|
82
|
+
overrides[slug] = entry
|
|
83
|
+
|
|
84
|
+
# stable order
|
|
85
|
+
overrides = dict(sorted(overrides.items()))
|
|
86
|
+
|
|
87
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
88
|
+
fh.write("/* Curated, beginner-friendly overrides (the \"teaching\" voice).\n")
|
|
89
|
+
fh.write(" Any slug here shadows the auto-generated text. Hand-edited —\n")
|
|
90
|
+
fh.write(" add an entry for any spell whose wording you want to simplify. */\n")
|
|
91
|
+
fh.write("const SPELL_OVERRIDES = ")
|
|
92
|
+
json.dump(overrides, fh, ensure_ascii=False, indent=2)
|
|
93
|
+
fh.write(";\n")
|
|
94
|
+
|
|
95
|
+
print(f"wrote {len(overrides)} overrides to {args.out}", file=sys.stderr)
|
|
96
|
+
if unmatched:
|
|
97
|
+
print("UNMATCHED (no slug found): " + ", ".join(unmatched),
|
|
98
|
+
file=sys.stderr)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|