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
package/src/engine.js
ADDED
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
src/engine.js — data-driven runner for the GM Core subsystems.
|
|
3
|
+
|
|
4
|
+
One Victory Point core (apply deltas, clamp, log, fire thresholds, advance
|
|
5
|
+
the round) with three structural layers on top of it: a sequence of
|
|
6
|
+
obstacles, a single open pool, or a set of capped research checks. Which
|
|
7
|
+
layer a subsystem uses, and what its numbers are, is declared in
|
|
8
|
+
content.js — nothing below hard-codes a subsystem's arithmetic.
|
|
9
|
+
============================================================ */
|
|
10
|
+
|
|
11
|
+
const KEY = "pf2eSubsystems.v1";
|
|
12
|
+
const LOG_CAP = 200;
|
|
13
|
+
|
|
14
|
+
let library = null;
|
|
15
|
+
|
|
16
|
+
/* ============================================================
|
|
17
|
+
SMALL HELPERS
|
|
18
|
+
============================================================ */
|
|
19
|
+
const byId = (id) => document.getElementById(id);
|
|
20
|
+
const clone = (o) => JSON.parse(JSON.stringify(o));
|
|
21
|
+
|
|
22
|
+
function esc(s) {
|
|
23
|
+
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => (
|
|
24
|
+
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
25
|
+
}
|
|
26
|
+
function uid() {
|
|
27
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
|
28
|
+
}
|
|
29
|
+
function b64encode(str) { return btoa(unescape(encodeURIComponent(str))); }
|
|
30
|
+
function b64decode(str) { return decodeURIComponent(escape(atob(str))); }
|
|
31
|
+
|
|
32
|
+
function toast(msg) {
|
|
33
|
+
const t = byId("toast");
|
|
34
|
+
t.textContent = msg;
|
|
35
|
+
t.classList.add("show");
|
|
36
|
+
clearTimeout(t._t);
|
|
37
|
+
t._t = setTimeout(() => t.classList.remove("show"), 1800);
|
|
38
|
+
}
|
|
39
|
+
function titleCase(s) {
|
|
40
|
+
return String(s || "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
41
|
+
}
|
|
42
|
+
function plural(n, one, many) { return n === 1 ? one : (many || one + "s"); }
|
|
43
|
+
|
|
44
|
+
/* ============================================================
|
|
45
|
+
PERSISTENCE
|
|
46
|
+
============================================================ */
|
|
47
|
+
function blankLibrary() {
|
|
48
|
+
return { v: 1, settings: { themeMode: "auto", custom: null }, encounters: [], activeId: null };
|
|
49
|
+
}
|
|
50
|
+
function load() {
|
|
51
|
+
try {
|
|
52
|
+
const raw = localStorage.getItem(KEY);
|
|
53
|
+
library = raw ? JSON.parse(raw) : blankLibrary();
|
|
54
|
+
} catch (e) {
|
|
55
|
+
library = blankLibrary();
|
|
56
|
+
}
|
|
57
|
+
if (!library || typeof library !== "object") library = blankLibrary();
|
|
58
|
+
library.settings = library.settings || { themeMode: "auto", custom: null };
|
|
59
|
+
library.encounters = Array.isArray(library.encounters) ? library.encounters : [];
|
|
60
|
+
}
|
|
61
|
+
function save() {
|
|
62
|
+
try {
|
|
63
|
+
localStorage.setItem(KEY, JSON.stringify(library));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
toast("Couldn't save — this browser's storage is full or blocked.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ============================================================
|
|
70
|
+
THEME
|
|
71
|
+
============================================================ */
|
|
72
|
+
function applyTheme() {
|
|
73
|
+
const s = library.settings;
|
|
74
|
+
let mode = s.themeMode || "auto";
|
|
75
|
+
if (mode !== "light" && mode !== "dark") {
|
|
76
|
+
mode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
77
|
+
? "dark" : "light";
|
|
78
|
+
}
|
|
79
|
+
document.documentElement.dataset.theme = mode;
|
|
80
|
+
if (s.custom && s.custom.accent) {
|
|
81
|
+
document.documentElement.style.setProperty("--accent", s.custom.accent);
|
|
82
|
+
} else {
|
|
83
|
+
document.documentElement.style.removeProperty("--accent");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function setThemeMode(m) { library.settings.themeMode = m; save(); applyTheme(); renderMenu(); }
|
|
87
|
+
function setAccent(v) {
|
|
88
|
+
library.settings.custom = library.settings.custom || {};
|
|
89
|
+
library.settings.custom.accent = v;
|
|
90
|
+
save(); applyTheme();
|
|
91
|
+
}
|
|
92
|
+
function resetTheme() { library.settings.custom = null; save(); applyTheme(); renderMenu(); }
|
|
93
|
+
|
|
94
|
+
/* ============================================================
|
|
95
|
+
GENERATED DATA LOOKUPS
|
|
96
|
+
============================================================ */
|
|
97
|
+
const SUB_PAGES = {};
|
|
98
|
+
(GENERATED_SUBSYSTEMS || []).forEach((p) => { SUB_PAGES[p.slug] = p; });
|
|
99
|
+
|
|
100
|
+
const ACTIONS_BY_SLUG = {};
|
|
101
|
+
(typeof GENERATED_ACTIONS !== "undefined" ? GENERATED_ACTIONS : []).forEach((a) => {
|
|
102
|
+
ACTIONS_BY_SLUG[a.slug] = a;
|
|
103
|
+
});
|
|
104
|
+
const CONDITIONS = typeof GENERATED_CONDITIONS !== "undefined" ? GENERATED_CONDITIONS : [];
|
|
105
|
+
const ACTIONS = typeof GENERATED_ACTIONS !== "undefined" ? GENERATED_ACTIONS : [];
|
|
106
|
+
|
|
107
|
+
/* ============================================================
|
|
108
|
+
THE ENCOUNTER MODEL
|
|
109
|
+
|
|
110
|
+
`def` is the prep: what you build before the session and what an export
|
|
111
|
+
code carries. `run` is the live state: pools, round, progress, log.
|
|
112
|
+
Keeping them apart is what lets "Reset run" hand you the same prepped
|
|
113
|
+
chase back, and what keeps a shared code free of last week's dice.
|
|
114
|
+
============================================================ */
|
|
115
|
+
function subOf(enc) { return SUBSYSTEMS[enc.subsystem]; }
|
|
116
|
+
|
|
117
|
+
/* The custom subsystem lets the GM rename its pool, so pools are read from
|
|
118
|
+
the encounter rather than straight off the definition. */
|
|
119
|
+
function poolsOf(enc) {
|
|
120
|
+
const sub = subOf(enc);
|
|
121
|
+
if (sub.renameable && enc.def.pointName) {
|
|
122
|
+
return [Object.assign({}, sub.pools[0], {
|
|
123
|
+
name: enc.def.pointName,
|
|
124
|
+
abbr: enc.def.pointAbbr || sub.pools[0].abbr,
|
|
125
|
+
})];
|
|
126
|
+
}
|
|
127
|
+
return sub.pools;
|
|
128
|
+
}
|
|
129
|
+
function scaleOf(enc) {
|
|
130
|
+
const sub = subOf(enc);
|
|
131
|
+
if (sub.modes) {
|
|
132
|
+
const m = sub.modes.find((x) => x.key === enc.def.mode) || sub.modes[0];
|
|
133
|
+
return m.scale;
|
|
134
|
+
}
|
|
135
|
+
return sub.scale;
|
|
136
|
+
}
|
|
137
|
+
function modeOf(enc) {
|
|
138
|
+
const sub = subOf(enc);
|
|
139
|
+
if (!sub.modes) return null;
|
|
140
|
+
return sub.modes.find((x) => x.key === enc.def.mode) || sub.modes[0];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function newObstacle(name, cost, opts) {
|
|
144
|
+
return Object.assign({
|
|
145
|
+
id: uid(), name: name || "", note: "", cost: cost || 1, mode: "group", options: [],
|
|
146
|
+
}, opts || {});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* GM Core's rule of thumb: half the obstacles need one fewer point than the
|
|
150
|
+
party size, half need two fewer, minimum 1. */
|
|
151
|
+
function suggestedCost(rule, partySize, index) {
|
|
152
|
+
if (!rule) return 1;
|
|
153
|
+
const offset = rule.offsets[index % rule.offsets.length];
|
|
154
|
+
return Math.max(rule.min, partySize - offset);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function newEncounter(subKey, opts) {
|
|
158
|
+
opts = opts || {};
|
|
159
|
+
const sub = SUBSYSTEMS[subKey];
|
|
160
|
+
const partySize = opts.partySize || 4;
|
|
161
|
+
const def = {
|
|
162
|
+
participants: opts.participants
|
|
163
|
+
|| Array.from({ length: partySize }, (_, i) => "PC " + (i + 1)),
|
|
164
|
+
thresholds: [],
|
|
165
|
+
note: "",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (subKey === "chase") {
|
|
169
|
+
const preset = sub.presets.find((p) => p.key === (opts.preset || "medium"));
|
|
170
|
+
def.obstacles = Array.from({ length: preset.obstacles }, (_, i) =>
|
|
171
|
+
newObstacle("Obstacle " + (i + 1),
|
|
172
|
+
suggestedCost(sub.obstacleCost, partySize, i)));
|
|
173
|
+
def.presetKey = preset.key;
|
|
174
|
+
} else if (subKey === "infiltration") {
|
|
175
|
+
def.obstacles = [newObstacle("First obstacle", 2), newObstacle("Second obstacle", 2)];
|
|
176
|
+
def.edgeStart = 0;
|
|
177
|
+
def.apFail = null; // null = follow awarenessRule
|
|
178
|
+
} else if (subKey === "influence") {
|
|
179
|
+
def.npc = {
|
|
180
|
+
name: opts.npcName || "The NPC",
|
|
181
|
+
discoveryDC: 15,
|
|
182
|
+
skills: [{ skill: "Diplomacy", dc: 18 }],
|
|
183
|
+
resistances: [],
|
|
184
|
+
weaknesses: [],
|
|
185
|
+
};
|
|
186
|
+
def.roundMinutes = "30 minutes";
|
|
187
|
+
} else if (subKey === "research") {
|
|
188
|
+
def.checks = [{ id: uid(), name: "First source", skill: "Society", dc: 15, cap: 3 }];
|
|
189
|
+
def.roundLabel = "1 hour";
|
|
190
|
+
} else if (subKey === "custom") {
|
|
191
|
+
const preset = opts.scale || null;
|
|
192
|
+
def.mode = opts.mode || "accumulating";
|
|
193
|
+
def.pointName = opts.pointName || "Victory Points";
|
|
194
|
+
def.pointAbbr = opts.pointAbbr || "VP";
|
|
195
|
+
def.goal = preset ? goalFromScale(preset) : 10;
|
|
196
|
+
if (preset) {
|
|
197
|
+
def.thresholds = preset.thresholds.map((at) => ({ at, label: "", effect: "" }));
|
|
198
|
+
def.scaleLabel = preset.duration;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const enc = {
|
|
203
|
+
id: uid(),
|
|
204
|
+
subsystem: subKey,
|
|
205
|
+
name: opts.name || sub.name,
|
|
206
|
+
createdAt: new Date().toISOString(),
|
|
207
|
+
def,
|
|
208
|
+
run: null,
|
|
209
|
+
};
|
|
210
|
+
enc.run = freshRun(enc);
|
|
211
|
+
return enc;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* "3-5" / "25-50" -> a number to aim at. The book gives a range; take the top
|
|
215
|
+
of it and let the GM change it. */
|
|
216
|
+
function goalFromScale(preset) {
|
|
217
|
+
const nums = String(preset.endPoint).match(/\d+/g) || ["10"];
|
|
218
|
+
return parseInt(nums[nums.length - 1], 10);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function freshRun(enc) {
|
|
222
|
+
const run = { pools: {}, round: 1, obstacleIndex: 0, perPC: {}, checks: {}, revealed: [], fired: [], log: [] };
|
|
223
|
+
poolsOf(enc).forEach((p) => { run.pools[p.key] = 0; });
|
|
224
|
+
const mode = modeOf(enc);
|
|
225
|
+
if (mode && mode.startAtGoal) run.pools[poolsOf(enc)[0].key] = enc.def.goal || 0;
|
|
226
|
+
if (subOf(enc).edge) run.pools.ep = enc.def.edgeStart || 0;
|
|
227
|
+
return run;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function activeEnc() {
|
|
231
|
+
if (!library.activeId) return null;
|
|
232
|
+
return library.encounters.find((e) => e.id === library.activeId) || null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ============================================================
|
|
236
|
+
THE VICTORY POINT CORE
|
|
237
|
+
|
|
238
|
+
Every mutation goes through act(). It snapshots the run first, so undo is
|
|
239
|
+
an exact restore rather than an attempt to re-derive the inverse — which
|
|
240
|
+
matters most at an obstacle boundary, where advancing discards points and
|
|
241
|
+
no arithmetic could put them back.
|
|
242
|
+
============================================================ */
|
|
243
|
+
function act(enc, entry, mutate) {
|
|
244
|
+
const before = clone(enc.run);
|
|
245
|
+
delete before.log;
|
|
246
|
+
mutate();
|
|
247
|
+
enc.run.log.push(Object.assign({
|
|
248
|
+
id: uid(),
|
|
249
|
+
at: new Date().toISOString(),
|
|
250
|
+
round: enc.run.round,
|
|
251
|
+
before,
|
|
252
|
+
}, entry));
|
|
253
|
+
if (enc.run.log.length > LOG_CAP) enc.run.log.splice(0, enc.run.log.length - LOG_CAP);
|
|
254
|
+
save();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function addDeltas(enc, deltas) {
|
|
258
|
+
const pools = poolsOf(enc);
|
|
259
|
+
Object.keys(deltas || {}).forEach((k) => {
|
|
260
|
+
const pool = pools.find((p) => p.key === k);
|
|
261
|
+
const min = pool ? (pool.min != null ? pool.min : 0) : 0;
|
|
262
|
+
const next = (enc.run.pools[k] || 0) + deltas[k];
|
|
263
|
+
enc.run.pools[k] = Math.max(min, next);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* What a threshold is counted against. In a sequence the points reset at every
|
|
268
|
+
obstacle, so progress is the obstacle count; everywhere else it is the pool. */
|
|
269
|
+
function thresholdValue(enc) {
|
|
270
|
+
return subOf(enc).thresholdBasis === "progress"
|
|
271
|
+
? enc.run.obstacleIndex
|
|
272
|
+
: (enc.run.pools[poolsOf(enc)[0].key] || 0);
|
|
273
|
+
}
|
|
274
|
+
function thresholdUnit(enc) {
|
|
275
|
+
return subOf(enc).thresholdBasis === "progress" ? "obstacles cleared" : poolsOf(enc)[0].name;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* Thresholds fire once, in order, the first time the count reaches them. */
|
|
279
|
+
function checkThresholds(enc) {
|
|
280
|
+
const value = thresholdValue(enc);
|
|
281
|
+
const hits = [];
|
|
282
|
+
(enc.def.thresholds || []).slice()
|
|
283
|
+
.sort((a, b) => a.at - b.at)
|
|
284
|
+
.forEach((t, i) => {
|
|
285
|
+
const id = "t" + i + ":" + t.at;
|
|
286
|
+
if (value >= t.at && enc.run.fired.indexOf(id) === -1) {
|
|
287
|
+
enc.run.fired.push(id);
|
|
288
|
+
hits.push(t);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return hits;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* Awareness thresholds are derived, not stored: a complication at each
|
|
295
|
+
quarter of the failure point, which itself defaults to twice the IP goal. */
|
|
296
|
+
function awarenessLadder(enc) {
|
|
297
|
+
const sub = subOf(enc);
|
|
298
|
+
if (!sub.awarenessRule) return null;
|
|
299
|
+
const goal = ipGoal(enc);
|
|
300
|
+
const fail = enc.def.apFail || goal * sub.awarenessRule.multiplier;
|
|
301
|
+
const step = Math.max(1, Math.round(fail / sub.awarenessRule.steps));
|
|
302
|
+
const marks = [];
|
|
303
|
+
for (let v = step; v < fail; v += step) marks.push(v);
|
|
304
|
+
return { fail, step, marks };
|
|
305
|
+
}
|
|
306
|
+
/* A resistance raises the DC of the approach it names; a weakness lowers it.
|
|
307
|
+
Which approach maps to which skill is the table's call, so the app shows the
|
|
308
|
+
arithmetic beside the base DC rather than guessing at it. */
|
|
309
|
+
function adjustedDC(baseDC, mods) {
|
|
310
|
+
return (mods || []).reduce((dc, m) => dc + (m.dc || 0), baseDC);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function ipGoal(enc) {
|
|
314
|
+
return (enc.def.obstacles || []).reduce((sum, o) => sum + (o.cost || 0)
|
|
315
|
+
* (o.mode === "individual" ? (enc.def.participants || []).length : 1), 0);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* ---- the actions a GM takes at the table ---- */
|
|
319
|
+
|
|
320
|
+
function score(encId, actor, degree, opts) {
|
|
321
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
322
|
+
if (!enc) return;
|
|
323
|
+
opts = opts || {};
|
|
324
|
+
const scale = scaleOf(enc);
|
|
325
|
+
let deltas = scale[degree] || {};
|
|
326
|
+
let label = (DEGREES.find((d) => d.key === degree) || {}).label || degree;
|
|
327
|
+
|
|
328
|
+
/* An Edge Point turns a failure into a success: the success line of the
|
|
329
|
+
scale, and nothing to the opposition. */
|
|
330
|
+
if (opts.edge) {
|
|
331
|
+
if ((enc.run.pools.ep || 0) < 1) { toast("No Edge Points left."); return; }
|
|
332
|
+
deltas = Object.assign({}, scale.success, { ep: -1 });
|
|
333
|
+
label = "Edge Point spent — counts as a success";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
act(enc, { kind: "score", actor, degree, label }, () => {
|
|
337
|
+
addDeltas(enc, deltas);
|
|
338
|
+
if (opts.edge) enc.run.pools.ep = Math.max(0, (enc.run.pools.ep || 0) - 1);
|
|
339
|
+
if (enc.def.checks && opts.checkId) bumpCheck(enc, opts.checkId, deltas);
|
|
340
|
+
if (subOf(enc).structure === "sequence") creditObstacle(enc, actor, deltas);
|
|
341
|
+
checkThresholds(enc);
|
|
342
|
+
});
|
|
343
|
+
renderAll();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* Research: a source stops paying out once it has given its maximum. */
|
|
347
|
+
function bumpCheck(enc, checkId, deltas) {
|
|
348
|
+
const check = (enc.def.checks || []).find((c) => c.id === checkId);
|
|
349
|
+
if (!check) return;
|
|
350
|
+
const poolKey = poolsOf(enc)[0].key;
|
|
351
|
+
const gained = deltas[poolKey] || 0;
|
|
352
|
+
const already = enc.run.checks[checkId] || 0;
|
|
353
|
+
if (gained <= 0) { enc.run.checks[checkId] = Math.max(0, already + gained); return; }
|
|
354
|
+
const room = Math.max(0, (check.cap || 0) - already);
|
|
355
|
+
const allowed = Math.min(gained, room);
|
|
356
|
+
/* addDeltas already credited the full amount; take back what the cap
|
|
357
|
+
wouldn't allow. */
|
|
358
|
+
if (allowed < gained) {
|
|
359
|
+
enc.run.pools[poolKey] = Math.max(0, (enc.run.pools[poolKey] || 0) - (gained - allowed));
|
|
360
|
+
}
|
|
361
|
+
enc.run.checks[checkId] = already + allowed;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/* Infiltration's individual obstacles are earned per PC; group ones pool. */
|
|
365
|
+
function creditObstacle(enc, actor, deltas) {
|
|
366
|
+
const ob = currentObstacle(enc);
|
|
367
|
+
if (!ob) return;
|
|
368
|
+
const poolKey = poolsOf(enc)[0].key;
|
|
369
|
+
const gained = deltas[poolKey] || 0;
|
|
370
|
+
if (ob.mode === "individual" && actor) {
|
|
371
|
+
enc.run.perPC[ob.id] = enc.run.perPC[ob.id] || {};
|
|
372
|
+
enc.run.perPC[ob.id][actor] = Math.max(0, (enc.run.perPC[ob.id][actor] || 0) + gained);
|
|
373
|
+
}
|
|
374
|
+
maybeAdvance(enc);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function currentObstacle(enc) {
|
|
378
|
+
const list = enc.def.obstacles || [];
|
|
379
|
+
return list[enc.run.obstacleIndex] || null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function obstacleCleared(enc) {
|
|
383
|
+
const ob = currentObstacle(enc);
|
|
384
|
+
if (!ob) return false;
|
|
385
|
+
if (ob.mode === "individual") {
|
|
386
|
+
const names = (enc.def.participants || []).filter(Boolean);
|
|
387
|
+
const got = enc.run.perPC[ob.id] || {};
|
|
388
|
+
return names.length > 0 && names.every((n) => (got[n] || 0) >= ob.cost);
|
|
389
|
+
}
|
|
390
|
+
return (enc.run.pools[poolsOf(enc)[0].key] || 0) >= ob.cost;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* Clearing an obstacle resets the pool to zero — extra points do NOT carry
|
|
394
|
+
over to the next one. This is the rule hands get wrong most often, and it
|
|
395
|
+
is why undo is snapshot-based. */
|
|
396
|
+
function maybeAdvance(enc) {
|
|
397
|
+
const ob = currentObstacle(enc);
|
|
398
|
+
if (!ob || !obstacleCleared(enc)) return;
|
|
399
|
+
const poolKey = poolsOf(enc)[0].key;
|
|
400
|
+
if (ob.mode !== "individual") enc.run.pools[poolKey] = 0;
|
|
401
|
+
enc.run.obstacleIndex += 1;
|
|
402
|
+
enc.run._justCleared = ob.name || "that obstacle";
|
|
403
|
+
checkThresholds(enc);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function extra(encId, key) {
|
|
407
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
408
|
+
if (!enc) return;
|
|
409
|
+
const ex = (subOf(enc).extras || []).find((x) => x.key === key);
|
|
410
|
+
if (!ex) return;
|
|
411
|
+
act(enc, { kind: "extra", label: ex.label }, () => {
|
|
412
|
+
addDeltas(enc, ex.deltas);
|
|
413
|
+
if (subOf(enc).structure === "sequence") creditObstacle(enc, null, ex.deltas);
|
|
414
|
+
checkThresholds(enc);
|
|
415
|
+
});
|
|
416
|
+
renderAll();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function discover(encId, actor, degree) {
|
|
420
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
421
|
+
if (!enc) return;
|
|
422
|
+
const action = subOf(enc).actions.find((a) => a.key === "discover");
|
|
423
|
+
const n = action.reveals[degree];
|
|
424
|
+
const label = n < 0 ? "Discover — learned something false"
|
|
425
|
+
: n === 0 ? "Discover — learned nothing"
|
|
426
|
+
: "Discover — " + n + " " + plural(n, "fact");
|
|
427
|
+
act(enc, { kind: "discover", actor, degree, label, reveals: n }, () => {
|
|
428
|
+
enc.run.revealHint = n;
|
|
429
|
+
});
|
|
430
|
+
renderAll();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function revealFact(encId, kind, index) {
|
|
434
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
435
|
+
if (!enc) return;
|
|
436
|
+
const id = kind + ":" + index;
|
|
437
|
+
if (enc.run.revealed.indexOf(id) !== -1) return;
|
|
438
|
+
act(enc, { kind: "reveal", label: "Revealed a " + kind }, () => {
|
|
439
|
+
enc.run.revealed.push(id);
|
|
440
|
+
});
|
|
441
|
+
renderAll();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function advanceRound(encId) {
|
|
445
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
446
|
+
if (!enc) return;
|
|
447
|
+
const sub = subOf(enc);
|
|
448
|
+
act(enc, { kind: "round", label: "Round " + (enc.run.round + 1) + " begins" }, () => {
|
|
449
|
+
enc.run.round += 1;
|
|
450
|
+
if (sub.roundEnd) addDeltas(enc, sub.roundEnd);
|
|
451
|
+
checkThresholds(enc);
|
|
452
|
+
});
|
|
453
|
+
renderAll();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function adjustPool(encId, poolKey, delta) {
|
|
457
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
458
|
+
if (!enc) return;
|
|
459
|
+
const d = {}; d[poolKey] = delta;
|
|
460
|
+
act(enc, { kind: "manual", label: (delta > 0 ? "+" : "") + delta + " " + poolKey.toUpperCase() }, () => {
|
|
461
|
+
if (poolKey === "ep") enc.run.pools.ep = Math.max(0, (enc.run.pools.ep || 0) + delta);
|
|
462
|
+
else addDeltas(enc, d);
|
|
463
|
+
if (subOf(enc).structure === "sequence") maybeAdvance(enc);
|
|
464
|
+
checkThresholds(enc);
|
|
465
|
+
});
|
|
466
|
+
renderAll();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function undoLast() {
|
|
470
|
+
const enc = activeEnc();
|
|
471
|
+
if (!enc || !enc.run.log.length) { toast("Nothing to undo."); return; }
|
|
472
|
+
const last = enc.run.log.pop();
|
|
473
|
+
const log = enc.run.log;
|
|
474
|
+
enc.run = Object.assign(clone(last.before), { log });
|
|
475
|
+
save();
|
|
476
|
+
toast("Undid: " + last.label);
|
|
477
|
+
renderAll();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function resetRun(encId) {
|
|
481
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
482
|
+
if (!enc) return;
|
|
483
|
+
if (!confirm("Reset the live run? The prep — obstacles, thresholds, the NPC — is kept.")) return;
|
|
484
|
+
enc.run = freshRun(enc);
|
|
485
|
+
save();
|
|
486
|
+
toast("Run reset.");
|
|
487
|
+
renderAll();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* ============================================================
|
|
491
|
+
LIBRARY ACTIONS
|
|
492
|
+
============================================================ */
|
|
493
|
+
function openEncounter(id) { library.activeId = id; save(); go("run"); }
|
|
494
|
+
|
|
495
|
+
function createEncounter(subKey, opts) {
|
|
496
|
+
const enc = newEncounter(subKey, opts);
|
|
497
|
+
library.encounters.unshift(enc);
|
|
498
|
+
library.activeId = enc.id;
|
|
499
|
+
save();
|
|
500
|
+
go("prep");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function duplicateEncounter(id) {
|
|
504
|
+
const src = library.encounters.find((e) => e.id === id);
|
|
505
|
+
if (!src) return;
|
|
506
|
+
const copy = clone(src);
|
|
507
|
+
copy.id = uid();
|
|
508
|
+
copy.name = src.name + " (copy)";
|
|
509
|
+
copy.createdAt = new Date().toISOString();
|
|
510
|
+
copy.run = freshRun(copy);
|
|
511
|
+
library.encounters.unshift(copy);
|
|
512
|
+
save();
|
|
513
|
+
toast("Duplicated.");
|
|
514
|
+
renderAll();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function deleteEncounter(id) {
|
|
518
|
+
const enc = library.encounters.find((e) => e.id === id);
|
|
519
|
+
if (!enc) return;
|
|
520
|
+
if (!confirm('Delete "' + enc.name + '"? This cannot be undone.')) return;
|
|
521
|
+
library.encounters = library.encounters.filter((e) => e.id !== id);
|
|
522
|
+
if (library.activeId === id) library.activeId = (library.encounters[0] || {}).id || null;
|
|
523
|
+
save();
|
|
524
|
+
renderAll();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function renameEncounter(id, name) {
|
|
528
|
+
const enc = library.encounters.find((e) => e.id === id);
|
|
529
|
+
if (!enc) return;
|
|
530
|
+
enc.name = name || enc.name;
|
|
531
|
+
save();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/* Export carries the prep only — never the run. */
|
|
535
|
+
function exportEncounter(id) {
|
|
536
|
+
const enc = library.encounters.find((e) => e.id === id);
|
|
537
|
+
if (!enc) return;
|
|
538
|
+
const payload = { t: "pf2e-subsystem", v: 1, subsystem: enc.subsystem, name: enc.name, def: enc.def };
|
|
539
|
+
const code = b64encode(JSON.stringify(payload));
|
|
540
|
+
navigator.clipboard && navigator.clipboard.writeText(code)
|
|
541
|
+
.then(() => toast("Code copied to the clipboard."))
|
|
542
|
+
.catch(() => prompt("Copy this code:", code));
|
|
543
|
+
if (!navigator.clipboard) prompt("Copy this code:", code);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function importEncounter(code) {
|
|
547
|
+
let payload;
|
|
548
|
+
try {
|
|
549
|
+
payload = JSON.parse(b64decode(String(code || "").trim()));
|
|
550
|
+
} catch (e) {
|
|
551
|
+
toast("That doesn't look like an encounter code.");
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
if (!payload || payload.t !== "pf2e-subsystem" || !SUBSYSTEMS[payload.subsystem]) {
|
|
555
|
+
toast("That code isn't from this app.");
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
const enc = {
|
|
559
|
+
id: uid(),
|
|
560
|
+
subsystem: payload.subsystem,
|
|
561
|
+
name: payload.name || SUBSYSTEMS[payload.subsystem].name,
|
|
562
|
+
createdAt: new Date().toISOString(),
|
|
563
|
+
def: payload.def,
|
|
564
|
+
run: null,
|
|
565
|
+
};
|
|
566
|
+
enc.run = freshRun(enc);
|
|
567
|
+
library.encounters.unshift(enc);
|
|
568
|
+
library.activeId = enc.id;
|
|
569
|
+
save();
|
|
570
|
+
toast("Imported.");
|
|
571
|
+
renderAll();
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/* ============================================================
|
|
576
|
+
NAVIGATION
|
|
577
|
+
============================================================ */
|
|
578
|
+
const VIEWS = [
|
|
579
|
+
{ key: "run", label: "Run" },
|
|
580
|
+
{ key: "prep", label: "Prep" },
|
|
581
|
+
{ key: "reference", label: "Reference" },
|
|
582
|
+
];
|
|
583
|
+
let current = "prep";
|
|
584
|
+
let menuOpen = false;
|
|
585
|
+
|
|
586
|
+
function go(view) {
|
|
587
|
+
current = view;
|
|
588
|
+
menuOpen = false;
|
|
589
|
+
renderAll();
|
|
590
|
+
window.scrollTo(0, 0);
|
|
591
|
+
}
|
|
592
|
+
function openMenu() { menuOpen = true; renderAll(); }
|
|
593
|
+
function closeMenu() { menuOpen = false; renderAll(); }
|
|
594
|
+
|
|
595
|
+
function renderAll() {
|
|
596
|
+
renderHeader();
|
|
597
|
+
renderTabs();
|
|
598
|
+
const views = byId("views");
|
|
599
|
+
if (menuOpen) { views.innerHTML = menuHtml(); return; }
|
|
600
|
+
if (current === "run") views.innerHTML = runHtml();
|
|
601
|
+
else if (current === "prep") views.innerHTML = prepHtml();
|
|
602
|
+
else views.innerHTML = referenceHtml();
|
|
603
|
+
wireInputs();
|
|
604
|
+
}
|
|
605
|
+
function renderMenu() { if (menuOpen) renderAll(); }
|
|
606
|
+
|
|
607
|
+
function renderHeader() {
|
|
608
|
+
const enc = activeEnc();
|
|
609
|
+
const sub = enc ? subOf(enc) : null;
|
|
610
|
+
byId("headSub").innerHTML = enc
|
|
611
|
+
? '<span class="glyph">' + esc(sub.glyph) + "</span> " + esc(enc.name)
|
|
612
|
+
+ ' <span class="dim">· ' + esc(sub.name) + "</span>"
|
|
613
|
+
: '<span class="dim">Nothing open — start one in Prep.</span>';
|
|
614
|
+
const undo = byId("undoBtn");
|
|
615
|
+
const can = enc && enc.run.log.length;
|
|
616
|
+
undo.disabled = !can;
|
|
617
|
+
undo.classList.toggle("off", !can);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function renderTabs() {
|
|
621
|
+
byId("tabs").innerHTML = VIEWS.map((v) =>
|
|
622
|
+
'<button id="nav-' + v.key + '" class="tab' + (current === v.key && !menuOpen ? " on" : "")
|
|
623
|
+
+ '" onclick="go(\'' + v.key + '\')">' + esc(v.label) + "</button>").join("");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/* ============================================================
|
|
627
|
+
THE RUN BOARD
|
|
628
|
+
============================================================ */
|
|
629
|
+
function runHtml() {
|
|
630
|
+
const enc = activeEnc();
|
|
631
|
+
if (!enc) return empty("No encounter open", "Open or build one in <b>Prep</b>.");
|
|
632
|
+
const sub = subOf(enc);
|
|
633
|
+
const parts = [];
|
|
634
|
+
|
|
635
|
+
parts.push(roundBarHtml(enc, sub));
|
|
636
|
+
parts.push(metersHtml(enc, sub));
|
|
637
|
+
|
|
638
|
+
if (sub.structure === "sequence") parts.push(obstacleHtml(enc, sub));
|
|
639
|
+
if (sub.structure === "checks") parts.push(checksHtml(enc, sub));
|
|
640
|
+
if (enc.subsystem === "influence") parts.push(npcHtml(enc, sub));
|
|
641
|
+
|
|
642
|
+
parts.push(participantsHtml(enc, sub));
|
|
643
|
+
parts.push(thresholdStateHtml(enc));
|
|
644
|
+
parts.push(logHtml(enc));
|
|
645
|
+
return parts.join("");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function roundBarHtml(enc, sub) {
|
|
649
|
+
const len = sub.roundLength
|
|
650
|
+
? ' <span class="dim">· a round is ' + esc(enc.def.roundMinutes || enc.def.roundLabel
|
|
651
|
+
|| (sub.roundLength.low + " to " + sub.roundLength.high)) + "</span>"
|
|
652
|
+
: "";
|
|
653
|
+
const note = sub.roundEnd
|
|
654
|
+
? '<div class="note warn">' + esc(sub.roundEndNote) + "</div>" : "";
|
|
655
|
+
return '<section class="card">'
|
|
656
|
+
+ '<div class="row spread">'
|
|
657
|
+
+ "<h2>Round " + enc.run.round + len + "</h2>"
|
|
658
|
+
+ '<div class="rowbtns">'
|
|
659
|
+
+ '<button class="btn" onclick="advanceRound(\'' + enc.id + '\')">Advance round →</button>'
|
|
660
|
+
+ '<button class="btn ghost" onclick="resetRun(\'' + enc.id + '\')">Reset run</button>'
|
|
661
|
+
+ "</div></div>" + note + "</section>";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function metersHtml(enc, sub) {
|
|
665
|
+
const pools = poolsOf(enc);
|
|
666
|
+
const cells = pools.map((p) => {
|
|
667
|
+
const v = enc.run.pools[p.key] || 0;
|
|
668
|
+
const target = meterTarget(enc, p);
|
|
669
|
+
return '<div class="meter ' + (p.role === "hazard" ? "hazard" : "goal") + '">'
|
|
670
|
+
+ '<div class="meterhead"><span>' + esc(p.name) + "</span>"
|
|
671
|
+
+ '<span class="mval">' + v + (target ? ' <span class="dim">/ ' + target + "</span>" : "") + "</span></div>"
|
|
672
|
+
+ meterPips(enc, p, v, target)
|
|
673
|
+
+ '<div class="rowbtns tight">'
|
|
674
|
+
+ '<button class="chipbtn" onclick="adjustPool(\'' + enc.id + "','" + p.key + "',-1)\">−1</button>"
|
|
675
|
+
+ '<button class="chipbtn" onclick="adjustPool(\'' + enc.id + "','" + p.key + "',1)\">+1</button>"
|
|
676
|
+
+ "</div></div>";
|
|
677
|
+
});
|
|
678
|
+
if (sub.edge) {
|
|
679
|
+
const ep = enc.run.pools.ep || 0;
|
|
680
|
+
cells.push('<div class="meter edge"><div class="meterhead"><span>'
|
|
681
|
+
+ esc(sub.edge.name) + '</span><span class="mval">' + ep + "</span></div>"
|
|
682
|
+
+ '<div class="note">' + esc(sub.edge.note) + "</div>"
|
|
683
|
+
+ '<div class="rowbtns tight">'
|
|
684
|
+
+ '<button class="chipbtn" onclick="adjustPool(\'' + enc.id + "',\'ep\',-1)\">−1</button>"
|
|
685
|
+
+ '<button class="chipbtn" onclick="adjustPool(\'' + enc.id + "',\'ep\',1)\">+1</button>"
|
|
686
|
+
+ "</div></div>");
|
|
687
|
+
}
|
|
688
|
+
return '<section class="meters">' + cells.join("") + "</section>";
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function meterTarget(enc, pool) {
|
|
692
|
+
if (pool.role === "hazard") {
|
|
693
|
+
const lad = awarenessLadder(enc);
|
|
694
|
+
return lad ? lad.fail : null;
|
|
695
|
+
}
|
|
696
|
+
if (subOf(enc).structure === "sequence") {
|
|
697
|
+
const ob = currentObstacle(enc);
|
|
698
|
+
return ob ? ob.cost : null;
|
|
699
|
+
}
|
|
700
|
+
return enc.def.goal || null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function meterPips(enc, pool, value, target) {
|
|
704
|
+
if (!target) return "";
|
|
705
|
+
const marks = pool.role === "hazard"
|
|
706
|
+
? ((awarenessLadder(enc) || {}).marks || [])
|
|
707
|
+
: (subOf(enc).thresholdBasis === "progress"
|
|
708
|
+
? [] : (enc.def.thresholds || []).map((t) => t.at));
|
|
709
|
+
const width = Math.min(100, target ? (value / target) * 100 : 0);
|
|
710
|
+
const ticks = marks.filter((m) => m > 0 && m < target).map((m) =>
|
|
711
|
+
'<span class="tick" style="left:' + ((m / target) * 100) + '%" title="'
|
|
712
|
+
+ esc(m) + '"></span>').join("");
|
|
713
|
+
return '<div class="bar"><span class="fill" style="width:' + width + '%"></span>'
|
|
714
|
+
+ ticks + "</div>";
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function obstacleHtml(enc, sub) {
|
|
718
|
+
const list = enc.def.obstacles || [];
|
|
719
|
+
const ob = currentObstacle(enc);
|
|
720
|
+
if (!ob) {
|
|
721
|
+
return '<section class="card done"><h2>All obstacles cleared</h2>'
|
|
722
|
+
+ '<p class="dim">' + list.length + " of " + list.length
|
|
723
|
+
+ " done. Whatever they were chasing, they caught it.</p></section>";
|
|
724
|
+
}
|
|
725
|
+
const cleared = enc.run._justCleared;
|
|
726
|
+
delete enc.run._justCleared;
|
|
727
|
+
const banner = cleared
|
|
728
|
+
? '<div class="note heal">Cleared <b>' + esc(cleared) + "</b> — leftover points don't carry over.</div>"
|
|
729
|
+
: "";
|
|
730
|
+
const opts = (ob.options || []).map((o) =>
|
|
731
|
+
'<span class="chk">' + esc(titleCase(o.skill)) + " DC " + o.dc
|
|
732
|
+
+ (o.how ? ' <span class="dim">to ' + esc(o.how) + "</span>" : "") + "</span>").join("");
|
|
733
|
+
let progress;
|
|
734
|
+
if (ob.mode === "individual") {
|
|
735
|
+
const got = enc.run.perPC[ob.id] || {};
|
|
736
|
+
progress = '<div class="perpc">' + (enc.def.participants || []).filter(Boolean).map((n) =>
|
|
737
|
+
'<span class="pcchip' + ((got[n] || 0) >= ob.cost ? " on" : "") + '">'
|
|
738
|
+
+ esc(n) + " " + (got[n] || 0) + "/" + ob.cost + "</span>").join("") + "</div>";
|
|
739
|
+
} else {
|
|
740
|
+
progress = '<p class="dim">' + (enc.run.pools[poolsOf(enc)[0].key] || 0)
|
|
741
|
+
+ " of " + ob.cost + " " + esc(poolsOf(enc)[0].abbr) + "</p>";
|
|
742
|
+
}
|
|
743
|
+
return '<section class="card">' + banner
|
|
744
|
+
+ '<div class="row spread"><h2>' + esc(ob.name || "Obstacle") + "</h2>"
|
|
745
|
+
+ '<span class="pill">' + (enc.run.obstacleIndex + 1) + " of " + list.length + "</span></div>"
|
|
746
|
+
+ (ob.note ? "<p>" + esc(ob.note) + "</p>" : "")
|
|
747
|
+
+ (opts ? '<div class="chks">' + opts + "</div>" : "")
|
|
748
|
+
+ '<div class="pill mode">' + esc(ob.mode === "individual" ? "Individual" : "Group") + "</div>"
|
|
749
|
+
+ progress + "</section>";
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function checksHtml(enc, sub) {
|
|
753
|
+
const checks = enc.def.checks || [];
|
|
754
|
+
if (!checks.length) return "";
|
|
755
|
+
return '<section class="card"><h2>Sources</h2>'
|
|
756
|
+
+ '<p class="dim">' + esc(GM_CALLS.researchCap) + "</p>"
|
|
757
|
+
+ checks.map((c) => {
|
|
758
|
+
const got = enc.run.checks[c.id] || 0;
|
|
759
|
+
const dry = got >= (c.cap || 0);
|
|
760
|
+
return '<div class="srcrow' + (dry ? " dry" : "") + '">'
|
|
761
|
+
+ '<span class="srcname">' + esc(c.name) + "</span>"
|
|
762
|
+
+ '<span class="chk">' + esc(c.skill) + " DC " + c.dc + "</span>"
|
|
763
|
+
+ '<span class="pill">' + got + "/" + (c.cap || 0) + " RP"
|
|
764
|
+
+ (dry ? " · spent" : "") + "</span></div>";
|
|
765
|
+
}).join("") + "</section>";
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function npcHtml(enc, sub) {
|
|
769
|
+
const npc = enc.def.npc || {};
|
|
770
|
+
const shown = (kind, list) => (list || []).map((r, i) => {
|
|
771
|
+
const id = kind + ":" + i;
|
|
772
|
+
const open = enc.run.revealed.indexOf(id) !== -1;
|
|
773
|
+
if (!open) {
|
|
774
|
+
return '<button class="hidden-fact" onclick="revealFact(\'' + enc.id + "','"
|
|
775
|
+
+ kind + "'," + i + ')">Reveal a ' + kind + "</button>";
|
|
776
|
+
}
|
|
777
|
+
const base = (npc.skills || []).map((s) => s.dc).sort((a, b) => a - b)[0];
|
|
778
|
+
const shift = base != null
|
|
779
|
+
? ' <span class="dim">(' + base + " \u2192 " + adjustedDC(base, [r]) + ")</span>"
|
|
780
|
+
: "";
|
|
781
|
+
return '<span class="chk ' + kind + '">' + esc(r.label)
|
|
782
|
+
+ " " + (r.dc > 0 ? "+" : "") + r.dc + " DC" + shift + "</span>";
|
|
783
|
+
}).join("");
|
|
784
|
+
return '<section class="card"><div class="row spread"><h2>' + esc(npc.name || "The NPC")
|
|
785
|
+
+ '</h2><span class="pill">Discovery DC ' + (npc.discoveryDC || "—") + "</span></div>"
|
|
786
|
+
+ '<div class="chks">' + (npc.skills || []).map((s) =>
|
|
787
|
+
'<span class="chk">' + esc(s.skill) + " DC " + s.dc + "</span>").join("") + "</div>"
|
|
788
|
+
+ ((npc.resistances || []).length
|
|
789
|
+
? '<div class="factrow"><span class="flabel">Resistances</span>' + shown("resistance", npc.resistances) + "</div>" : "")
|
|
790
|
+
+ ((npc.weaknesses || []).length
|
|
791
|
+
? '<div class="factrow"><span class="flabel">Weaknesses</span>' + shown("weakness", npc.weaknesses) + "</div>" : "")
|
|
792
|
+
+ '<div class="note">' + esc(sub.discoverNote) + "</div></section>";
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function participantsHtml(enc, sub) {
|
|
796
|
+
const names = (enc.def.participants || []).filter(Boolean);
|
|
797
|
+
if (!names.length) {
|
|
798
|
+
return empty("No participants yet", "Add their names in <b>Prep</b> so the rows have labels.");
|
|
799
|
+
}
|
|
800
|
+
const isInfluence = enc.subsystem === "influence";
|
|
801
|
+
const checks = enc.def.checks || [];
|
|
802
|
+
const rows = names.map((n) => {
|
|
803
|
+
const degBtns = DEGREES.map((d) =>
|
|
804
|
+
'<button class="deg ' + d.tone + '" onclick="scoreFrom(\'' + enc.id + "','"
|
|
805
|
+
+ esc(n).replace(/'/g, "\\'") + "','" + d.key + '\')">' + esc(d.short) + "</button>").join("");
|
|
806
|
+
const discBtns = isInfluence ? DEGREES.map((d) =>
|
|
807
|
+
'<button class="deg ghost" onclick="discover(\'' + enc.id + "','"
|
|
808
|
+
+ esc(n).replace(/'/g, "\\'") + "','" + d.key + '\')">' + esc(d.short) + "</button>").join("") : "";
|
|
809
|
+
const edgeBtn = sub.edge
|
|
810
|
+
? '<button class="deg edge" onclick="scoreFrom(\'' + enc.id + "','"
|
|
811
|
+
+ esc(n).replace(/'/g, "\\'") + '\',\'failure\',true)" title="'
|
|
812
|
+
+ esc(sub.edge.note) + '">Spend EP</button>'
|
|
813
|
+
: "";
|
|
814
|
+
return '<div class="pcrow"><div class="pcname">' + esc(n) + "</div>"
|
|
815
|
+
+ '<div class="degs">' + (isInfluence ? '<span class="deglabel">Influence</span>' : "")
|
|
816
|
+
+ degBtns + edgeBtn + "</div>"
|
|
817
|
+
+ (isInfluence ? '<div class="degs"><span class="deglabel">Discover</span>' + discBtns + "</div>" : "")
|
|
818
|
+
+ "</div>";
|
|
819
|
+
}).join("");
|
|
820
|
+
const picker = checks.length > 1
|
|
821
|
+
? '<div class="row"><label class="flabel" for="srcPick">Reading</label>'
|
|
822
|
+
+ '<select id="srcPick" class="inp">' + checks.map((c) =>
|
|
823
|
+
'<option value="' + c.id + '">' + esc(c.name) + " · " + esc(c.skill)
|
|
824
|
+
+ " DC " + c.dc + "</option>").join("") + "</select></div>"
|
|
825
|
+
: "";
|
|
826
|
+
const extras = (sub.extras || []).map((x) =>
|
|
827
|
+
'<button class="btn ghost" title="' + esc(x.note) + '" onclick="extra(\''
|
|
828
|
+
+ enc.id + "','" + x.key + '\')">' + esc(x.label) + "</button>").join("");
|
|
829
|
+
return '<section class="card"><h2>This round</h2>' + picker + rows
|
|
830
|
+
+ (extras ? '<div class="rowbtns wrap">' + extras + "</div>" : "") + "</section>";
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/* The source picker is read at click time, so it survives a re-render. */
|
|
834
|
+
function scoreFrom(encId, actor, degree, edge) {
|
|
835
|
+
const pick = byId("srcPick");
|
|
836
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
837
|
+
const only = enc && (enc.def.checks || []).length === 1 ? enc.def.checks[0].id : null;
|
|
838
|
+
score(encId, actor, degree, { edge: !!edge, checkId: pick ? pick.value : only });
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function thresholdStateHtml(enc) {
|
|
842
|
+
const list = (enc.def.thresholds || []).slice().sort((a, b) => a.at - b.at);
|
|
843
|
+
if (!list.length) return "";
|
|
844
|
+
const v = thresholdValue(enc);
|
|
845
|
+
return '<section class="card"><h2>Thresholds</h2>'
|
|
846
|
+
+ '<p class="dim">Counted in ' + esc(thresholdUnit(enc)) + " — at " + v + " now.</p>"
|
|
847
|
+
+ list.map((t, i) => {
|
|
848
|
+
const hit = v >= t.at;
|
|
849
|
+
return '<div class="throw' + (hit ? " hit" : "") + '">'
|
|
850
|
+
+ '<span class="pill">' + t.at + "</span>"
|
|
851
|
+
+ "<span><b>" + esc(t.label || "Threshold " + (i + 1)) + "</b>"
|
|
852
|
+
+ (t.effect ? '<br><span class="dim">' + esc(t.effect) + "</span>" : "") + "</span></div>";
|
|
853
|
+
}).join("") + "</section>";
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function logHtml(enc) {
|
|
857
|
+
const log = enc.run.log.slice(-12).reverse();
|
|
858
|
+
if (!log.length) return "";
|
|
859
|
+
return '<section class="card log"><h2>What happened</h2>' + log.map((e) =>
|
|
860
|
+
'<div class="logrow"><span class="pill">R' + e.round + "</span>"
|
|
861
|
+
+ (e.actor ? "<b>" + esc(e.actor) + "</b> " : "") + esc(e.label) + "</div>").join("")
|
|
862
|
+
+ "</section>";
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/* ============================================================
|
|
866
|
+
PREP
|
|
867
|
+
============================================================ */
|
|
868
|
+
function prepHtml() {
|
|
869
|
+
const enc = activeEnc();
|
|
870
|
+
return newEncounterHtml() + libraryHtml() + (enc ? editorHtml(enc) : "");
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function newEncounterHtml() {
|
|
874
|
+
return '<section class="card"><h2>Start something</h2><div class="picker">'
|
|
875
|
+
+ SUBSYSTEM_ORDER.map((k) => {
|
|
876
|
+
const s = SUBSYSTEMS[k];
|
|
877
|
+
return '<button class="pick" onclick="startNew(\'' + k + '\')">'
|
|
878
|
+
+ '<span class="glyph">' + esc(s.glyph) + "</span>"
|
|
879
|
+
+ "<b>" + esc(s.name) + "</b>"
|
|
880
|
+
+ '<span class="dim">' + esc(s.blurb) + "</span></button>";
|
|
881
|
+
}).join("") + "</div>"
|
|
882
|
+
+ '<div class="row"><input id="impCode" class="inp" placeholder="…or paste an encounter code">'
|
|
883
|
+
+ '<button class="btn" onclick="importEncounter(byId(\'impCode\').value)">Import</button></div>'
|
|
884
|
+
+ "</section>";
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function startNew(subKey) {
|
|
888
|
+
const opts = {};
|
|
889
|
+
if (subKey === "chase") {
|
|
890
|
+
const p = prompt("Short, medium or long? (6, 8 or 10 obstacles)", "medium");
|
|
891
|
+
if (p === null) return;
|
|
892
|
+
opts.preset = /^s/i.test(p) ? "short" : /^l/i.test(p) ? "long" : "medium";
|
|
893
|
+
const n = parseInt(prompt("How many PCs?", "4"), 10);
|
|
894
|
+
if (n > 0) opts.partySize = n;
|
|
895
|
+
} else if (subKey === "custom") {
|
|
896
|
+
const scales = GENERATED_VP_SCALES || [];
|
|
897
|
+
const list = scales.map((s, i) => (i + 1) + ") " + s.duration + " — " + s.endPoint + " VP").join("\n");
|
|
898
|
+
const pick = prompt("How long is this challenge?\n\n" + list + "\n\nNumber:", "2");
|
|
899
|
+
if (pick === null) return;
|
|
900
|
+
const idx = parseInt(pick, 10) - 1;
|
|
901
|
+
if (scales[idx]) opts.scale = scales[idx];
|
|
902
|
+
const nm = prompt("What are the points called?", "Victory Points");
|
|
903
|
+
if (nm) { opts.pointName = nm; opts.pointAbbr = nm.split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice(0, 3); }
|
|
904
|
+
} else if (subKey === "influence") {
|
|
905
|
+
const nm = prompt("Who are they working on?", "The NPC");
|
|
906
|
+
if (nm === null) return;
|
|
907
|
+
opts.npcName = nm;
|
|
908
|
+
}
|
|
909
|
+
createEncounter(subKey, opts);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function libraryHtml() {
|
|
913
|
+
if (!library.encounters.length) return "";
|
|
914
|
+
return '<section class="card"><h2>Saved</h2>' + library.encounters.map((e) => {
|
|
915
|
+
const s = subOf(e);
|
|
916
|
+
const on = e.id === library.activeId;
|
|
917
|
+
return '<div class="encrow' + (on ? " on" : "") + '">'
|
|
918
|
+
+ '<button class="encopen" onclick="openEncounter(\'' + e.id + '\')">'
|
|
919
|
+
+ '<span class="glyph">' + esc(s.glyph) + "</span><b>" + esc(e.name) + "</b>"
|
|
920
|
+
+ '<span class="dim">' + esc(s.name) + " · round " + e.run.round + "</span></button>"
|
|
921
|
+
+ '<div class="rowbtns tight">'
|
|
922
|
+
+ '<button class="chipbtn" onclick="exportEncounter(\'' + e.id + '\')">Code</button>'
|
|
923
|
+
+ '<button class="chipbtn" onclick="duplicateEncounter(\'' + e.id + '\')">Copy</button>'
|
|
924
|
+
+ '<button class="chipbtn danger" onclick="deleteEncounter(\'' + e.id + '\')">Delete</button>'
|
|
925
|
+
+ "</div></div>";
|
|
926
|
+
}).join("") + "</section>";
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function editorHtml(enc) {
|
|
930
|
+
const sub = subOf(enc);
|
|
931
|
+
const asks = (sub.asks || []).map((k) =>
|
|
932
|
+
'<div class="note">' + esc(GM_CALLS[k]) + "</div>").join("");
|
|
933
|
+
return '<section class="card"><h2>Editing: ' + esc(enc.name) + "</h2>"
|
|
934
|
+
+ field("Name", '<input class="inp" value="' + esc(enc.name)
|
|
935
|
+
+ '" oninput="renameEncounter(\'' + enc.id + '\', this.value); renderHeader()">')
|
|
936
|
+
+ field("Participants", '<input class="inp" value="'
|
|
937
|
+
+ esc((enc.def.participants || []).join(", "))
|
|
938
|
+
+ '" oninput="setParticipants(\'' + enc.id + '\', this.value)">'
|
|
939
|
+
+ '<span class="hint">Comma separated. These label the rows on the run board.</span>')
|
|
940
|
+
+ (sub.structure === "sequence" ? obstacleEditorHtml(enc) : "")
|
|
941
|
+
+ (sub.structure === "checks" ? checkEditorHtml(enc) : "")
|
|
942
|
+
+ (enc.subsystem === "influence" ? npcEditorHtml(enc) : "")
|
|
943
|
+
+ (sub.modes ? modeEditorHtml(enc, sub) : "")
|
|
944
|
+
+ thresholdEditorHtml(enc)
|
|
945
|
+
+ asks
|
|
946
|
+
+ "</section>";
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function field(label, inner) {
|
|
950
|
+
return '<div class="fld"><label class="flabel">' + esc(label) + "</label>" + inner + "</div>";
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function setParticipants(id, value) {
|
|
954
|
+
const enc = library.encounters.find((e) => e.id === id);
|
|
955
|
+
if (!enc) return;
|
|
956
|
+
enc.def.participants = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
957
|
+
save();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function obstacleEditorHtml(enc) {
|
|
961
|
+
const list = enc.def.obstacles || [];
|
|
962
|
+
return '<div class="fld"><label class="flabel">Obstacles</label>'
|
|
963
|
+
+ list.map((o, i) =>
|
|
964
|
+
'<div class="obrow">'
|
|
965
|
+
+ '<input class="inp" value="' + esc(o.name) + '" oninput="setObstacle(\''
|
|
966
|
+
+ enc.id + "','" + o.id + '\',\'name\',this.value)">'
|
|
967
|
+
+ '<input class="inp num" type="number" min="1" value="' + o.cost
|
|
968
|
+
+ '" oninput="setObstacle(\'' + enc.id + "','" + o.id + '\',\'cost\',this.value)">'
|
|
969
|
+
+ (enc.subsystem === "infiltration"
|
|
970
|
+
? '<select class="inp" onchange="setObstacle(\'' + enc.id + "','" + o.id
|
|
971
|
+
+ '\',\'mode\',this.value)">'
|
|
972
|
+
+ subOf(enc).obstacleModes.map((m) => '<option value="' + m.key + '"'
|
|
973
|
+
+ (o.mode === m.key ? " selected" : "") + ">" + esc(m.label) + "</option>").join("")
|
|
974
|
+
+ "</select>" : "")
|
|
975
|
+
+ '<button class="chipbtn danger" onclick="removeObstacle(\'' + enc.id + "','"
|
|
976
|
+
+ o.id + '\')">×</button></div>').join("")
|
|
977
|
+
+ '<div class="rowbtns wrap"><button class="btn ghost" onclick="addObstacle(\''
|
|
978
|
+
+ enc.id + '\')">Add obstacle</button>'
|
|
979
|
+
+ (subOf(enc).obstacleLibrary
|
|
980
|
+
? '<button class="btn ghost" onclick="pickObstacle(\'' + enc.id
|
|
981
|
+
+ '\')">Pick from GM Core (' + (GENERATED_CHASE_OBSTACLES || []).length + ")</button>" : "")
|
|
982
|
+
+ "</div></div>";
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function setObstacle(encId, obId, key, value) {
|
|
986
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
987
|
+
const ob = enc && (enc.def.obstacles || []).find((o) => o.id === obId);
|
|
988
|
+
if (!ob) return;
|
|
989
|
+
ob[key] = key === "cost" ? Math.max(1, parseInt(value, 10) || 1) : value;
|
|
990
|
+
save();
|
|
991
|
+
}
|
|
992
|
+
function addObstacle(encId) {
|
|
993
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
994
|
+
if (!enc) return;
|
|
995
|
+
const n = (enc.def.obstacles || []).length;
|
|
996
|
+
enc.def.obstacles.push(newObstacle("Obstacle " + (n + 1),
|
|
997
|
+
suggestedCost(subOf(enc).obstacleCost, (enc.def.participants || []).length || 4, n)));
|
|
998
|
+
save(); renderAll();
|
|
999
|
+
}
|
|
1000
|
+
function removeObstacle(encId, obId) {
|
|
1001
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1002
|
+
if (!enc) return;
|
|
1003
|
+
enc.def.obstacles = enc.def.obstacles.filter((o) => o.id !== obId);
|
|
1004
|
+
if (enc.run.obstacleIndex >= enc.def.obstacles.length) {
|
|
1005
|
+
enc.run.obstacleIndex = Math.max(0, enc.def.obstacles.length - 1);
|
|
1006
|
+
}
|
|
1007
|
+
save(); renderAll();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function pickObstacle(encId) {
|
|
1011
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1012
|
+
if (!enc) return;
|
|
1013
|
+
const lib = GENERATED_CHASE_OBSTACLES || [];
|
|
1014
|
+
const term = prompt("Search GM Core's sample obstacles by name, terrain or level\n"
|
|
1015
|
+
+ "(e.g. \"urban\", \"rooftops\", \"5\"):", "");
|
|
1016
|
+
if (term === null) return;
|
|
1017
|
+
const t = term.trim().toLowerCase();
|
|
1018
|
+
const hits = lib.filter((o) => !t
|
|
1019
|
+
|| o.name.toLowerCase().indexOf(t) !== -1
|
|
1020
|
+
|| o.table.toLowerCase().indexOf(t) !== -1
|
|
1021
|
+
|| String(o.level) === t);
|
|
1022
|
+
if (!hits.length) { toast("Nothing matched."); return; }
|
|
1023
|
+
const list = hits.slice(0, 20).map((o, i) =>
|
|
1024
|
+
(i + 1) + ") " + o.name + " (" + o.table + ", level " + o.level + ") — "
|
|
1025
|
+
+ o.options.map((x) => titleCase(x.skill) + " DC " + x.dc).join(", ")).join("\n");
|
|
1026
|
+
const pick = parseInt(prompt(list + "\n\nNumber:", "1"), 10) - 1;
|
|
1027
|
+
const chosen = hits[pick];
|
|
1028
|
+
if (!chosen) return;
|
|
1029
|
+
const n = (enc.def.obstacles || []).length;
|
|
1030
|
+
enc.def.obstacles.push(newObstacle(chosen.name,
|
|
1031
|
+
suggestedCost(subOf(enc).obstacleCost, (enc.def.participants || []).length || 4, n),
|
|
1032
|
+
{ options: chosen.options.map((o) => ({ skill: o.skill, dc: o.dc, how: o.how })) }));
|
|
1033
|
+
save(); renderAll();
|
|
1034
|
+
toast("Added " + chosen.name + ".");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function checkEditorHtml(enc) {
|
|
1038
|
+
return '<div class="fld"><label class="flabel">Research checks</label>'
|
|
1039
|
+
+ (enc.def.checks || []).map((c) =>
|
|
1040
|
+
'<div class="obrow">'
|
|
1041
|
+
+ '<input class="inp" value="' + esc(c.name) + '" oninput="setCheck(\'' + enc.id + "','" + c.id + '\',\'name\',this.value)">'
|
|
1042
|
+
+ '<input class="inp" value="' + esc(c.skill) + '" oninput="setCheck(\'' + enc.id + "','" + c.id + '\',\'skill\',this.value)">'
|
|
1043
|
+
+ '<input class="inp num" type="number" value="' + c.dc + '" oninput="setCheck(\'' + enc.id + "','" + c.id + '\',\'dc\',this.value)">'
|
|
1044
|
+
+ '<input class="inp num" type="number" min="1" value="' + c.cap + '" oninput="setCheck(\'' + enc.id + "','" + c.id + '\',\'cap\',this.value)">'
|
|
1045
|
+
+ '<button class="chipbtn danger" onclick="removeCheck(\'' + enc.id + "','" + c.id + '\')">×</button></div>').join("")
|
|
1046
|
+
+ '<span class="hint">Name · skill · DC · maximum RP. ' + esc(subOf(enc).checkNote) + "</span>"
|
|
1047
|
+
+ '<div class="rowbtns"><button class="btn ghost" onclick="addCheck(\'' + enc.id + '\')">Add a source</button></div></div>';
|
|
1048
|
+
}
|
|
1049
|
+
function setCheck(encId, cid, key, value) {
|
|
1050
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1051
|
+
const c = enc && (enc.def.checks || []).find((x) => x.id === cid);
|
|
1052
|
+
if (!c) return;
|
|
1053
|
+
c[key] = (key === "dc" || key === "cap") ? (parseInt(value, 10) || 0) : value;
|
|
1054
|
+
save();
|
|
1055
|
+
}
|
|
1056
|
+
function addCheck(encId) {
|
|
1057
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1058
|
+
if (!enc) return;
|
|
1059
|
+
enc.def.checks.push({ id: uid(), name: "Another source", skill: "Society", dc: 18, cap: 2 });
|
|
1060
|
+
save(); renderAll();
|
|
1061
|
+
}
|
|
1062
|
+
function removeCheck(encId, cid) {
|
|
1063
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1064
|
+
if (!enc) return;
|
|
1065
|
+
enc.def.checks = enc.def.checks.filter((c) => c.id !== cid);
|
|
1066
|
+
save(); renderAll();
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function npcEditorHtml(enc) {
|
|
1070
|
+
const npc = enc.def.npc;
|
|
1071
|
+
const rows = (kind) => (npc[kind] || []).map((r, i) =>
|
|
1072
|
+
'<div class="obrow"><input class="inp" value="' + esc(r.label)
|
|
1073
|
+
+ '" oninput="setFact(\'' + enc.id + "','" + kind + "'," + i + ',\'label\',this.value)">'
|
|
1074
|
+
+ '<input class="inp num" type="number" value="' + r.dc + '" oninput="setFact(\''
|
|
1075
|
+
+ enc.id + "','" + kind + "'," + i + ',\'dc\',this.value)">'
|
|
1076
|
+
+ '<button class="chipbtn danger" onclick="removeFact(\'' + enc.id + "','" + kind
|
|
1077
|
+
+ "'," + i + ')">×</button></div>').join("")
|
|
1078
|
+
+ '<div class="rowbtns"><button class="btn ghost" onclick="addFact(\'' + enc.id
|
|
1079
|
+
+ "','" + kind + '\')">Add a ' + kind + "</button></div>";
|
|
1080
|
+
return field("NPC", '<input class="inp" value="' + esc(npc.name)
|
|
1081
|
+
+ '" oninput="setNpc(\'' + enc.id + '\',\'name\',this.value)">')
|
|
1082
|
+
+ field("Discovery DC", '<input class="inp num" type="number" value="' + npc.discoveryDC
|
|
1083
|
+
+ '" oninput="setNpc(\'' + enc.id + '\',\'discoveryDC\',this.value)">')
|
|
1084
|
+
+ '<div class="fld"><label class="flabel">Influence skills</label>'
|
|
1085
|
+
+ (npc.skills || []).map((s, i) =>
|
|
1086
|
+
'<div class="obrow"><input class="inp" value="' + esc(s.skill)
|
|
1087
|
+
+ '" oninput="setSkill(\'' + enc.id + "'," + i + ',\'skill\',this.value)">'
|
|
1088
|
+
+ '<input class="inp num" type="number" value="' + s.dc + '" oninput="setSkill(\''
|
|
1089
|
+
+ enc.id + "'," + i + ',\'dc\',this.value)">'
|
|
1090
|
+
+ '<button class="chipbtn danger" onclick="removeSkill(\'' + enc.id + "'," + i
|
|
1091
|
+
+ ')">×</button></div>').join("")
|
|
1092
|
+
+ '<span class="hint">Lowest DC first — that is the order the book uses.</span>'
|
|
1093
|
+
+ '<div class="rowbtns"><button class="btn ghost" onclick="addSkill(\'' + enc.id
|
|
1094
|
+
+ '\')">Add a skill</button></div></div>'
|
|
1095
|
+
+ '<div class="fld"><label class="flabel">Resistances (raise a DC)</label>' + rows("resistance") + "</div>"
|
|
1096
|
+
+ '<div class="fld"><label class="flabel">Weaknesses (lower a DC)</label>' + rows("weakness") + "</div>";
|
|
1097
|
+
}
|
|
1098
|
+
function setNpc(encId, key, value) {
|
|
1099
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1100
|
+
if (!enc) return;
|
|
1101
|
+
enc.def.npc[key] = key === "discoveryDC" ? (parseInt(value, 10) || 0) : value;
|
|
1102
|
+
save();
|
|
1103
|
+
}
|
|
1104
|
+
function setSkill(encId, i, key, value) {
|
|
1105
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1106
|
+
if (!enc) return;
|
|
1107
|
+
enc.def.npc.skills[i][key] = key === "dc" ? (parseInt(value, 10) || 0) : value;
|
|
1108
|
+
save();
|
|
1109
|
+
}
|
|
1110
|
+
function addSkill(encId) {
|
|
1111
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1112
|
+
enc.def.npc.skills.push({ skill: "Deception", dc: 20 });
|
|
1113
|
+
save(); renderAll();
|
|
1114
|
+
}
|
|
1115
|
+
function removeSkill(encId, i) {
|
|
1116
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1117
|
+
enc.def.npc.skills.splice(i, 1);
|
|
1118
|
+
save(); renderAll();
|
|
1119
|
+
}
|
|
1120
|
+
function factList(enc, kind) {
|
|
1121
|
+
return kind === "resistance" ? enc.def.npc.resistances : enc.def.npc.weaknesses;
|
|
1122
|
+
}
|
|
1123
|
+
function setFact(encId, kind, i, key, value) {
|
|
1124
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1125
|
+
factList(enc, kind)[i][key] = key === "dc" ? (parseInt(value, 10) || 0) : value;
|
|
1126
|
+
save();
|
|
1127
|
+
}
|
|
1128
|
+
function addFact(encId, kind) {
|
|
1129
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1130
|
+
factList(enc, kind).push({ label: kind === "resistance" ? "Flattery" : "Honesty",
|
|
1131
|
+
dc: kind === "resistance" ? 3 : -3 });
|
|
1132
|
+
save(); renderAll();
|
|
1133
|
+
}
|
|
1134
|
+
function removeFact(encId, kind, i) {
|
|
1135
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1136
|
+
factList(enc, kind).splice(i, 1);
|
|
1137
|
+
save(); renderAll();
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function modeEditorHtml(enc, sub) {
|
|
1141
|
+
return field("Structure", sub.modes.map((m) =>
|
|
1142
|
+
'<label class="radio"><input type="radio" name="mode" value="' + m.key + '"'
|
|
1143
|
+
+ (enc.def.mode === m.key ? " checked" : "")
|
|
1144
|
+
+ ' onchange="setMode(\'' + enc.id + "','" + m.key + '\')"> <b>' + esc(m.label)
|
|
1145
|
+
+ '</b> <span class="dim">' + esc(m.note) + "</span></label>").join(""))
|
|
1146
|
+
+ field("End point", '<input class="inp num" type="number" value="' + (enc.def.goal || 0)
|
|
1147
|
+
+ '" oninput="setGoal(\'' + enc.id + '\', this.value)">'
|
|
1148
|
+
+ (enc.def.scaleLabel ? '<span class="hint">GM Core scale: ' + esc(enc.def.scaleLabel) + "</span>" : ""));
|
|
1149
|
+
}
|
|
1150
|
+
function setMode(encId, mode) {
|
|
1151
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1152
|
+
enc.def.mode = mode;
|
|
1153
|
+
enc.run = freshRun(enc);
|
|
1154
|
+
save(); renderAll();
|
|
1155
|
+
toast("Structure changed — the run was reset.");
|
|
1156
|
+
}
|
|
1157
|
+
function setGoal(encId, value) {
|
|
1158
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1159
|
+
enc.def.goal = Math.max(1, parseInt(value, 10) || 1);
|
|
1160
|
+
save();
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function thresholdEditorHtml(enc) {
|
|
1164
|
+
return '<div class="fld"><label class="flabel">Thresholds ('
|
|
1165
|
+
+ esc(thresholdUnit(enc)) + ')</label>'
|
|
1166
|
+
+ (enc.def.thresholds || []).map((t, i) =>
|
|
1167
|
+
'<div class="obrow"><input class="inp num" type="number" value="' + t.at
|
|
1168
|
+
+ '" oninput="setThreshold(\'' + enc.id + "'," + i + ',\'at\',this.value)">'
|
|
1169
|
+
+ '<input class="inp" placeholder="What it is" value="' + esc(t.label)
|
|
1170
|
+
+ '" oninput="setThreshold(\'' + enc.id + "'," + i + ',\'label\',this.value)">'
|
|
1171
|
+
+ '<input class="inp" placeholder="What it grants" value="' + esc(t.effect)
|
|
1172
|
+
+ '" oninput="setThreshold(\'' + enc.id + "'," + i + ',\'effect\',this.value)">'
|
|
1173
|
+
+ '<button class="chipbtn danger" onclick="removeThreshold(\'' + enc.id + "'," + i
|
|
1174
|
+
+ ')">×</button></div>').join("")
|
|
1175
|
+
+ '<div class="rowbtns"><button class="btn ghost" onclick="addThreshold(\'' + enc.id
|
|
1176
|
+
+ '\')">Add a threshold</button></div></div>';
|
|
1177
|
+
}
|
|
1178
|
+
function setThreshold(encId, i, key, value) {
|
|
1179
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1180
|
+
enc.def.thresholds[i][key] = key === "at" ? (parseInt(value, 10) || 0) : value;
|
|
1181
|
+
save();
|
|
1182
|
+
}
|
|
1183
|
+
function addThreshold(encId) {
|
|
1184
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1185
|
+
enc.def.thresholds.push({ at: 5, label: "", effect: "" });
|
|
1186
|
+
save(); renderAll();
|
|
1187
|
+
}
|
|
1188
|
+
function removeThreshold(encId, i) {
|
|
1189
|
+
const enc = library.encounters.find((e) => e.id === encId);
|
|
1190
|
+
enc.def.thresholds.splice(i, 1);
|
|
1191
|
+
save(); renderAll();
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/* ============================================================
|
|
1195
|
+
REFERENCE
|
|
1196
|
+
============================================================ */
|
|
1197
|
+
let refFilter = "";
|
|
1198
|
+
|
|
1199
|
+
function referenceHtml() {
|
|
1200
|
+
const meta = GENERATED_SUB_META;
|
|
1201
|
+
return '<section class="card"><h2>How these work</h2>'
|
|
1202
|
+
+ '<p class="dim">Paizo\'s own text, generated from the open Foundry pf2e data '
|
|
1203
|
+
+ "(" + esc(meta.source) + " " + esc(meta.sourceCommit || meta.sourceBranch)
|
|
1204
|
+
+ ", " + esc(meta.generated) + "), so it can't drift from the book.</p>"
|
|
1205
|
+
+ SUBSYSTEM_ORDER.concat(["reputation", "duels", "leadership", "hexploration"])
|
|
1206
|
+
.map((k) => {
|
|
1207
|
+
const sub = SUBSYSTEMS[k];
|
|
1208
|
+
const page = SUB_PAGES[sub ? sub.rulesPage : k];
|
|
1209
|
+
if (!page) return "";
|
|
1210
|
+
return '<details class="ref"><summary>' + esc(page.name)
|
|
1211
|
+
+ (sub ? "" : ' <span class="dim">· reference only</span>')
|
|
1212
|
+
+ '<span class="cite">' + esc(page.book) + " pg. " + esc(page.pages) + "</span></summary>"
|
|
1213
|
+
+ '<div class="refbody">' + inlineActions(page.html) + "</div></details>";
|
|
1214
|
+
}).join("")
|
|
1215
|
+
+ "</section>" + lookupHtml();
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/* The subsystem pages name actions rather than repeating them; splice the
|
|
1219
|
+
action's own text in where the marker sits. */
|
|
1220
|
+
function inlineActions(html) {
|
|
1221
|
+
return html.replace(/<span class="actref" data-action="([^"]+)">([^<]*)<\/span>/g,
|
|
1222
|
+
(m, slug, name) => {
|
|
1223
|
+
const a = ACTIONS_BY_SLUG[slug];
|
|
1224
|
+
if (!a) return "<b>" + esc(name) + "</b>";
|
|
1225
|
+
return "<b>" + esc(a.name) + "</b>" + actionMeta(a)
|
|
1226
|
+
+ '<div class="actbody">' + esc(a.description).replace(/\n+/g, "<br>") + "</div>";
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
function actionMeta(a) {
|
|
1230
|
+
const bits = [];
|
|
1231
|
+
if (a.actions) bits.push(a.actions + " " + plural(a.actions, "action"));
|
|
1232
|
+
if (a.actionType && a.actionType !== "action") bits.push(a.actionType);
|
|
1233
|
+
(a.traits || []).forEach((t) => bits.push(t));
|
|
1234
|
+
return bits.length ? ' <span class="dim">(' + esc(bits.join(", ")) + ")</span>" : "";
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function lookupHtml() {
|
|
1238
|
+
const t = refFilter.trim().toLowerCase();
|
|
1239
|
+
const match = (x) => !t || x.name.toLowerCase().indexOf(t) !== -1;
|
|
1240
|
+
const conds = CONDITIONS.filter(match);
|
|
1241
|
+
const acts = ACTIONS.filter(match);
|
|
1242
|
+
const card = (x, kind) => '<details class="ref"><summary>' + esc(x.name)
|
|
1243
|
+
+ '<span class="cite">' + kind + "</span></summary>"
|
|
1244
|
+
+ '<div class="refbody">' + esc(x.description || "").replace(/\n+/g, "<br>")
|
|
1245
|
+
+ "</div></details>";
|
|
1246
|
+
return '<section class="card"><h2>Conditions and actions</h2>'
|
|
1247
|
+
+ '<input id="refFilter" class="inp" placeholder="Search ' + CONDITIONS.length
|
|
1248
|
+
+ " conditions and " + ACTIONS.length + ' actions" value="' + esc(refFilter) + '">'
|
|
1249
|
+
+ (t ? conds.map((c) => card(c, "condition")).join("")
|
|
1250
|
+
+ acts.map((a) => card(a, "action")).join("")
|
|
1251
|
+
: '<p class="dim">Type to search.</p>')
|
|
1252
|
+
+ "</section>";
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/* ============================================================
|
|
1256
|
+
MENU
|
|
1257
|
+
============================================================ */
|
|
1258
|
+
function menuHtml() {
|
|
1259
|
+
const s = library.settings;
|
|
1260
|
+
const meta = GENERATED_SUB_META;
|
|
1261
|
+
return '<section class="card"><div class="row spread"><h2>Settings</h2>'
|
|
1262
|
+
+ '<button class="btn ghost" onclick="closeMenu()">Done</button></div>'
|
|
1263
|
+
+ field("Theme", ["auto", "light", "dark"].map((m) =>
|
|
1264
|
+
'<label class="radio"><input type="radio" name="theme" value="' + m + '"'
|
|
1265
|
+
+ ((s.themeMode || "auto") === m ? " checked" : "")
|
|
1266
|
+
+ ' onchange="setThemeMode(\'' + m + '\')"> ' + m + "</label>").join(""))
|
|
1267
|
+
+ field("Accent", '<input class="inp" type="color" value="'
|
|
1268
|
+
+ esc((s.custom && s.custom.accent) || "#a87c4a")
|
|
1269
|
+
+ '" oninput="setAccent(this.value)"> '
|
|
1270
|
+
+ '<button class="btn ghost" onclick="resetTheme()">Reset</button>')
|
|
1271
|
+
+ '<div class="note">Everything you type stays in this browser, on this device. '
|
|
1272
|
+
+ "There is no account and no server. Use an encounter's <b>Code</b> button to "
|
|
1273
|
+
+ "move prep to another device.</div>"
|
|
1274
|
+
+ '<div class="note dim">Rules text: ' + esc(meta.source) + " @ "
|
|
1275
|
+
+ esc(meta.sourceCommit || "?") + " (" + esc(meta.sourceBranch) + "), built "
|
|
1276
|
+
+ esc(meta.generated) + ". " + esc(meta.pages) + " subsystem pages, "
|
|
1277
|
+
+ esc(meta.obstacles) + " sample obstacles. "
|
|
1278
|
+
+ meta.sources.map((x) => esc(x.title) + " (" + esc(x.license) + ")").join(", ")
|
|
1279
|
+
+ ".</div></section>";
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/* ============================================================
|
|
1283
|
+
BOOT
|
|
1284
|
+
============================================================ */
|
|
1285
|
+
function empty(title, body) {
|
|
1286
|
+
return '<section class="card empty"><h2>' + esc(title) + "</h2><p>" + body + "</p></section>";
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/* Re-renders blow away focus, so the two free-typing inputs are rewired by
|
|
1290
|
+
hand rather than re-rendered on every keystroke. */
|
|
1291
|
+
function wireInputs() {
|
|
1292
|
+
const f = byId("refFilter");
|
|
1293
|
+
if (f) {
|
|
1294
|
+
f.oninput = () => {
|
|
1295
|
+
refFilter = f.value;
|
|
1296
|
+
const pos = f.selectionStart;
|
|
1297
|
+
byId("views").innerHTML = referenceHtml();
|
|
1298
|
+
wireInputs();
|
|
1299
|
+
const again = byId("refFilter");
|
|
1300
|
+
if (again) { again.focus(); again.setSelectionRange(pos, pos); }
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function boot() {
|
|
1306
|
+
load();
|
|
1307
|
+
applyTheme();
|
|
1308
|
+
const icon = byId("titleIcon");
|
|
1309
|
+
if (icon) {
|
|
1310
|
+
icon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
|
1311
|
+
+ 'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
|
|
1312
|
+
+ '<path d="M3 20h18"/><path d="M6 20v-4"/><path d="M11 20v-8"/>'
|
|
1313
|
+
+ '<path d="M16 20v-12"/><path d="M3 7h18" stroke-dasharray="2.5 2.5"/></svg>';
|
|
1314
|
+
}
|
|
1315
|
+
current = library.encounters.length ? "run" : "prep";
|
|
1316
|
+
renderAll();
|
|
1317
|
+
if ("serviceWorker" in navigator && location.protocol.indexOf("http") === 0) {
|
|
1318
|
+
navigator.serviceWorker.register("sw.js").catch(() => {});
|
|
1319
|
+
}
|
|
1320
|
+
}
|