pf2e-party-tracker 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 +70 -0
- package/bin/cli.mjs +86 -0
- package/data/reference.generated.js +6 -0
- package/dist/icon.svg +5 -0
- package/dist/index.html +1330 -0
- package/dist/manifest.webmanifest +14 -0
- package/dist/sw.js +62 -0
- package/notice.md +81 -0
- package/package.json +47 -0
- package/src/config.js +140 -0
- package/src/engine.js +766 -0
- package/src/icon.svg +5 -0
- package/src/manifest.webmanifest +14 -0
- package/src/styles.css +298 -0
- package/src/sw.js +62 -0
- package/src/template.html +120 -0
- package/tools/build.py +60 -0
- package/tools/build_reference.py +249 -0
- package/tools/fixtures/investigator.json +1 -0
- package/tools/test.mjs +165 -0
|
@@ -0,0 +1,249 @@
|
|
|
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 exploration & passive play and the
|
|
189
|
+
# bundle small. Seek/Sense Motive are in basic/; Track/Cover Tracks/Recall
|
|
190
|
+
# Knowledge/Treat Wounds in skill/ & downtime/.
|
|
191
|
+
act_docs = []
|
|
192
|
+
for sub in ("exploration", "skill", "basic", "downtime"):
|
|
193
|
+
act_docs.extend(collect(os.path.join(args.src, "actions", sub, "**", "*.json")))
|
|
194
|
+
|
|
195
|
+
conditions, actions = [], []
|
|
196
|
+
src_counts = {}
|
|
197
|
+
|
|
198
|
+
for doc in cond_docs:
|
|
199
|
+
if doc.get("type") != "condition":
|
|
200
|
+
continue
|
|
201
|
+
c = transform_condition(doc)
|
|
202
|
+
_tally(src_counts, c)
|
|
203
|
+
conditions.append(_strip_private(c))
|
|
204
|
+
|
|
205
|
+
for doc in act_docs:
|
|
206
|
+
if doc.get("type") != "action":
|
|
207
|
+
continue
|
|
208
|
+
a = transform_action(doc)
|
|
209
|
+
_tally(src_counts, a)
|
|
210
|
+
actions.append(_strip_private(a))
|
|
211
|
+
|
|
212
|
+
conditions.sort(key=lambda x: x["name"])
|
|
213
|
+
actions.sort(key=lambda x: x["name"])
|
|
214
|
+
|
|
215
|
+
commit = src_commit(args.src)
|
|
216
|
+
meta = {
|
|
217
|
+
"generated": datetime.date.today().isoformat(),
|
|
218
|
+
"source": "foundryvtt/pf2e",
|
|
219
|
+
"sourceCommit": commit,
|
|
220
|
+
"conditions": len(conditions),
|
|
221
|
+
"actions": len(actions),
|
|
222
|
+
"sources": [{"title": t, "license": lic, "count": n}
|
|
223
|
+
for (t, lic), n in sorted(src_counts.items(), key=lambda kv: -kv[1])],
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
header = ("/* data/reference.generated.js — AUTO-GENERATED. Do not hand-edit.\n"
|
|
227
|
+
" Rebuild with: npm run build:ref\n"
|
|
228
|
+
" Source: foundryvtt/pf2e condition + action data (Paizo content, OGL/ORC). */\n")
|
|
229
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
230
|
+
fh.write(header)
|
|
231
|
+
fh.write("const GENERATED_REF_META = " + json.dumps(meta, ensure_ascii=False) + ";\n")
|
|
232
|
+
fh.write("const GENERATED_CONDITIONS = " + json.dumps(conditions, ensure_ascii=False) + ";\n")
|
|
233
|
+
fh.write("const GENERATED_ACTIONS = " + json.dumps(actions, ensure_ascii=False) + ";\n")
|
|
234
|
+
|
|
235
|
+
print(f"wrote {args.out}: {len(conditions)} conditions, {len(actions)} actions"
|
|
236
|
+
+ (f" (commit {commit})" if commit else ""))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _tally(counts, item):
|
|
240
|
+
key = (item.get("_source") or "Unknown", item.get("_license") or "")
|
|
241
|
+
counts[key] = counts.get(key, 0) + 1
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _strip_private(item):
|
|
245
|
+
return {k: v for k, v in item.items() if not k.startswith("_")}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"success":true,"build":{"name":"Perengis \"Perry\" Xvant","class":"Investigator","level":2,"ancestry":"Human","heritage":"Half-Elf","background":"Lesser Scion","alignment":"NG","gender":"Female","age":"20","deity":"None","size":2,"keyability":"int","languages":["Common","Dwarven","Elven","Goblin","Orcish","Sylvan"],"attributes":{"ancestryhp":8,"classhp":8,"bonushp":0,"bonushpPerLevel":0,"speed":25,"speedBonus":0},"abilities":{"str":10,"dex":14,"con":10,"int":18,"wis":14,"cha":12},"proficiencies":{"classDC":2,"perception":4,"fortitude":2,"reflex":4,"will":4,"heavy":0,"medium":0,"light":2,"unarmored":2,"advanced":0,"martial":2,"simple":2,"unarmed":2,"castingArcane":0,"castingDivine":0,"castingOccult":0,"castingPrimal":0,"acrobatics":2,"arcana":2,"athletics":0,"crafting":0,"deception":2,"diplomacy":2,"intimidation":2,"medicine":4,"nature":0,"occultism":2,"performance":0,"religion":2,"society":2,"stealth":2,"survival":0,"thievery":2},"feats":[["Hobnobber",null],["Battle Medicine",null],["Forensic Acumen",null],["Natural Ambition",null,"Ancestry Feat",1],["That's Odd",null,"Class Feat",1],["Half-Elf",null,"Heritage",1],["Known Weaknesses",null,"Class Feat",1],["Shared Stratagem",null,"Class Feat",2],["Continual Recovery",null,"Skill Feat",2]],"specials":["Low-Light Vision","Devise a Stratagem","On the Case","Clue In","Strategic Strike","Forensic Medicine Methodology","Pursue a Lead","Half-Elf"],"lores":[["Heraldry",2],["Osirion",0]],"equipment":[["Bedroll",1],["Chalk",10],["Flint and Steel",1],["Rope",1],["Rations",2],["Torch",5],["Waterskin",1],["Soap",1],["Healer's Tools",1],["Arrows",30],["Thieves' Tools",1],["Healing Potion (Minor)",1]],"specificProficiencies":{"trained":[],"expert":[],"master":[],"legendary":[]},"weapons":[{"name":"Bo Staff","qty":1,"prof":"martial","die":"d8","pot":0,"str":"","display":"Bo Staff","runes":[]},{"name":"Shortbow","qty":1,"prof":"martial","die":"d6","pot":0,"str":"","display":"Shortbow","runes":[]}],"money":{"pp":0,"gp":0,"sp":0,"cp":0},"armor":[{"name":"Leather Armor","qty":1,"prof":"light","pot":0,"res":"","display":"Leather","worn":true,"runes":[]}],"spellCasters":[],"formula":[],"pets":[],"acTotal":{"acProfBonus":4,"acAbilityBonus":2,"acItemBonus":1,"acTotal":17}}}
|
package/tools/test.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { JSDOM, VirtualConsole } from "jsdom";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
const html = fs.readFileSync(new URL("../dist/index.html", import.meta.url), "utf8");
|
|
5
|
+
|
|
6
|
+
let pass = 0, fail = 0;
|
|
7
|
+
function ok(cond, msg) { (cond ? pass++ : fail++); console.log((cond ? " ✓ " : " ✗ FAIL ") + msg); }
|
|
8
|
+
function eq(a, b, msg) { ok(a === b, `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`); }
|
|
9
|
+
|
|
10
|
+
const errors = [];
|
|
11
|
+
const vc = new VirtualConsole();
|
|
12
|
+
vc.on("jsdomError", (e) => errors.push(e.message));
|
|
13
|
+
|
|
14
|
+
const dom = new JSDOM(html, {
|
|
15
|
+
runScripts: "dangerously",
|
|
16
|
+
virtualConsole: vc,
|
|
17
|
+
url: "https://example.org/",
|
|
18
|
+
beforeParse(window) { window.scrollTo = () => {}; window.confirm = () => true; window.alert = () => {}; },
|
|
19
|
+
});
|
|
20
|
+
const w = dom.window;
|
|
21
|
+
const d = w.document;
|
|
22
|
+
const ev = (expr) => w.eval(expr);
|
|
23
|
+
|
|
24
|
+
/* The real L2 Investigator export (tools/fixtures/investigator.json), inlined so
|
|
25
|
+
the test is self-contained. Every derived value below is hand-computed. */
|
|
26
|
+
const FIX = {
|
|
27
|
+
success: true,
|
|
28
|
+
build: {
|
|
29
|
+
name: 'Perengis "Perry" Xvant', class: "Investigator", level: 2,
|
|
30
|
+
ancestry: "Half-Elf", heritage: "", background: "Criminal", size: 2, keyability: "int",
|
|
31
|
+
languages: ["Common", "Dwarven", "Elven", "Goblin", "Orcish", "Sylvan"],
|
|
32
|
+
abilities: { str: 10, dex: 14, con: 10, int: 18, wis: 14, cha: 12 },
|
|
33
|
+
attributes: { ancestryhp: 8, classhp: 8, bonushp: 0, bonushpPerLevel: 0, speed: 25, speedBonus: 0 },
|
|
34
|
+
proficiencies: {
|
|
35
|
+
perception: 4, fortitude: 2, reflex: 4, will: 4, classDC: 2,
|
|
36
|
+
acrobatics: 2, arcana: 2, athletics: 0, crafting: 0, deception: 2, diplomacy: 2,
|
|
37
|
+
intimidation: 2, medicine: 4, nature: 0, occultism: 2, performance: 0, religion: 2,
|
|
38
|
+
society: 2, stealth: 2, survival: 0, thievery: 2,
|
|
39
|
+
},
|
|
40
|
+
acTotal: { acProfBonus: 4, acAbilityBonus: 2, acItemBonus: 1, acTotal: 17 },
|
|
41
|
+
lores: [["Heraldry", 2], ["Osirion", 0]],
|
|
42
|
+
specials: ["Low-Light Vision", "Devise a Stratagem", "Half-Elf"],
|
|
43
|
+
spellCasters: [],
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
console.log("\n# load / no script errors");
|
|
48
|
+
ok(errors.length === 0, "no jsdom script errors" + (errors.length ? ": " + errors[0] : ""));
|
|
49
|
+
ok(!html.includes("/*__INJECT_"), "all build markers were replaced");
|
|
50
|
+
ok(ev("pcs().length") === 0, "starts with an empty party");
|
|
51
|
+
ok(!d.getElementById("view-roster").classList.contains("hide"), "lands on the roster view");
|
|
52
|
+
ok(/No characters yet/.test(d.getElementById("view-roster").innerHTML), "roster shows the empty state");
|
|
53
|
+
|
|
54
|
+
console.log("\n# arithmetic helpers");
|
|
55
|
+
eq(w.abilityMod(18), 4, "abilityMod(18)");
|
|
56
|
+
eq(w.abilityMod(10), 0, "abilityMod(10)");
|
|
57
|
+
eq(w.abilityMod(7), -2, "abilityMod(7)");
|
|
58
|
+
eq(w.profBonus(0, 5), 0, "profBonus untrained adds neither rank nor level");
|
|
59
|
+
eq(w.profBonus(2, 1), 3, "profBonus trained @L1 = 2+1");
|
|
60
|
+
eq(w.profBonus(8, 20), 28, "profBonus legendary @L20 = 8+20");
|
|
61
|
+
|
|
62
|
+
console.log("\n# parsePathbuilder — derived stats vs the real fixture");
|
|
63
|
+
const pc = w.parsePathbuilder(FIX);
|
|
64
|
+
eq(pc.name, 'Perengis "Perry" Xvant', "name (whitespace collapsed)");
|
|
65
|
+
eq(pc.level, 2, "level");
|
|
66
|
+
eq(pc.mods.int, 4, "INT mod +4");
|
|
67
|
+
eq(pc.mods.dex, 2, "DEX mod +2");
|
|
68
|
+
eq(pc.mods.con, 0, "CON mod 0");
|
|
69
|
+
eq(pc.perception, 8, "Perception +8 (wis+2, expert 4+2)");
|
|
70
|
+
eq(pc.perceptionPassive, 18, "Perception passive 18");
|
|
71
|
+
eq(pc.saves.fortitude, 4, "Fort +4");
|
|
72
|
+
eq(pc.saves.reflex, 8, "Ref +8");
|
|
73
|
+
eq(pc.saves.will, 8, "Will +8");
|
|
74
|
+
eq(pc.ac, 17, "AC 17 (uses acTotal.acTotal)");
|
|
75
|
+
eq(pc.classDC, 18, "Class DC 18 (10 + prof 6 + int 4) — no double level");
|
|
76
|
+
eq(pc.hpMax, 24, "HP max 24 (8 + (8+0)*2)");
|
|
77
|
+
eq(pc.speed, 25, "Speed 25");
|
|
78
|
+
eq(pc.skills.athletics.mod, 0, "Athletics +0 (untrained, str 0)");
|
|
79
|
+
eq(pc.skills.arcana.mod, 8, "Arcana +8 (int 4, trained 2+2)");
|
|
80
|
+
eq(pc.skills.medicine.mod, 8, "Medicine +8 (wis 2, expert 4+2)");
|
|
81
|
+
eq(pc.skills.deception.mod, 5, "Deception +5 (cha 1, trained 2+2)");
|
|
82
|
+
eq(pc.skills.nature.mod, 2, "Nature +2 (untrained, wis 2)");
|
|
83
|
+
eq(pc.skills.arcana.passive, 18, "Arcana passive 18");
|
|
84
|
+
eq(pc.lores.length, 2, "two lores");
|
|
85
|
+
eq(pc.lores[0].name + " " + pc.lores[0].mod, "Heraldry 8", "Heraldry Lore +8 (int 4, trained 2+2)");
|
|
86
|
+
eq(pc.lores[1].mod, 4, "Osirion Lore +4 (untrained, int 4)");
|
|
87
|
+
eq(pc.senses.join(","), "Low-Light Vision", "senses picks Low-Light Vision out of specials");
|
|
88
|
+
eq(pc.languages.length, 6, "6 languages");
|
|
89
|
+
eq(pc.spellcasting.length, 0, "no spellcasting");
|
|
90
|
+
|
|
91
|
+
console.log("\n# parsePathbuilder — synthetic spellcaster DC/attack");
|
|
92
|
+
const caster = w.parsePathbuilder({ build: {
|
|
93
|
+
name: "Sorc", level: 5, keyability: "cha",
|
|
94
|
+
abilities: { str: 8, dex: 14, con: 12, int: 10, wis: 10, cha: 18 },
|
|
95
|
+
attributes: { ancestryhp: 6, classhp: 6, bonushp: 0, bonushpPerLevel: 0, speed: 25, speedBonus: 0 },
|
|
96
|
+
proficiencies: { perception: 2, fortitude: 2, reflex: 2, will: 4, classDC: 0 },
|
|
97
|
+
acTotal: { acTotal: 20 }, lores: [],
|
|
98
|
+
spellCasters: [{ name: "Arcane", magicTradition: "arcane", ability: "cha", proficiency: 4, focusPoints: 1 }],
|
|
99
|
+
} });
|
|
100
|
+
eq(caster.spellcasting[0].dc, 10 + (4 + 5) + 4, "spell DC = 10 + prof(4,5) + cha 4 = 23");
|
|
101
|
+
eq(caster.spellcasting[0].attack, (4 + 5) + 4, "spell attack = prof(4,5) + cha 4 = +13");
|
|
102
|
+
eq(caster.focusPool, 1, "focus pool summed");
|
|
103
|
+
eq(caster.hpMax, 6 + (6 + 1) * 5, "HP with positive Con: 6 + (6+1)*5 = 41");
|
|
104
|
+
|
|
105
|
+
console.log("\n# validation");
|
|
106
|
+
let threw = false; try { w.parsePathbuilder({ build: { name: "x" } }); } catch (e) { threw = true; }
|
|
107
|
+
ok(threw, "missing core fields throws");
|
|
108
|
+
|
|
109
|
+
console.log("\n# commitImport — add, then re-import preserves live state & clamps HP");
|
|
110
|
+
w.commitImport(FIX, "102154");
|
|
111
|
+
eq(ev("pcs().length"), 1, "one character after import");
|
|
112
|
+
const id = ev("pcs()[0].id");
|
|
113
|
+
ev(`library.characters['${id}'].live.hpCur = 5; library.characters['${id}'].live.conditions=[{key:'frightened',value:2}]; library.characters['${id}'].live.heroPoints=3;`);
|
|
114
|
+
const r2 = w.commitImport(FIX, "102154");
|
|
115
|
+
ok(r2.updated === true, "re-import by pbId updates in place (no duplicate)");
|
|
116
|
+
eq(ev("pcs().length"), 1, "still one character (matched, not duplicated)");
|
|
117
|
+
eq(ev(`library.characters['${id}'].live.hpCur`), 5, "live HP preserved across re-import");
|
|
118
|
+
eq(ev(`library.characters['${id}'].live.conditions[0].value`), 2, "conditions preserved across re-import");
|
|
119
|
+
|
|
120
|
+
console.log("\n# roller — degrees of success");
|
|
121
|
+
eq(w.degreeOf(25, 10, 15), 3, "total >= DC+10 -> crit success");
|
|
122
|
+
eq(w.degreeOf(15, 10, 15), 2, "total >= DC -> success");
|
|
123
|
+
eq(w.degreeOf(14, 10, 15), 1, "below DC -> failure");
|
|
124
|
+
eq(w.degreeOf(5, 10, 15), 0, "total <= DC-10 -> crit failure");
|
|
125
|
+
eq(w.degreeOf(14, 20, 15), 2, "natural 20 shifts failure up to success");
|
|
126
|
+
eq(w.degreeOf(15, 1, 15), 1, "natural 1 shifts success down to failure");
|
|
127
|
+
eq(w.degreeOf(30, 10, ""), null, "no DC -> no degree");
|
|
128
|
+
|
|
129
|
+
console.log("\n# roller — rolls once per PC, sorted, uses stored mods");
|
|
130
|
+
w.go("roll");
|
|
131
|
+
const realRandom = w.Math.random; let seq = [0.0, 0.95], k = 0;
|
|
132
|
+
w.Math.random = () => seq[(k++) % seq.length];
|
|
133
|
+
d.getElementById("rollStat").value = "perception";
|
|
134
|
+
d.getElementById("rollDC").value = "";
|
|
135
|
+
w.rollForParty();
|
|
136
|
+
eq(ev("_lastRoll.rolls.length"), 1, "one roll for the one PC");
|
|
137
|
+
w.Math.random = realRandom;
|
|
138
|
+
|
|
139
|
+
console.log("\n# live tracking — damage soaks temp HP first, heal caps at max");
|
|
140
|
+
ev(`library.activeId='${id}';`);
|
|
141
|
+
ev(`library.characters['${id}'].live.hpCur=24; library.characters['${id}'].live.hpTemp=3;`);
|
|
142
|
+
w.go("roster");
|
|
143
|
+
w.setHP(id, 24);
|
|
144
|
+
ev(`(function(){var a=document.getElementById('amt-${id}'); if(a) a.value=5;})()`);
|
|
145
|
+
w.applyHP(id, -1);
|
|
146
|
+
eq(ev(`library.characters['${id}'].live.hpTemp`), 0, "temp HP absorbed first (3 of 5)");
|
|
147
|
+
eq(ev(`library.characters['${id}'].live.hpCur`), 22, "remaining 2 damage hits current (24 -> 22)");
|
|
148
|
+
w.applyHP(id, 1);
|
|
149
|
+
ev(`(function(){var a=document.getElementById('amt-${id}'); if(a) a.value=100;})()`);
|
|
150
|
+
w.applyHP(id, 1);
|
|
151
|
+
eq(ev(`library.characters['${id}'].live.hpCur`), 24, "heal caps at max HP");
|
|
152
|
+
|
|
153
|
+
console.log("\n# backup codes round-trip");
|
|
154
|
+
w.exportParty();
|
|
155
|
+
const code = d.getElementById("backupIO").value;
|
|
156
|
+
ok(code.startsWith("PF2EPARTY1:"), "party export produces a PF2EPARTY1 code");
|
|
157
|
+
d.getElementById("backupIO").value = code;
|
|
158
|
+
w.importBackup();
|
|
159
|
+
eq(ev("pcs().length"), 2, "importing the party code adds the characters back");
|
|
160
|
+
|
|
161
|
+
console.log("\n# no emoji in the shipped UI text");
|
|
162
|
+
ok(!/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(html.replace(/[✓✗]/g, "")), "no stray emoji in dist");
|
|
163
|
+
|
|
164
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
165
|
+
process.exit(fail ? 1 : 0);
|