pf2e-primer 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()
package/tools/test.mjs ADDED
@@ -0,0 +1,277 @@
1
+ /* tools/test.mjs — loads the built dist/index.html in jsdom and checks the
2
+ things that can silently rot: the hand-authored character numbers, the
3
+ resolution rules the demos teach, and every rules slug the content cites.
4
+
5
+ Run: node tools/test.mjs (after python3 tools/build.py) */
6
+ import { JSDOM, VirtualConsole } from "jsdom";
7
+ import fs from "node:fs";
8
+
9
+ const html = fs.readFileSync(new URL("../dist/index.html", import.meta.url), "utf8");
10
+
11
+ let pass = 0, fail = 0;
12
+ function ok(cond, msg) { (cond ? pass++ : fail++); console.log((cond ? " ✓ " : " ✗ FAIL ") + msg); }
13
+ function eq(a, b, msg) { ok(a === b, `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`); }
14
+ function has(hay, needle, msg) { ok(String(hay).indexOf(needle) !== -1, `${msg} — expected to find ${JSON.stringify(needle)}`); }
15
+ function group(name) { console.log("\n" + name); }
16
+
17
+ const errors = [];
18
+ const vc = new VirtualConsole();
19
+ vc.on("jsdomError", (e) => errors.push(e.message));
20
+
21
+ const dom = new JSDOM(html, {
22
+ runScripts: "dangerously",
23
+ virtualConsole: vc,
24
+ url: "https://example.org/",
25
+ beforeParse(window) { window.scrollTo = () => {}; window.alert = () => {}; },
26
+ });
27
+ const w = dom.window;
28
+ const d = w.document;
29
+ const ev = (expr) => w.eval(expr);
30
+
31
+ /* Force the dice: d20() is 1 + floor(random*20), so (n-0.5)/20 yields n. */
32
+ function seed(values) {
33
+ let i = 0;
34
+ w.Math.random = () => { const v = values[Math.min(i++, values.length - 1)]; return (v - 0.5) / 20; };
35
+ }
36
+ function unseed() { w.Math.random = Math.random; }
37
+
38
+ /* ============================================================ */
39
+ group("Boot");
40
+ ok(errors.length === 0, "no script errors while loading" + (errors.length ? ": " + errors[0] : ""));
41
+ ok(d.querySelectorAll("nav.tabs button").length === ev("CHAPTERS.length"), "one tab per chapter");
42
+ ok(!/<script[^>]+src=/.test(html), "no external scripts — the file is self-contained");
43
+ ok(!/<link[^>]+stylesheet/.test(html), "no external stylesheets");
44
+
45
+ /* ============================================================ */
46
+ group("Degrees of success — the one mechanic");
47
+ eq(ev("degreeOf(25, 15, 10)"), 3, "beat the DC by 10 is a critical success");
48
+ eq(ev("degreeOf(24, 15, 10)"), 2, "beat it by 9 is a plain success");
49
+ eq(ev("degreeOf(15, 15, 10)"), 2, "meeting the DC succeeds");
50
+ eq(ev("degreeOf(14, 15, 10)"), 1, "one under is a failure");
51
+ eq(ev("degreeOf(5, 15, 10)"), 0, "ten under is a critical failure");
52
+ eq(ev("degreeOf(14, 15, 20)"), 2, "a natural 20 lifts a failure to a success, not a critical");
53
+ eq(ev("degreeOf(25, 15, 20)"), 3, "a natural 20 on a critical success stays a critical success");
54
+ eq(ev("degreeOf(16, 15, 1)"), 1, "a natural 1 drops a success to a failure");
55
+ eq(ev("degreeOf(5, 15, 1)"), 0, "a natural 1 on a critical failure stays there");
56
+ has(ev("degreeReason(25, 15, 10)"), "ten or more over", "the explanation names the ten-over rule");
57
+ has(ev("degreeReason(14, 15, 20)"), "natural 20 moves it one step better", "the explanation names the natural-20 nudge");
58
+
59
+ group("Multiple attack penalty");
60
+ eq(ev("mapPenalty(0)"), 0, "first attack is at full value");
61
+ eq(ev("mapPenalty(1)"), -5, "second attack is −5");
62
+ eq(ev("mapPenalty(2)"), -10, "third attack is −10");
63
+ eq(ev("mapPenalty(3)"), -10, "a fourth attack is still −10");
64
+ eq(ev("mapPenalty(1, true)"), -4, "agile second attack is −4");
65
+ eq(ev("mapPenalty(2, true)"), -8, "agile third attack is −8");
66
+
67
+ /* ============================================================
68
+ Every number on the pregens, re-derived from the build.
69
+ modifier = ability + rank bonus + level, and untrained adds nothing.
70
+ ============================================================ */
71
+ group("The pregens' arithmetic");
72
+ const pregens = ev("PREGENS");
73
+ const RANK = ev("RANK_BONUS");
74
+ const SKAB = ev("SKILL_ABILITY");
75
+ for (const pc of pregens) {
76
+ eq(pc.hp, pc.hpParts.ancestry + pc.hpParts.cls + pc.hpParts.con * pc.level,
77
+ `${pc.short}: hit points are ancestry + class + Constitution`);
78
+ const dexToAC = Math.min(pc.abilities.dex, pc.armor.dexCap);
79
+ eq(pc.ac, 10 + dexToAC + pc.armor.item + RANK[pc.armor.rank] + pc.level,
80
+ `${pc.short}: AC is 10 + Dex (capped) + armour + proficiency`);
81
+ eq(pc.perception, pc.abilities.wis + RANK[pc.perceptionRank] + pc.level,
82
+ `${pc.short}: Perception is Wis + rank + level`);
83
+ for (const [save, mod] of Object.entries(pc.saves)) {
84
+ const ability = save === "fortitude" ? "con" : save === "reflex" ? "dex" : "wis";
85
+ eq(mod, pc.abilities[ability] + RANK[pc.saveRanks[save]] + pc.level,
86
+ `${pc.short}: ${save} save is ${ability.toUpperCase()} + rank + level`);
87
+ }
88
+ for (const [skill, mod] of Object.entries(pc.skills)) {
89
+ ok(SKAB[skill], `${pc.short}: ${skill} has a key ability on record`);
90
+ eq(mod, pc.abilities[SKAB[skill]] + RANK.trained + pc.level,
91
+ `${pc.short}: ${skill} is ${SKAB[skill].toUpperCase()} + trained + level`);
92
+ }
93
+ if (pc.spellDC) {
94
+ eq(pc.spellDC, 10 + pc.abilities.cha + RANK.trained + pc.level, `${pc.short}: spell DC is 10 + the modifier`);
95
+ eq(pc.spellAttack, pc.abilities.cha + RANK.trained + pc.level, `${pc.short}: spell attack is Cha + rank + level`);
96
+ eq(pc.spellDC, pc.spellAttack + 10, `${pc.short}: any modifier becomes a DC by adding 10`);
97
+ }
98
+ }
99
+ eq(ev("socialMod(PC_BY_ID.mari, SOCIAL_MOVES[0])"), 0,
100
+ "an untrained skill is +0 — not even the character's level");
101
+
102
+ /* ============================================================
103
+ Every rules slug the content cites must exist in the bundle.
104
+ ============================================================ */
105
+ group("Rules citations resolve");
106
+ const missing = ev(`(function(){
107
+ const bad = [];
108
+ const act = (s) => { if (!ACTION_BY_SLUG[s]) bad.push("action:" + s); };
109
+ const con = (s) => { if (!CONDITION_BY_SLUG[s]) bad.push("condition:" + s); };
110
+ CHAPTERS.forEach((c) => c.sections.forEach((s) => {
111
+ if (s.type === "ref") (s.slugs || []).forEach(s.kind === "condition" ? con : act);
112
+ }));
113
+ Object.values(TURN_ACTIONS).forEach((list) => list.forEach((a) => { if (a.slug) act(a.slug); }));
114
+ EXPLORATION_ACTIVITIES.forEach((a) => act(a.slug));
115
+ EXPLORATION_STOPS.forEach((a) => act(a.slug));
116
+ DOWNTIME_ACTIVITIES.forEach((a) => act(a.slug));
117
+ SOCIAL_MOVES.forEach((m) => act(m.slug));
118
+ REACTIONS.forEach((r) => act(r.slug));
119
+ ATTITUDES.forEach(con);
120
+ return bad;
121
+ })()`);
122
+ ok(missing.length === 0, `every cited action and condition is in the data${missing.length ? ": missing " + missing.join(", ") : ""}`);
123
+ ok(ev("CONDITIONS.length") >= 40, `the bundle carries the conditions (${ev("CONDITIONS.length")})`);
124
+ ok(ev("ACTIONS.length") >= 90, `the bundle carries the actions (${ev("ACTIONS.length")})`);
125
+
126
+ /* ============================================================ */
127
+ group("Every chapter renders, every demo mounts");
128
+ for (const ch of ev("CHAPTERS")) {
129
+ ev(`go(${JSON.stringify(ch.key)})`);
130
+ const view = d.getElementById("view-" + ch.key);
131
+ ok(view && !view.classList.contains("hide"), `${ch.key}: the tab shows`);
132
+ has(view.innerHTML, ch.title, `${ch.key}: the chapter title is on the page`);
133
+ ok(view.textContent.length > 900, `${ch.key}: has real content (${view.textContent.length} chars)`);
134
+ for (const s of ch.sections) {
135
+ if (s.type === "demo") {
136
+ const host = d.getElementById("demo-" + s.id);
137
+ ok(host && host.innerHTML.length > 120, `${ch.key}: the ${s.id} demo mounted`);
138
+ }
139
+ }
140
+ }
141
+ ok(d.querySelectorAll(".gmsec").length > 0, "GM notes are in the DOM");
142
+ ev("setGmMode(true)");
143
+ ok(d.body.classList.contains("gmon"), "GM mode marks the body so the notes show");
144
+ ev("setGmMode(false)");
145
+ ok(!d.body.classList.contains("gmon"), "player mode hides them again");
146
+
147
+ /* ============================================================ */
148
+ group("The check demo");
149
+ ev("go('start'); checkSet('who','mari'); checkSet('what','sk-athletics'); checkSet('dc',15)");
150
+ seed([20]);
151
+ ev("rollTheCheck(1)");
152
+ let out = d.getElementById("checkOut").textContent;
153
+ has(out, "Critical success", "Athletics +7 with a natural 20 against DC 15 is a critical success");
154
+ has(out, "27", "the total is shown");
155
+ seed([1]);
156
+ ev("rollTheCheck(1)");
157
+ out = d.getElementById("checkOut").textContent;
158
+ has(out, "Critical failure", "a natural 1 on 8 against DC 15 drops a failure to a critical failure");
159
+ unseed();
160
+ ev("rollTheCheck(20)");
161
+ has(d.getElementById("checkOut").textContent, "Twenty rolls", "the twenty-roll distribution renders");
162
+
163
+ /* ============================================================ */
164
+ group("The turn demo — penalties and debuffs move the numbers");
165
+ ev("go('fight'); turnSet('mari'); turnClear()");
166
+ ev("turnPick(4); turnPick(0); turnPick(0)"); /* Demoralize, Strike, Strike */
167
+ eq(ev("turnState.picks.length"), 3, "three single actions fill the turn");
168
+ eq(ev("turnSpent()"), 3, "and the turn is full");
169
+ seed([20]);
170
+ ev("runTurn()");
171
+ let turn = d.getElementById("turnOut").textContent;
172
+ has(turn, "frightened 2", "a critical Demoralize leaves it frightened 2");
173
+ has(turn, "vs AC 14", "frightened 2 drops the AC the next Strike rolls against from 16 to 14");
174
+ has(turn, "-5 multiple attack penalty", "the second attack shows its penalty");
175
+ has(turn, "doubled for the critical hit", "beating AC by 10 doubles the damage");
176
+ ev("turnClear()");
177
+ ok(ev("turnState.picks.length") === 0, "clearing empties the turn");
178
+ /* A two-action spell can't be followed by two more actions. */
179
+ ev("turnSet('sable'); turnPick(1); turnPick(1)");
180
+ eq(ev("turnSpent()"), 2, "a second two-action spell won't fit in the last action");
181
+ ev("turnClear(); turnPick(2)"); /* Cast Fear */
182
+ seed([1]);
183
+ ev("runTurn()");
184
+ turn = d.getElementById("turnOut").textContent;
185
+ has(turn, "Frightened 3", "a critically failed Will save against Fear is frightened 3 and fleeing");
186
+ has(turn, "spell DC 17", "the save is against the caster's spell DC, not an attack roll");
187
+ unseed();
188
+
189
+ /* ============================================================ */
190
+ group("The dying demo");
191
+ ev("go('fight'); dyingReset(); dyingHit(12)");
192
+ eq(ev("dyingState.hp"), 8, "12 damage off 20 hit points leaves 8");
193
+ eq(ev("dyingState.dying"), 0, "still up");
194
+ ev("dyingHit(25, true)");
195
+ eq(ev("dyingState.hp"), 0, "the critical hit drops her");
196
+ eq(ev("dyingState.dying"), 2, "dropping to 0 from a critical hit starts you at dying 2");
197
+ seed([20]);
198
+ ev("dyingRecover()");
199
+ eq(ev("dyingState.dying"), 0, "a critically successful recovery check clears dying");
200
+ eq(ev("dyingState.wounded"), 1, "and losing dying always leaves you wounded 1");
201
+ ev("dyingHit(25, true)");
202
+ eq(ev("dyingState.dying"), 3, "dropping again adds the wounded value: 2 + 1 = dying 3");
203
+ seed([1]);
204
+ ev("dyingRecover()");
205
+ eq(ev("dyingState.dying"), 4, "a critically failed recovery check adds 2 — that's dead");
206
+ ev("dyingReset(); dyingHit(25, true); dyingHero()");
207
+ eq(ev("dyingState.dying"), 0, "spending every hero point clears dying");
208
+ eq(ev("dyingState.hero"), 0, "and costs all of them");
209
+ unseed();
210
+
211
+ /* ============================================================ */
212
+ group("The attitude demo");
213
+ ev("go('talk'); socialSet('sable'); socialReset()");
214
+ eq(ev("socialState.attitude"), 2, "the NPC starts indifferent");
215
+ seed([20]);
216
+ ev("socialDo('impression')");
217
+ eq(ev("socialState.attitude"), 4, "a critical Make an Impression moves two steps: indifferent → helpful");
218
+ seed([1]);
219
+ ev("socialDo('coerce')");
220
+ eq(ev("socialState.attitude"), 0, "a critically failed Coerce makes them hostile");
221
+ eq(ev("socialState.immune"), true, "and immune to further threats");
222
+ ev("socialReset()");
223
+ seed([11]);
224
+ ev("socialDo('impression')"); /* 11 + 7 = 18 vs DC 15 */
225
+ eq(ev("socialState.attitude"), 3, "a plain success moves one step: indifferent → friendly");
226
+ seed([2]);
227
+ ev("socialDo('impression')"); /* 2 + 7 = 9 vs DC 15: fail, not crit */
228
+ eq(ev("socialState.attitude"), 3, "a plain failure moves nobody");
229
+ unseed();
230
+
231
+ /* ============================================================ */
232
+ group("The exploration and downtime boards");
233
+ ev("go('explore'); expSet('mari','search'); expSet('sable','avoid-notice')");
234
+ let exp = d.getElementById("demo-explore").textContent;
235
+ has(exp, "the GM rolls this", "secret checks are flagged as the GM's to roll");
236
+ has(exp, "+6", "Search shows Mari's Perception modifier");
237
+ ev("expSet('mari','scout'); expSet('sable','scout')");
238
+ has(d.getElementById("demo-explore").textContent, "Nobody is Searching",
239
+ "with nobody Searching, the board says so plainly");
240
+ ev("expMove(0, 1)");
241
+ eq(ev("exploreState.order[0]"), "sable", "marching order can be reordered");
242
+ ev("go('ashore'); downSet('mari','earn-income'); downSet('days', 7)");
243
+ has(d.getElementById("demo-downtime").textContent, "task level",
244
+ "Earn Income tells you what the GM still has to decide");
245
+
246
+ /* ============================================================ */
247
+ group("The rules browser");
248
+ ev("go('cards')");
249
+ d.getElementById("browseQ").value = "frightened";
250
+ ev("renderBrowseList()");
251
+ has(d.getElementById("browseList").textContent, "Frightened", "search finds a condition");
252
+ has(d.getElementById("browseList").textContent, "status penalty", "and shows Paizo's own text");
253
+ ev("setBrowseFilter('downtime')");
254
+ d.getElementById("browseQ").value = "";
255
+ ev("renderBrowseList()");
256
+ has(d.getElementById("browseList").textContent, "Earn Income", "the downtime filter finds downtime activities");
257
+ ev("setBrowseFilter('all')");
258
+
259
+ /* ============================================================ */
260
+ group("Damage rolls");
261
+ for (let i = 0; i < 200; i++) {
262
+ const t = ev('rollDamage("1d8+4").total');
263
+ if (t < 5 || t > 12) { ok(false, "1d8+4 stays in range (got " + t + ")"); break; }
264
+ if (i === 199) ok(true, "1d8+4 always lands between 5 and 12");
265
+ }
266
+ eq(ev('rollDamage("2d6").total >= 2 && rollDamage("2d6").total <= 12'), true, "2d6 stays in range");
267
+
268
+ /* ============================================================ */
269
+ group("Progress and settings survive a reload");
270
+ ev("markRead('start'); markRead('fight')");
271
+ const saved = JSON.parse(w.localStorage.getItem("pf2ePrimer.v1"));
272
+ ok(saved.done.start && saved.done.fight, "read tabs are remembered");
273
+ ev("resetProgress()");
274
+ eq(Object.keys(JSON.parse(w.localStorage.getItem("pf2ePrimer.v1")).done).length, 0, "and can be cleared");
275
+
276
+ console.log(`\n${pass} passed, ${fail} failed`);
277
+ process.exit(fail ? 1 : 0);