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/tools/test.mjs ADDED
@@ -0,0 +1,370 @@
1
+ /* tools/test.mjs — loads the built dist/index.html in jsdom and drives the real
2
+ UI. These assert the rules ARITHMETIC, not that something rendered: the
3
+ degree-of-success scales, the discard at an obstacle boundary, awareness
4
+ ticking at end of round, research caps, and that undo restores exactly.
5
+
6
+ Run: node tools/test.mjs (after python3 tools/build.py) */
7
+ import { JSDOM, VirtualConsole } from "jsdom";
8
+ import fs from "node:fs";
9
+
10
+ const html = fs.readFileSync(new URL("../dist/index.html", import.meta.url), "utf8");
11
+
12
+ let pass = 0, fail = 0;
13
+ function ok(cond, msg) { (cond ? pass++ : fail++); console.log((cond ? " ✓ " : " ✗ FAIL ") + msg); }
14
+ function eq(a, b, msg) { ok(JSON.stringify(a) === JSON.stringify(b), `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`); }
15
+ function has(hay, needle, msg) { ok(String(hay).indexOf(needle) !== -1, `${msg} — expected to find ${JSON.stringify(needle)}`); }
16
+ function group(name) { console.log("\n" + name); }
17
+
18
+ const errors = [];
19
+ const vc = new VirtualConsole();
20
+ vc.on("jsdomError", (e) => errors.push(e.message));
21
+
22
+ const dom = new JSDOM(html, {
23
+ runScripts: "dangerously",
24
+ virtualConsole: vc,
25
+ url: "https://example.org/",
26
+ beforeParse(window) {
27
+ window.scrollTo = () => {};
28
+ window.alert = () => {};
29
+ window.confirm = () => true;
30
+ window.prompt = () => null;
31
+ },
32
+ });
33
+ const w = dom.window;
34
+ const d = w.document;
35
+ const ev = (expr) => w.eval(expr);
36
+
37
+ /* Build an encounter straight into the library and return its id. */
38
+ function mk(subKey, opts) {
39
+ return ev(`(function(){
40
+ const e = newEncounter(${JSON.stringify(subKey)}, ${JSON.stringify(opts || {})});
41
+ library.encounters.unshift(e); library.activeId = e.id; save(); return e.id;
42
+ })()`);
43
+ }
44
+ const enc = (id) => ev(`library.encounters.find(e => e.id === ${JSON.stringify(id)})`);
45
+ const pools = (id) => enc(id).run.pools;
46
+ const run = (id) => enc(id).run;
47
+ function scoreAs(id, actor, degree, opts) {
48
+ ev(`score(${JSON.stringify(id)}, ${JSON.stringify(actor)}, ${JSON.stringify(degree)}, ${JSON.stringify(opts || {})})`);
49
+ }
50
+
51
+ /* ============================================================ */
52
+ group("Boot");
53
+ ok(errors.length === 0, "no script errors while loading" + (errors.length ? ": " + errors[0] : ""));
54
+ ok(!/<script[^>]+src=/.test(html), "no external scripts — the file is self-contained");
55
+ ok(!/<link[^>]+stylesheet/.test(html), "no external stylesheets");
56
+ eq(d.querySelectorAll("nav.tabs button").length, 3, "three tabs");
57
+ eq(ev("SUBSYSTEM_ORDER.length"), 5, "five subsystems in the picker");
58
+
59
+ /* ============================================================ */
60
+ group("Chase — the group's points, and the ones that don't carry");
61
+ {
62
+ const id = mk("chase", { preset: "medium", partySize: 4 });
63
+ eq(enc(id).def.obstacles.length, 8, "a medium chase is 8 obstacles");
64
+ eq(ev("SUBSYSTEMS.chase.presets.map(p => p.obstacles)"), [6, 8, 10], "short/medium/long are 6/8/10");
65
+
66
+ /* GM Core's rule of thumb, straight off the declared rule. */
67
+ eq(ev("suggestedCost(SUBSYSTEMS.chase.obstacleCost, 4, 0)"), 3, "a party of 4: half the obstacles need 3 CP");
68
+ eq(ev("suggestedCost(SUBSYSTEMS.chase.obstacleCost, 4, 1)"), 2, "…and half need 2 CP");
69
+ eq(ev("suggestedCost(SUBSYSTEMS.chase.obstacleCost, 2, 1)"), 1, "a party of 2 floors at 1 CP, never 0");
70
+
71
+ ev(`library.encounters.find(e=>e.id===${JSON.stringify(id)}).def.obstacles[0].cost = 5`);
72
+ scoreAs(id, "Mari", "success");
73
+ eq(pools(id).cp, 1, "a success is +1 Chase Point");
74
+ scoreAs(id, "Sable", "critSuccess");
75
+ eq(pools(id).cp, 3, "a critical success is +2");
76
+ scoreAs(id, "Mari", "failure");
77
+ eq(pools(id).cp, 3, "a plain failure costs nothing");
78
+ scoreAs(id, "Sable", "critFailure");
79
+ eq(pools(id).cp, 2, "a critical failure is −1");
80
+
81
+ ev(`extra(${JSON.stringify(id)}, "cantAct")`);
82
+ eq(pools(id).cp, 1, "a PC who couldn't act costs the group 1 CP");
83
+
84
+ /* Floor at zero. */
85
+ scoreAs(id, "Mari", "critFailure");
86
+ scoreAs(id, "Mari", "critFailure");
87
+ eq(pools(id).cp, 0, "the group's total never falls below 0");
88
+ }
89
+ {
90
+ const id = mk("chase", { preset: "short", partySize: 4 });
91
+ ev(`library.encounters.find(e=>e.id===${JSON.stringify(id)}).def.obstacles[0].cost = 3`);
92
+ scoreAs(id, "Mari", "success"); // 1
93
+ eq(pools(id).cp, 1, "one point banked against a 3-point obstacle");
94
+ scoreAs(id, "Sable", "critSuccess"); // 1 + 2 = 3 -> clears
95
+ eq(run(id).obstacleIndex, 1, "reaching the requirement advances to the next obstacle");
96
+ eq(pools(id).cp, 0, "and the excess does NOT carry over");
97
+ }
98
+
99
+ /* ============================================================ */
100
+ group("Infiltration — two pools, and the one that moves on its own");
101
+ {
102
+ const id = mk("infiltration", { partySize: 4 });
103
+ eq(ev("SUBSYSTEMS.infiltration.scale.failure"), { ap: 1 }, "a failure is 0 IP and 1 Awareness Point");
104
+ eq(ev("SUBSYSTEMS.infiltration.scale.critFailure"), { ap: 2 }, "a critical failure is 2 Awareness Points");
105
+
106
+ scoreAs(id, "Mari", "failure");
107
+ eq(pools(id).ip, 0, "a failure earns no Infiltration Points");
108
+ eq(pools(id).ap, 1, "…and hands the opposition one");
109
+ scoreAs(id, "Sable", "critFailure");
110
+ eq(pools(id).ap, 3, "a critical failure hands over two more");
111
+
112
+ const before = pools(id).ap;
113
+ ev(`advanceRound(${JSON.stringify(id)})`);
114
+ eq(pools(id).ap, before + 1, "every round that passes adds 1 Awareness Point");
115
+ eq(run(id).round, 2, "and the round counter moves");
116
+
117
+ /* Failure threshold = twice the IP the job needs; complications at quarters. */
118
+ const lad = ev(`awarenessLadder(library.encounters.find(e=>e.id===${JSON.stringify(id)}))`);
119
+ eq(ev(`ipGoal(library.encounters.find(e=>e.id===${JSON.stringify(id)}))`), 4, "the default job needs 4 IP");
120
+ eq(lad.fail, 8, "so it fails at 8 AP — twice the goal");
121
+ eq(lad.marks, [2, 4, 6], "with a complication at each quarter");
122
+ }
123
+ {
124
+ /* Edge Points turn a failure into a success, and cost the opposition nothing. */
125
+ const id = mk("infiltration", {});
126
+ ev(`library.encounters.find(e=>e.id===${JSON.stringify(id)}).run.pools.ep = 1`);
127
+ scoreAs(id, "Mari", "failure", { edge: true });
128
+ eq(pools(id).ip, 1, "spending an Edge Point scores the success line — 1 IP");
129
+ eq(pools(id).ap, 0, "and raises no suspicion at all");
130
+ eq(pools(id).ep, 0, "the Edge Point is spent");
131
+ scoreAs(id, "Mari", "failure", { edge: true });
132
+ eq(pools(id).ip, 1, "with none left, an Edge spend does nothing");
133
+ }
134
+ {
135
+ /* Individual obstacles are earned per PC; group ones pool. */
136
+ const id = mk("infiltration", {});
137
+ ev(`(function(){ const e = library.encounters.find(x=>x.id===${JSON.stringify(id)});
138
+ e.def.participants = ["Mari","Sable"];
139
+ e.def.obstacles = [ { id:"o1", name:"Guard post", note:"", cost:1, mode:"individual", options:[] } ];
140
+ e.run = freshRun(e); save(); })()`);
141
+ scoreAs(id, "Mari", "success");
142
+ eq(run(id).obstacleIndex, 0, "one PC past an individual obstacle is not the party past it");
143
+ scoreAs(id, "Sable", "success");
144
+ eq(run(id).obstacleIndex, 1, "an individual obstacle clears only when every PC has earned it");
145
+ }
146
+
147
+ /* ============================================================ */
148
+ group("Influence — points, and what the party has actually worked out");
149
+ {
150
+ const id = mk("influence", { npcName: "Quartermaster Bel" });
151
+ eq(ev("SUBSYSTEMS.influence.scale.critSuccess"), { ip: 2 }, "a critical success is 2 Influence Points");
152
+ eq(ev("SUBSYSTEMS.influence.scale.critFailure"), { ip: -1 }, "a critical failure costs one");
153
+
154
+ scoreAs(id, "Mari", "critSuccess");
155
+ eq(pools(id).ip, 2, "two banked");
156
+ scoreAs(id, "Mari", "critFailure");
157
+ eq(pools(id).ip, 1, "and one lost again");
158
+
159
+ const r = ev("SUBSYSTEMS.influence.actions.find(a => a.key === 'discover').reveals");
160
+ eq(r.success, 1, "a successful Discover learns one thing");
161
+ eq(r.critSuccess, 2, "a critical Discover learns two");
162
+ eq(r.critFailure, -1, "a critical failure learns something false");
163
+
164
+ /* Resistances raise the DC of the approach they name; weaknesses lower it. */
165
+ eq(ev("adjustedDC(18, [{label:'Flattery', dc:3}])"), 21, "a resistance raises the DC by its value");
166
+ eq(ev("adjustedDC(18, [{label:'Honesty', dc:-3}])"), 15, "a weakness lowers it");
167
+
168
+ ev(`revealFact(${JSON.stringify(id)}, "resistance", 0)`);
169
+ ev(`revealFact(${JSON.stringify(id)}, "resistance", 0)`);
170
+ eq(run(id).revealed.length, 1, "revealing the same fact twice reveals it once");
171
+ }
172
+ {
173
+ /* Thresholds fire once, in order. */
174
+ const id = mk("influence", {});
175
+ ev(`(function(){ const e = library.encounters.find(x=>x.id===${JSON.stringify(id)});
176
+ e.def.thresholds = [{at:2,label:"Warms up",effect:""},{at:5,label:"Helps",effect:""}]; save(); })()`);
177
+ scoreAs(id, "Mari", "critSuccess"); // 2
178
+ eq(run(id).fired.length, 1, "reaching the first threshold fires it");
179
+ scoreAs(id, "Mari", "success"); // 3
180
+ eq(run(id).fired.length, 1, "staying above it does not fire it again");
181
+ scoreAs(id, "Mari", "critSuccess"); // 5
182
+ eq(run(id).fired.length, 2, "reaching the second fires the second");
183
+ }
184
+
185
+ /* ============================================================ */
186
+ group("Research — sources that run dry");
187
+ {
188
+ const id = mk("research", {});
189
+ const cid = enc(id).def.checks[0].id;
190
+ ev(`(function(){ const e = library.encounters.find(x=>x.id===${JSON.stringify(id)});
191
+ e.def.checks[0].cap = 2; save(); })()`);
192
+ scoreAs(id, "Mari", "critSuccess", { checkId: cid });
193
+ eq(pools(id).rp, 2, "a critical success is 2 Research Points");
194
+ eq(run(id).checks[cid], 2, "all of it from that source");
195
+ scoreAs(id, "Mari", "critSuccess", { checkId: cid });
196
+ eq(pools(id).rp, 2, "past its maximum the source gives nothing more");
197
+ eq(run(id).checks[cid], 2, "and its tally stops there");
198
+
199
+ ev(`(function(){ const e = library.encounters.find(x=>x.id===${JSON.stringify(id)});
200
+ e.def.checks.push({id:"c2", name:"Another", skill:"Occultism", dc:20, cap:5}); save(); })()`);
201
+ scoreAs(id, "Sable", "success", { checkId: "c2" });
202
+ eq(pools(id).rp, 3, "a fresh source still pays out");
203
+ }
204
+
205
+ /* ============================================================ */
206
+ group("Victory Points — the framework underneath");
207
+ {
208
+ const scales = ev("GENERATED_VP_SCALES");
209
+ ok(scales.length >= 5, `the VP Scales table came through (${scales.length} rows)`);
210
+ eq(scales.find((s) => /Long encounter/.test(s.duration)).thresholds, [4], "a long encounter has one threshold, at 4");
211
+ eq(scales.find((s) => /Most of a session/.test(s.duration)).thresholds, [5, 10, 15], "most of a session has 5/10/15");
212
+
213
+ const acc = mk("custom", { mode: "accumulating" });
214
+ scoreAs(acc, "Party", "critSuccess");
215
+ eq(pools(acc).vp, 2, "accumulating: a critical success is +2");
216
+ scoreAs(acc, "Party", "failure");
217
+ eq(pools(acc).vp, 2, "accumulating: a plain failure costs nothing");
218
+
219
+ const dim = mk("custom", { mode: "diminishing" });
220
+ ev(`(function(){ const e = library.encounters.find(x=>x.id===${JSON.stringify(dim)});
221
+ e.def.goal = 5; e.run = freshRun(e); save(); })()`);
222
+ eq(pools(dim).vp, 5, "diminishing: you start at the end point");
223
+ scoreAs(dim, "Party", "success");
224
+ eq(pools(dim).vp, 5, "diminishing: a success loses nothing");
225
+ scoreAs(dim, "Party", "failure");
226
+ eq(pools(dim).vp, 4, "diminishing: a failure loses one");
227
+ scoreAs(dim, "Party", "critFailure");
228
+ eq(pools(dim).vp, 2, "diminishing: a critical failure loses two");
229
+ scoreAs(dim, "Party", "critSuccess");
230
+ eq(pools(dim).vp, 3, "diminishing: a critical success regains one");
231
+ }
232
+
233
+ /* ============================================================ */
234
+ group("Thresholds count the right thing");
235
+ {
236
+ /* In a chase the points reset at every obstacle, so a threshold counted in
237
+ points would fire once and then sit stranded at zero. */
238
+ eq(ev("SUBSYSTEMS.chase.thresholdBasis"), "progress", "a chase counts thresholds in obstacles cleared");
239
+ eq(ev("SUBSYSTEMS.infiltration.thresholdBasis"), "progress", "so does an infiltration");
240
+ ok(ev("SUBSYSTEMS.influence.thresholdBasis === undefined"), "an influence encounter counts its points");
241
+ ok(ev("SUBSYSTEMS.research.thresholdBasis === undefined"), "and so does research");
242
+
243
+ const id = mk("chase", { preset: "short", partySize: 4 });
244
+ ev(`(function(){ const e = library.encounters.find(x=>x.id===${JSON.stringify(id)});
245
+ e.def.obstacles.forEach(o => { o.cost = 1; });
246
+ e.def.thresholds = [{at:2,label:"They gain ground",effect:""}];
247
+ e.run = freshRun(e); save(); })()`);
248
+ eq(ev(`thresholdValue(library.encounters.find(e=>e.id===${JSON.stringify(id)}))`), 0,
249
+ "nothing cleared yet");
250
+ scoreAs(id, "Mari", "success"); // clears obstacle 1
251
+ eq(run(id).fired.length, 0, "one obstacle down is not yet the threshold");
252
+ scoreAs(id, "Sable", "success"); // clears obstacle 2
253
+ eq([run(id).obstacleIndex, run(id).fired.length], [2, 1],
254
+ "the second obstacle cleared fires it — even though the pool is back at 0");
255
+ eq(pools(id).cp, 0, "and the pool is indeed back at 0");
256
+ has(ev(`thresholdUnit(library.encounters.find(e=>e.id===${JSON.stringify(id)}))`),
257
+ "obstacles cleared", "the board says what it is counting");
258
+ }
259
+
260
+ /* ============================================================ */
261
+ group("Undo — including across an obstacle boundary");
262
+ {
263
+ const id = mk("chase", { preset: "short", partySize: 4 });
264
+ ev(`library.encounters.find(e=>e.id===${JSON.stringify(id)}).def.obstacles[0].cost = 3`);
265
+ scoreAs(id, "Mari", "success"); // cp 1
266
+ scoreAs(id, "Sable", "critSuccess"); // cp 3 -> clears, cp 0, index 1
267
+ eq([pools(id).cp, run(id).obstacleIndex], [0, 1], "the obstacle cleared and the pool reset");
268
+ ev("undoLast()");
269
+ eq(pools(id).cp, 1, "undo puts the discarded points back");
270
+ eq(run(id).obstacleIndex, 0, "and steps back to the obstacle they were on");
271
+ ev("undoLast()");
272
+ eq(pools(id).cp, 0, "undoing again unwinds the first success too");
273
+ const len = run(id).log.length;
274
+ ev("undoLast()");
275
+ eq(run(id).log.length, len, "undo on an empty log is a no-op, not a crash");
276
+ }
277
+ {
278
+ /* Awareness is on the same undo stack as everything else. */
279
+ const id = mk("infiltration", {});
280
+ scoreAs(id, "Mari", "critFailure");
281
+ eq(pools(id).ap, 2, "two Awareness Points");
282
+ ev(`advanceRound(${JSON.stringify(id)})`);
283
+ eq(pools(id).ap, 3, "and a third for the round");
284
+ ev("undoLast()");
285
+ eq([pools(id).ap, run(id).round], [2, 1], "undo takes back the round and its Awareness Point");
286
+ }
287
+
288
+ /* ============================================================ */
289
+ group("Prep survives the round");
290
+ {
291
+ const id = mk("chase", { preset: "medium", partySize: 4 });
292
+ ev(`library.encounters.find(e=>e.id===${JSON.stringify(id)}).def.obstacles[0].name = "Fruit cart"`);
293
+ scoreAs(id, "Mari", "success");
294
+ ev(`resetRun(${JSON.stringify(id)})`);
295
+ eq(pools(id).cp, 0, "reset clears the live pools");
296
+ eq(run(id).log.length, 0, "and the log");
297
+ eq(enc(id).def.obstacles[0].name, "Fruit cart", "but the prep is untouched");
298
+ eq(enc(id).def.obstacles.length, 8, "every obstacle still there");
299
+ }
300
+ {
301
+ /* An export code carries the prep and none of the run. */
302
+ const id = mk("influence", { npcName: "Quartermaster Bel" });
303
+ scoreAs(id, "Mari", "critSuccess");
304
+ const code = ev(`b64encode(JSON.stringify({t:"pf2e-subsystem", v:1,
305
+ subsystem: "influence", name: "Bel",
306
+ def: library.encounters.find(e=>e.id===${JSON.stringify(id)}).def }))`);
307
+ const okImport = ev(`importEncounter(${JSON.stringify(code)})`);
308
+ ok(okImport === true, "a code imports");
309
+ const fresh = ev("library.encounters[0]");
310
+ eq(fresh.def.npc.name, "Quartermaster Bel", "the NPC came across intact");
311
+ eq(fresh.run.pools.ip, 0, "and the import starts at zero — no dice came with it");
312
+ ok(ev(`importEncounter("not-a-code")`) === false, "junk is refused, not thrown");
313
+ }
314
+ {
315
+ /* The whole library round-trips through localStorage. */
316
+ ev("save()");
317
+ const before = ev("JSON.stringify(library)");
318
+ ev("load()");
319
+ eq(ev("JSON.stringify(library)"), before, "the library survives a reload");
320
+ }
321
+
322
+ /* ============================================================ */
323
+ group("Data integrity — the generated halves line up");
324
+ {
325
+ const pages = ev("GENERATED_SUBSYSTEMS.map(p => p.slug)");
326
+ ev("SUBSYSTEM_ORDER").forEach((k) => {
327
+ const slug = ev(`SUBSYSTEMS[${JSON.stringify(k)}].rulesPage`);
328
+ ok(pages.indexOf(slug) !== -1, `${k} points at a rules page that exists (${slug})`);
329
+ });
330
+ ok(ev("GENERATED_SUBSYSTEMS.every(p => p.book && p.pages)"),
331
+ "every page of rules text carries its Paizo citation");
332
+
333
+ const obs = ev("GENERATED_CHASE_OBSTACLES");
334
+ ok(obs.length >= 40, `GM Core's sample obstacles came through (${obs.length})`);
335
+ ok(obs.every((o) => o.name && o.level != null && o.options.length > 0),
336
+ "every obstacle has a name, a level and at least one skill+DC");
337
+ ok(obs.every((o) => o.options.every((x) => x.skill && x.dc > 0)),
338
+ "every option parsed to a real skill and DC");
339
+ eq([...new Set(obs.map((o) => o.table))].sort(), ["Underground", "Urban", "Wilderness"],
340
+ "all three terrain tables");
341
+
342
+ /* The Influence page is nothing but pointers to these two actions, so a
343
+ reference build without actions/subsystems renders it as bare headings. */
344
+ const slugs = ev(`[...new Set(GENERATED_SUBSYSTEMS.flatMap(s =>
345
+ [...s.html.matchAll(/data-action="([^"]+)"/g)].map(m => m[1])))]`);
346
+ const missing = slugs.filter((s) => !ev(`!!ACTIONS_BY_SLUG[${JSON.stringify(s)}]`));
347
+ eq(missing, [], `all ${slugs.length} actions the rules text points at resolve`);
348
+ has(ev("inlineActions('<span class=\"actref\" data-action=\"influence\">Influence</span>')"),
349
+ "Influence Point", "the Influence action's own text is spliced in where it is named");
350
+ }
351
+
352
+ /* ============================================================ */
353
+ group("It renders");
354
+ {
355
+ const id = mk("chase", { preset: "medium", partySize: 4 });
356
+ ev(`openEncounter(${JSON.stringify(id)})`);
357
+ ev("go('run')");
358
+ const v = d.getElementById("views").innerHTML;
359
+ has(v, "Round 1", "the run board shows the round");
360
+ has(v, "Chase Points", "and the pool");
361
+ eq(d.querySelectorAll(".pcrow").length, 4, "one row per PC");
362
+ ev("go('prep')");
363
+ has(d.getElementById("views").innerHTML, "Start something", "prep offers a new encounter");
364
+ ev("go('reference')");
365
+ has(d.getElementById("views").innerHTML, "Pathfinder GM Core", "reference cites the book");
366
+ ok(errors.length === 0, "still no script errors after driving every view");
367
+ }
368
+
369
+ console.log(`\n${pass} passed, ${fail} failed`);
370
+ process.exit(fail ? 1 : 0);