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.
@@ -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()