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