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.
- package/LICENSE +31 -0
- package/README.md +110 -0
- package/bin/cli.mjs +86 -0
- package/data/reference.generated.js +6 -0
- package/data/subsystems.generated.js +7 -0
- package/dist/icon.svg +7 -0
- package/dist/index.html +1930 -0
- package/dist/manifest.webmanifest +13 -0
- package/dist/sw.js +60 -0
- package/notice.md +132 -0
- package/package.json +52 -0
- package/src/content.js +234 -0
- package/src/engine.js +1320 -0
- package/src/icon.svg +7 -0
- package/src/manifest.webmanifest +13 -0
- package/src/styles.css +294 -0
- package/src/sw.js +60 -0
- package/src/template.html +69 -0
- package/tools/build.py +62 -0
- package/tools/build_reference.py +252 -0
- package/tools/build_subsystems.py +315 -0
- package/tools/test.mjs +370 -0
|
@@ -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()
|