pf2e-subsystems 0.1.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,252 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ build_reference.py — convert the Foundry VTT `pf2e` system condition & action
4
+ JSON into the compact reference data bundled by the party tracker.
5
+
6
+ Source data: https://github.com/foundryvtt/pf2e (game content under OGL/ORC).
7
+ Get it with a sparse, shallow clone (only the two packs we need):
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/conditions packs/pf2e/actions
12
+
13
+ Then:
14
+
15
+ python3 tools/build_reference.py --src /path/to/pf2e-data/packs/pf2e \\
16
+ --out data/reference.generated.js
17
+
18
+ Output declares:
19
+ const GENERATED_REF_META = {generated, source, sourceCommit, conditions, actions, sources[]}
20
+ const GENERATED_CONDITIONS = [{slug, name, description, valued, group}, ...]
21
+ const GENERATED_ACTIONS = [{slug, name, category, traits, exploration, actionType, actions, description}, ...]
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
+
33
+
34
+ def src_commit(path):
35
+ """Best-effort short git commit of the Foundry data clone, for the stamp."""
36
+ try:
37
+ out = subprocess.check_output(
38
+ ["git", "-C", path, "rev-parse", "--short", "HEAD"],
39
+ stderr=subprocess.DEVNULL)
40
+ return out.decode().strip()
41
+ except Exception:
42
+ return None
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Inline Foundry markup -> readable text (mirrors tools/build_spells.py)
47
+ # ---------------------------------------------------------------------------
48
+ GLYPH = {"1": "◆", "2": "◆◆", "3": "◆◆◆", "r": "⤳ reaction", "f": "◇ free"}
49
+
50
+
51
+ def _glyph(content):
52
+ c = content.strip().lower()
53
+ return GLYPH.get(c, content)
54
+
55
+
56
+ def _template(inner):
57
+ parts = inner.split("|")
58
+ ttype = parts[0]
59
+ dist = width = None
60
+ for p in parts[1:]:
61
+ if p.startswith("distance:"):
62
+ dist = p.split(":", 1)[1]
63
+ elif p.startswith("width:"):
64
+ width = p.split(":", 1)[1]
65
+ if dist and ttype == "line" and width:
66
+ return f"{dist}-foot line ({width} ft wide)"
67
+ if dist:
68
+ return f"{dist}-foot {ttype}"
69
+ return ttype
70
+
71
+
72
+ def _damage(inner):
73
+ inner = inner.split(",")[0]
74
+ mtype = re.search(r"\[([^\]]+)\]", inner)
75
+ dtype = mtype.group(1).split(",")[0].strip() if mtype else ""
76
+ formula = inner[:mtype.start()] if mtype else inner
77
+ formula = formula.split("|")[0]
78
+ return f"{formula.strip()} {dtype}".strip()
79
+
80
+
81
+ def _check(inner):
82
+ stat = inner.split("|")[0].split(":")[-1]
83
+ return f"{stat.capitalize()} check"
84
+
85
+
86
+ def _uuid_label(inner):
87
+ seg = inner.split(".")[-1]
88
+ seg = re.sub(r"^(spell|feat|item|action|condition)s?[-_]?", "", seg, flags=re.I)
89
+ return seg.replace("-", " ").replace("_", " ").strip()
90
+
91
+
92
+ def clean_html(raw):
93
+ if not raw:
94
+ return ""
95
+ s = raw
96
+ s = re.sub(r'<span class="action-glyph">(.*?)</span>',
97
+ lambda m: _glyph(m.group(1)), s, flags=re.S)
98
+ s = re.sub(r"@Template\[([^\]]+)\]", lambda m: _template(m.group(1)), s)
99
+ dmg = r"@Damage\[((?:[^\[\]]|\[[^\]]*\])*)\](?:\{([^}]+)\})?"
100
+ s = re.sub(dmg, lambda m: m.group(2) or _damage(m.group(1)), s)
101
+ s = re.sub(r"@Check\[([^\]]*)\]\{([^}]+)\}", r"\2", s)
102
+ s = re.sub(r"@Check\[([^\]]*)\]", lambda m: _check(m.group(1)), s)
103
+ s = re.sub(r"@UUID\[[^\]]+\]\{([^}]+)\}", r"\1", s)
104
+ s = re.sub(r"@UUID\[([^\]]+)\]", lambda m: _uuid_label(m.group(1)), s)
105
+ s = re.sub(r"@[A-Za-z]+\[[^\]]*\]\{([^}]+)\}", r"\1", s)
106
+ s = re.sub(r"@[A-Za-z]+\[[^\]]*\]", "", s)
107
+ s = re.sub(r"<hr\s*/?>", "\n", s)
108
+ s = re.sub(r"<li[^>]*>", "\n• ", s)
109
+ s = re.sub(r"</li>", "", s)
110
+ s = re.sub(r"<br\s*/?>", "\n", s)
111
+ s = re.sub(r"<p[^>]*>", "\n\n", s)
112
+ s = re.sub(r"</(p|ul|ol|div|table|tr)>", "\n", s)
113
+ s = re.sub(r"<[^>]+>", "", s)
114
+ # drop any remaining Foundry variable interpolation
115
+ s = re.sub(r"@[a-z]+\.[\w.]+(?:\*\d+)?", "", s, flags=re.I)
116
+ s = html.unescape(s)
117
+ s = re.sub(r"[ \t]+", " ", s)
118
+ s = re.sub(r" *\n *", "\n", s)
119
+ s = re.sub(r"\n{3,}", "\n\n", s)
120
+ return s.strip()
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ def load_json(path):
125
+ with open(path, encoding="utf-8") as fh:
126
+ return json.load(fh)
127
+
128
+
129
+ def transform_condition(doc):
130
+ sysd = doc.get("system", {})
131
+ value = sysd.get("value") or {}
132
+ pub = sysd.get("publication") or sysd.get("source") or {}
133
+ return {
134
+ "slug": doc.get("system", {}).get("slug") or slug_from(doc),
135
+ "name": doc.get("name", ""),
136
+ "description": clean_html((sysd.get("description") or {}).get("value", "")),
137
+ "valued": bool(value.get("isValued")),
138
+ "group": sysd.get("group") or None,
139
+ "_license": pub.get("license") or "",
140
+ "_source": pub.get("title") or "",
141
+ }
142
+
143
+
144
+ def transform_action(doc):
145
+ sysd = doc.get("system", {})
146
+ traits = (sysd.get("traits") or {}).get("value", []) or []
147
+ pub = sysd.get("publication") or sysd.get("source") or {}
148
+ return {
149
+ "slug": sysd.get("slug") or slug_from(doc),
150
+ "name": doc.get("name", ""),
151
+ "category": sysd.get("category") or "",
152
+ "traits": traits,
153
+ "exploration": "exploration" in traits,
154
+ "actionType": (sysd.get("actionType") or {}).get("value", ""),
155
+ "actions": (sysd.get("actions") or {}).get("value"),
156
+ "description": clean_html((sysd.get("description") or {}).get("value", "")),
157
+ "_license": pub.get("license") or "",
158
+ "_source": pub.get("title") or "",
159
+ }
160
+
161
+
162
+ def slug_from(doc):
163
+ name = doc.get("name", "item")
164
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
165
+
166
+
167
+ def collect(pattern):
168
+ out = []
169
+ for path in sorted(glob.glob(pattern, recursive=True)):
170
+ try:
171
+ doc = load_json(path)
172
+ except Exception:
173
+ continue
174
+ yield_doc = doc if isinstance(doc, dict) else None
175
+ if yield_doc:
176
+ out.append(yield_doc)
177
+ return out
178
+
179
+
180
+ def main():
181
+ ap = argparse.ArgumentParser()
182
+ ap.add_argument("--src", required=True, help="path to <clone>/packs/pf2e")
183
+ ap.add_argument("--out", required=True, help="output .js path")
184
+ args = ap.parse_args()
185
+
186
+ cond_docs = collect(os.path.join(args.src, "conditions", "**", "*.json"))
187
+ # Only the GM-facing general actions (not every class/ancestry/archetype feat)
188
+ # — this keeps the reference relevant to play and the bundle small.
189
+ # Seek/Sense Motive are in basic/; Track/Cover Tracks/Recall Knowledge/Treat
190
+ # Wounds in skill/ & downtime/. subsystems/ is what the GM Core subsystem
191
+ # pages point at: Influence and Discover carry the whole substance of the
192
+ # Influence page, and the six infiltration preparation activities are only
193
+ # named there, so without this folder those pages render as bare headings.
194
+ act_docs = []
195
+ for sub in ("exploration", "skill", "basic", "downtime", "subsystems"):
196
+ act_docs.extend(collect(os.path.join(args.src, "actions", sub, "**", "*.json")))
197
+
198
+ conditions, actions = [], []
199
+ src_counts = {}
200
+
201
+ for doc in cond_docs:
202
+ if doc.get("type") != "condition":
203
+ continue
204
+ c = transform_condition(doc)
205
+ _tally(src_counts, c)
206
+ conditions.append(_strip_private(c))
207
+
208
+ for doc in act_docs:
209
+ if doc.get("type") != "action":
210
+ continue
211
+ a = transform_action(doc)
212
+ _tally(src_counts, a)
213
+ actions.append(_strip_private(a))
214
+
215
+ conditions.sort(key=lambda x: x["name"])
216
+ actions.sort(key=lambda x: x["name"])
217
+
218
+ commit = src_commit(args.src)
219
+ meta = {
220
+ "generated": datetime.date.today().isoformat(),
221
+ "source": "foundryvtt/pf2e",
222
+ "sourceCommit": commit,
223
+ "conditions": len(conditions),
224
+ "actions": len(actions),
225
+ "sources": [{"title": t, "license": lic, "count": n}
226
+ for (t, lic), n in sorted(src_counts.items(), key=lambda kv: -kv[1])],
227
+ }
228
+
229
+ header = ("/* data/reference.generated.js — AUTO-GENERATED. Do not hand-edit.\n"
230
+ " Rebuild with: npm run build:ref\n"
231
+ " Source: foundryvtt/pf2e condition + action data (Paizo content, OGL/ORC). */\n")
232
+ with open(args.out, "w", encoding="utf-8") as fh:
233
+ fh.write(header)
234
+ fh.write("const GENERATED_REF_META = " + json.dumps(meta, ensure_ascii=False) + ";\n")
235
+ fh.write("const GENERATED_CONDITIONS = " + json.dumps(conditions, ensure_ascii=False) + ";\n")
236
+ fh.write("const GENERATED_ACTIONS = " + json.dumps(actions, ensure_ascii=False) + ";\n")
237
+
238
+ print(f"wrote {args.out}: {len(conditions)} conditions, {len(actions)} actions"
239
+ + (f" (commit {commit})" if commit else ""))
240
+
241
+
242
+ def _tally(counts, item):
243
+ key = (item.get("_source") or "Unknown", item.get("_license") or "")
244
+ counts[key] = counts.get(key, 0) + 1
245
+
246
+
247
+ def _strip_private(item):
248
+ return {k: v for k, v in item.items() if not k.startswith("_")}
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ build_subsystems.py — Foundry GM Screen journal -> data/subsystems.generated.js
4
+
5
+ The GM Core subsystems chapter ships inside the open-source Foundry VTT pf2e
6
+ system as one journal, `packs/pf2e/journals/gm-screen.json`. Every subsystem is
7
+ a page of that journal, carrying its own Paizo page citation, so the rules text
8
+ in this app is generated rather than transcribed and cannot drift from the book.
9
+
10
+ Get it with a sparse, shallow clone (only the packs we need). The subsystem
11
+ pages are present on the v14-dev branch:
12
+
13
+ git clone --depth 1 --filter=blob:none --sparse -b v14-dev \\
14
+ https://github.com/foundryvtt/pf2e.git pf2e-data
15
+ cd pf2e-data && git sparse-checkout set packs/pf2e/journals \\
16
+ packs/pf2e/conditions packs/pf2e/actions && cd ..
17
+
18
+ python3 tools/build_subsystems.py \\
19
+ --src pf2e-data/packs/pf2e/journals/gm-screen.json \\
20
+ --out data/subsystems.generated.js
21
+
22
+ Emits three consts:
23
+
24
+ GENERATED_SUB_META {generated, source, sourceBranch, sourceCommit,
25
+ pages, sources[]}
26
+ GENERATED_SUBSYSTEMS [{slug, name, html, section, book, pages}, ...]
27
+ GENERATED_CHASE_OBSTACLES [{table, name, level, options:[{skill,dc,how}]}, ...]
28
+ GENERATED_VP_SCALES [{duration, endPoint, thresholds:[int]}, ...]
29
+
30
+ The three @-markups Foundry uses inline are rewritten, not dropped:
31
+ @Check[skill|dc:N] -> <span class="chk" data-skill data-dc>
32
+ @UUID[Compendium...Item.Name] -> <span class="actref" data-action=slug>
33
+ @Embed[Compendium...Item.id inline] -> removed; the engine inlines the
34
+ action's own text from the reference
35
+ data next to the actref marker.
36
+ """
37
+ import argparse
38
+ import datetime
39
+ import html as htmllib
40
+ import json
41
+ import os
42
+ import re
43
+ import subprocess
44
+
45
+ # The subsystem pages, in the order GM Core presents them. The first five have
46
+ # runners in the app; the rest ship as reference text only.
47
+ PAGES = [
48
+ "Victory Points", "Influence", "Research", "Chases", "Infiltration",
49
+ "Reputation", "Duels", "Leadership", "Hexploration",
50
+ ]
51
+
52
+ # Tags worth keeping. Everything else is unwrapped (content kept, tag dropped).
53
+ KEEP_TAGS = {
54
+ "p", "strong", "em", "h1", "h2", "h3", "h4", "ul", "li", "hr",
55
+ "table", "thead", "tbody", "tr", "th", "td", "caption", "span",
56
+ }
57
+ # `span` survives only to carry the markers rewrite_checks/rewrite_refs make;
58
+ # Foundry's own styled spans keep none of these attributes and are unwrapped.
59
+ KEEP_ATTRS = {"colspan", "rowspan", "class", "data-skill", "data-dc",
60
+ "data-action"}
61
+
62
+ CITATION_RE = re.compile(
63
+ r"<p>\s*<em>Section:\s*(?P<section>[^<]*?)\s*</em>\s*"
64
+ r"<span[^>]*>\s*<em>\s*(?P<book>.*?)\s+pg\.\s*(?P<pages>[\d\-–,\s]+?)\s*</em>\s*</span>\s*</p>",
65
+ re.S,
66
+ )
67
+
68
+
69
+ def src_commit(path):
70
+ """Short HEAD of the clone `path` lives in, or None outside a checkout."""
71
+ try:
72
+ return subprocess.check_output(
73
+ ["git", "-C", os.path.dirname(os.path.abspath(path)),
74
+ "rev-parse", "--short", "HEAD"],
75
+ stderr=subprocess.DEVNULL, text=True).strip()
76
+ except Exception:
77
+ return None
78
+
79
+
80
+ def slugify(name):
81
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
82
+
83
+
84
+ def rewrite_checks(text):
85
+ """@Check[acrobatics|dc:13|...] -> a readable, data-carrying span."""
86
+ def one(m):
87
+ parts = m.group(1).split("|")
88
+ skill = parts[0].strip().lower()
89
+ dc = None
90
+ for p in parts[1:]:
91
+ d = re.match(r"\s*dc:\s*(\d+)", p)
92
+ if d:
93
+ dc = int(d.group(1))
94
+ label = skill.replace("-", " ").title()
95
+ if dc is None:
96
+ return f'<span class="chk" data-skill="{skill}">{label}</span>'
97
+ return (f'<span class="chk" data-skill="{skill}" data-dc="{dc}">'
98
+ f'{label} DC {dc}</span>')
99
+ return re.sub(r"@Check\[([^\]]+)\]", one, text)
100
+
101
+
102
+ def rewrite_refs(text):
103
+ """@UUID[...Item.Name] -> an action marker; @Embed[...] -> removed."""
104
+ def one(m):
105
+ target = m.group(1).rsplit(".Item.", 1)[-1] if ".Item." in m.group(1) \
106
+ else m.group(1).lstrip(".")
107
+ # Bare Foundry ids (no readable name) carry nothing a reader can use.
108
+ if not re.search(r"[a-z]", target) or not re.search(r"[A-Z]", target) \
109
+ or re.fullmatch(r"[A-Za-z0-9]{16}", target):
110
+ return ""
111
+ return f'<span class="actref" data-action="{slugify(target)}">{target}</span>'
112
+ text = re.sub(r"@UUID\[([^\]]+)\]", one, text)
113
+ text = re.sub(r"@Embed\[[^\]]+\]", "", text)
114
+ return text
115
+
116
+
117
+ def _balance_spans(text):
118
+ """Drop </span> closers left behind when an opening <span> was unwrapped."""
119
+ out, depth, pos = [], 0, 0
120
+ for m in re.finditer(r"<(/?)span[^>]*>", text):
121
+ out.append(text[pos:m.start()])
122
+ if m.group(1):
123
+ if depth:
124
+ depth -= 1
125
+ out.append(m.group(0))
126
+ else:
127
+ depth += 1
128
+ out.append(m.group(0))
129
+ pos = m.end()
130
+ out.append(text[pos:])
131
+ return "".join(out)
132
+
133
+
134
+ def clean_html(text):
135
+ """Whitelist tags and attributes; Foundry's inline styles go."""
136
+ def tag(m):
137
+ closing, name, attrs = m.group(1), m.group(2).lower(), m.group(3) or ""
138
+ if name not in KEEP_TAGS:
139
+ return ""
140
+ if closing:
141
+ return f"</{name}>"
142
+ kept = " ".join(
143
+ f'{a.lower()}="{v}"'
144
+ for a, v in re.findall(r'([a-zA-Z-]+)\s*=\s*"([^"]*)"', attrs)
145
+ if a.lower() in KEEP_ATTRS
146
+ )
147
+ if name == "span" and not kept:
148
+ return ""
149
+ return f"<{name}{' ' + kept if kept else ''}>"
150
+ text = re.sub(r"<(/?)([a-zA-Z0-9]+)([^>]*)>", tag, text)
151
+ # Unwrapping an opening <span> would strand its </span>; drop any closer
152
+ # that no longer has an opener.
153
+ text = _balance_spans(text)
154
+ text = re.sub(r"<p>\s*</p>", "", text)
155
+ return re.sub(r"\s+", " ", text).strip()
156
+
157
+
158
+ def parse_level(s):
159
+ m = re.search(r"\((\d+)(?:st|nd|rd|th)\)", s)
160
+ return int(m.group(1)) if m else None
161
+
162
+
163
+ def parse_options(fragment):
164
+ """Pull (skill, dc, how) triples out of one obstacle cell."""
165
+ out = []
166
+ for m in re.finditer(
167
+ r'<span class="chk" data-skill="([^"]+)" data-dc="(\d+)">[^<]*</span>'
168
+ r'(?P<tail>[^<;]*)', fragment):
169
+ how = m.group("tail").strip(" ,;.")
170
+ how = re.sub(r"^to\s+", "", how)
171
+ out.append({"skill": m.group(1), "dc": int(m.group(2)),
172
+ "how": how or None})
173
+ return out
174
+
175
+
176
+ def parse_chase_obstacles(page_html):
177
+ """Each table row is a low-level obstacle and its high-level variant."""
178
+ obstacles = []
179
+ for table in re.findall(r"<table.*?</table>", page_html, re.S):
180
+ rows = re.findall(r"<tr>(.*?)</tr>", table, re.S)
181
+ if not rows:
182
+ continue
183
+ group = re.sub(r"<[^>]+>", "", rows[0]).replace("Obstacles", "").strip()
184
+ for row in rows[1:]:
185
+ cells = re.findall(r"<td[^>]*>(.*?)</td>", row, re.S)
186
+ if len(cells) != 2:
187
+ continue
188
+ name_cell, detail = cells
189
+ # The low-level obstacle: name from cell one, checks from the
190
+ # first paragraph of cell two.
191
+ paras = re.findall(r"<p>(.*?)</p>", detail, re.S) or [detail]
192
+ base_name = re.sub(r"<[^>]+>", "", name_cell).strip()
193
+ entry = {"table": group,
194
+ "name": re.sub(r"\s*\(\d+\w*\)\s*$", "", base_name).strip(),
195
+ "level": parse_level(base_name),
196
+ "options": parse_options(paras[0])}
197
+ if entry["options"]:
198
+ obstacles.append(entry)
199
+ # The high-level variant: bolded name, then its own checks.
200
+ for para in paras[1:]:
201
+ v = re.match(r"\s*<strong>(.*?)</strong>(.*)", para, re.S)
202
+ if not v:
203
+ continue
204
+ vname = re.sub(r"<[^>]+>", "", v.group(1)).strip()
205
+ ventry = {"table": group,
206
+ "name": re.sub(r"\s*\(\d+\w*\)\s*$", "", vname).strip(),
207
+ "level": parse_level(vname),
208
+ "options": parse_options(v.group(2))}
209
+ if ventry["options"]:
210
+ obstacles.append(ventry)
211
+ return obstacles
212
+
213
+
214
+ def parse_vp_scales(page_html):
215
+ """The Victory Point Scales table: duration -> end point -> thresholds."""
216
+ scales = []
217
+ for table in re.findall(r"<table.*?</table>", page_html, re.S):
218
+ if "VP End Point" not in re.sub(r"<[^>]+>", " ", table):
219
+ continue
220
+ for row in re.findall(r"<tr>(.*?)</tr>", table, re.S):
221
+ cells = [re.sub(r"<[^>]+>", "", c).strip()
222
+ for c in re.findall(r"<td[^>]*>(.*?)</td>", row, re.S)]
223
+ if len(cells) != 3:
224
+ continue
225
+ scales.append({
226
+ "duration": cells[0],
227
+ "endPoint": cells[1],
228
+ "thresholds": [int(n) for n in re.findall(r"\d+", cells[2])],
229
+ })
230
+ return scales
231
+
232
+
233
+ def main():
234
+ ap = argparse.ArgumentParser()
235
+ ap.add_argument("--src", required=True,
236
+ help="path to Foundry packs/pf2e/journals/gm-screen.json")
237
+ ap.add_argument("--out", required=True, help="path to write")
238
+ ap.add_argument("--branch", default="v14-dev",
239
+ help="the branch --src was cloned from (recorded in the stamp)")
240
+ args = ap.parse_args()
241
+
242
+ with open(args.src, encoding="utf-8") as fh:
243
+ journal = json.load(fh)
244
+ by_name = {p["name"]: p for p in journal.get("pages", [])}
245
+
246
+ missing = [n for n in PAGES if n not in by_name]
247
+ if missing:
248
+ raise SystemExit(
249
+ "gm-screen.json is missing these subsystem pages: "
250
+ + ", ".join(missing)
251
+ + "\nIs the clone on the right branch? (see the docstring)")
252
+
253
+ subsystems, obstacles, scales, books = [], [], [], {}
254
+ for name in PAGES:
255
+ raw = by_name[name]["text"]["content"]
256
+
257
+ cite = CITATION_RE.search(raw)
258
+ if not cite:
259
+ raise SystemExit(f"no Paizo citation found on the {name!r} page — "
260
+ "refusing to ship rules text with no attribution")
261
+ raw = CITATION_RE.sub("", raw)
262
+
263
+ body = clean_html(rewrite_refs(rewrite_checks(raw)))
264
+ book = htmllib.unescape(cite.group("book")).strip()
265
+ books[book] = books.get(book, 0) + 1
266
+
267
+ subsystems.append({
268
+ "slug": slugify(name),
269
+ "name": name,
270
+ "section": htmllib.unescape(cite.group("section")).strip(),
271
+ "book": book,
272
+ "pages": cite.group("pages").strip(),
273
+ "html": body,
274
+ })
275
+ if name == "Chases":
276
+ obstacles = parse_chase_obstacles(body)
277
+ if name == "Victory Points":
278
+ scales = parse_vp_scales(body)
279
+
280
+ if not obstacles:
281
+ raise SystemExit("no chase obstacles parsed — the tables moved")
282
+ if not scales:
283
+ raise SystemExit("no VP scales parsed — the table moved")
284
+
285
+ meta = {
286
+ "generated": datetime.date.today().isoformat(),
287
+ "source": "foundryvtt/pf2e",
288
+ "sourceBranch": args.branch,
289
+ "sourceCommit": src_commit(args.src),
290
+ "pages": len(subsystems),
291
+ "obstacles": len(obstacles),
292
+ "sources": [{"title": t, "license": "ORC", "count": n}
293
+ for t, n in sorted(books.items(), key=lambda kv: -kv[1])],
294
+ }
295
+
296
+ def const(name, value):
297
+ return f"const {name} = {json.dumps(value, ensure_ascii=False)};\n"
298
+
299
+ with open(args.out, "w", encoding="utf-8") as fh:
300
+ fh.write(
301
+ "/* data/subsystems.generated.js — AUTO-GENERATED. Do not hand-edit.\n"
302
+ " Rebuild with: npm run build:sub\n"
303
+ " Source: foundryvtt/pf2e GM Screen journal (Paizo content, ORC). */\n")
304
+ fh.write(const("GENERATED_SUB_META", meta))
305
+ fh.write(const("GENERATED_SUBSYSTEMS", subsystems))
306
+ fh.write(const("GENERATED_CHASE_OBSTACLES", obstacles))
307
+ fh.write(const("GENERATED_VP_SCALES", scales))
308
+
309
+ print(f"wrote {args.out}: {len(subsystems)} subsystems, "
310
+ f"{len(obstacles)} chase obstacles, {len(scales)} VP scales "
311
+ f"(source {meta['sourceCommit'] or 'unknown'}@{args.branch})")
312
+
313
+
314
+ if __name__ == "__main__":
315
+ main()