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.
- package/LICENSE +31 -0
- package/README.md +106 -0
- package/bin/cli.mjs +86 -0
- package/data/reference.generated.js +6 -0
- package/dist/icon.svg +6 -0
- package/dist/index.html +2275 -0
- package/dist/manifest.webmanifest +13 -0
- package/dist/sw.js +60 -0
- package/notice.md +124 -0
- package/package.json +47 -0
- package/src/content.js +547 -0
- package/src/engine.js +1210 -0
- package/src/icon.svg +6 -0
- package/src/manifest.webmanifest +13 -0
- package/src/styles.css +445 -0
- package/src/sw.js +60 -0
- package/src/template.html +67 -0
- package/tools/build.py +60 -0
- package/tools/build_reference.py +249 -0
- package/tools/test.mjs +277 -0
package/src/engine.js
ADDED
|
@@ -0,0 +1,1210 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
ENGINE — renders CHAPTERS from content.js, drives the demos,
|
|
3
|
+
and searches the Foundry-derived rules text. No framework,
|
|
4
|
+
no build step to use: the whole app is one file.
|
|
5
|
+
============================================================ */
|
|
6
|
+
|
|
7
|
+
const REF_META = (typeof GENERATED_REF_META !== "undefined") ? GENERATED_REF_META : {};
|
|
8
|
+
const CONDITIONS = (typeof GENERATED_CONDITIONS !== "undefined") ? GENERATED_CONDITIONS : [];
|
|
9
|
+
const ACTIONS = (typeof GENERATED_ACTIONS !== "undefined") ? GENERATED_ACTIONS : [];
|
|
10
|
+
const CONDITION_BY_SLUG = {}; CONDITIONS.forEach((c) => { CONDITION_BY_SLUG[c.slug] = c; });
|
|
11
|
+
const ACTION_BY_SLUG = {}; ACTIONS.forEach((a) => { ACTION_BY_SLUG[a.slug] = a; });
|
|
12
|
+
const PC_BY_ID = {}; PREGENS.forEach((p) => { PC_BY_ID[p.id] = p; });
|
|
13
|
+
|
|
14
|
+
/* ============================================================
|
|
15
|
+
ICONS — one stroke path per name, inheriting the accent.
|
|
16
|
+
============================================================ */
|
|
17
|
+
const ICONS = {
|
|
18
|
+
compass: '<circle cx="12" cy="12" r="9"/><path d="M15.5 8.5 13 13l-4.5 2.5L11 11z"/>',
|
|
19
|
+
compass2: '<path d="M12 3v2M12 19v2M3 12h2M19 12h2"/><circle cx="12" cy="12" r="7"/><path d="M9 15l2-4 4-2-2 4z"/>',
|
|
20
|
+
sword: '<path d="M14.5 3.5 20 9l-8.5 8.5-2.5-.5-.5-2.5z"/><path d="M6 15l3 3"/><path d="M4.5 20.5 7 18"/><path d="M3.5 16.5 7.5 20.5"/>',
|
|
21
|
+
speech: '<path d="M20 12.5c0 3.6-3.6 6.5-8 6.5a9.8 9.8 0 0 1-2.6-.34L5 20.5l1.2-3A6.6 6.6 0 0 1 4 12.5C4 8.9 7.6 6 12 6s8 2.9 8 6.5z"/>',
|
|
22
|
+
anchor: '<circle cx="12" cy="5" r="2"/><path d="M12 7v13"/><path d="M8 11h8"/><path d="M5 14a7 7 0 0 0 14 0"/>',
|
|
23
|
+
cards: '<rect x="3.5" y="6" width="12" height="14" rx="2"/><path d="M8 3.5h8.5a2 2 0 0 1 2 2V16"/>',
|
|
24
|
+
menu: '<circle cx="12" cy="8" r="3.4"/><path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>',
|
|
25
|
+
roll: '<rect x="4" y="4" width="16" height="16" rx="3"/><circle cx="8.5" cy="8.5" r="1.1" fill="currentColor" stroke="none"/><circle cx="15.5" cy="15.5" r="1.1" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.1" fill="currentColor" stroke="none"/>',
|
|
26
|
+
d20: '<path d="M12 2.8 20 7.4v9.2L12 21.2 4 16.6V7.4z"/><path d="M12 2.8 8 12l4 9.2L16 12z"/><path d="M4 7.4 8 12h8l4-4.6"/>',
|
|
27
|
+
check: '<path d="M5 12.5 10 17.5 19 7"/>',
|
|
28
|
+
x: '<path d="M6 6l12 12M18 6 6 18"/>',
|
|
29
|
+
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
30
|
+
minus: '<path d="M5 12h14"/>',
|
|
31
|
+
chevron: '<path d="M6 9l6 6 6-6"/>',
|
|
32
|
+
arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
|
33
|
+
up: '<path d="M12 19V5M6 11l6-6 6 6"/>',
|
|
34
|
+
down: '<path d="M12 5v14M6 13l6 6 6-6"/>',
|
|
35
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>',
|
|
36
|
+
eye: '<path d="M2.5 12S6 6 12 6s9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6z"/><circle cx="12" cy="12" r="2.4"/>',
|
|
37
|
+
secret: '<path d="M3 3l18 18"/><path d="M10.6 6.3A8.7 8.7 0 0 1 12 6c6 0 9.5 6 9.5 6a15 15 0 0 1-3.3 3.8M7 7.6A15 15 0 0 0 2.5 12S6 18 12 18a8.8 8.8 0 0 0 3.4-.68"/>',
|
|
38
|
+
heart: '<path d="M12 20s-7-4.4-7-9.5A3.8 3.8 0 0 1 12 8a3.8 3.8 0 0 1 7 2.5C19 15.6 12 20 12 20z"/>',
|
|
39
|
+
star: '<path d="M12 3.5l2.5 5.3 5.5.7-4 4 1 5.6L12 16.5 6.9 19l1-5.6-4-4 5.5-.7z"/>',
|
|
40
|
+
shield: '<path d="M12 3 5 5.5v5.5c0 4 2.9 7 7 8.5 4.1-1.5 7-4.5 7-8.5V5.5z"/>',
|
|
41
|
+
book: '<path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5z"/><path d="M5 19.5A1.5 1.5 0 0 1 6.5 18H19v3H6.5A1.5 1.5 0 0 1 5 19.5z"/>',
|
|
42
|
+
install: '<path d="M12 3v11M8 10l4 4 4-4"/><path d="M5 20h14"/>',
|
|
43
|
+
reset: '<path d="M4 12a8 8 0 1 1 2.5 5.8"/><path d="M4 19v-5h5"/>',
|
|
44
|
+
link: '<path d="M10 13a4 4 0 0 0 5.7 0l2.6-2.6a4 4 0 0 0-5.7-5.7L11.2 6"/><path d="M14 11a4 4 0 0 0-5.7 0l-2.6 2.6a4 4 0 0 0 5.7 5.7L12.8 18"/>',
|
|
45
|
+
};
|
|
46
|
+
function iconSvg(name, cls) {
|
|
47
|
+
const p = ICONS[name] || "";
|
|
48
|
+
return `<svg class="icn${cls ? " " + cls : ""}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${p}</svg>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* ============================================================
|
|
52
|
+
STATE — settings and reading progress, in this browser only.
|
|
53
|
+
============================================================ */
|
|
54
|
+
const LS_KEY = "pf2ePrimer.v1";
|
|
55
|
+
let state = loadState();
|
|
56
|
+
|
|
57
|
+
function defaultState() {
|
|
58
|
+
return { settings: { themeMode: "auto", custom: null, gm: false }, done: {} };
|
|
59
|
+
}
|
|
60
|
+
function loadState() {
|
|
61
|
+
try {
|
|
62
|
+
const raw = JSON.parse(localStorage.getItem(LS_KEY));
|
|
63
|
+
if (!raw || typeof raw !== "object") return defaultState();
|
|
64
|
+
const d = defaultState();
|
|
65
|
+
return {
|
|
66
|
+
settings: Object.assign(d.settings, raw.settings || {}),
|
|
67
|
+
done: raw.done && typeof raw.done === "object" ? raw.done : {},
|
|
68
|
+
};
|
|
69
|
+
} catch (e) { return defaultState(); }
|
|
70
|
+
}
|
|
71
|
+
let _storageWarned = false;
|
|
72
|
+
function saveState() {
|
|
73
|
+
try { localStorage.setItem(LS_KEY, JSON.stringify(state)); }
|
|
74
|
+
catch (e) { if (!_storageWarned) { _storageWarned = true; toast("Couldn't save progress in this browser"); } }
|
|
75
|
+
}
|
|
76
|
+
function gmMode() { return !!state.settings.gm; }
|
|
77
|
+
function setGmMode(on) {
|
|
78
|
+
state.settings.gm = !!on; saveState();
|
|
79
|
+
document.body.classList.toggle("gmon", gmMode());
|
|
80
|
+
renderHeader(); renderAll();
|
|
81
|
+
toast(gmMode() ? "GM mode on — table notes shown" : "GM mode off");
|
|
82
|
+
}
|
|
83
|
+
function toggleGmMode() { setGmMode(!gmMode()); }
|
|
84
|
+
|
|
85
|
+
/* ============================================================
|
|
86
|
+
SMALL HELPERS
|
|
87
|
+
============================================================ */
|
|
88
|
+
function sign(n) { n = Number(n) || 0; return (n >= 0 ? "+" : "") + n; }
|
|
89
|
+
function escapeHtml(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
|
|
90
|
+
function textToHtml(s) { return escapeHtml(s).replace(/\n/g, "<br>"); }
|
|
91
|
+
function titleCase(s) { s = String(s || ""); return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; }
|
|
92
|
+
/* Skill keys are lower-case ("sailing lore"); every word gets a capital. */
|
|
93
|
+
function skillLabel(s) { return String(s || "").replace(/(^|\s)([a-z])/g, (m, sp, c) => sp + c.toUpperCase()); }
|
|
94
|
+
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
|
|
95
|
+
function byId(id) { return document.getElementById(id); }
|
|
96
|
+
function setHTML(id, html) { const el = byId(id); if (el) el.innerHTML = html; }
|
|
97
|
+
|
|
98
|
+
/* ============================================================
|
|
99
|
+
DICE & DEGREES OF SUCCESS — the one mechanic, in code.
|
|
100
|
+
============================================================ */
|
|
101
|
+
function d20() { return 1 + Math.floor(Math.random() * 20); }
|
|
102
|
+
function dN(n) { return 1 + Math.floor(Math.random() * n); }
|
|
103
|
+
|
|
104
|
+
/* 0 critical failure · 1 failure · 2 success · 3 critical success */
|
|
105
|
+
const DEG_LABEL = ["Critical failure", "Failure", "Success", "Critical success"];
|
|
106
|
+
const DEG_CLASS = ["cfail", "fail", "succ", "csucc"];
|
|
107
|
+
|
|
108
|
+
/* The whole resolution rule: ten over or ten under moves a step,
|
|
109
|
+
then a natural 20 or 1 moves one more. */
|
|
110
|
+
function degreeOf(total, dc, nat) {
|
|
111
|
+
let step = total >= dc + 10 ? 3 : total >= dc ? 2 : total <= dc - 10 ? 0 : 1;
|
|
112
|
+
if (nat === 20) step = Math.min(3, step + 1);
|
|
113
|
+
else if (nat === 1) step = Math.max(0, step - 1);
|
|
114
|
+
return step;
|
|
115
|
+
}
|
|
116
|
+
/* Why it came out that way, in words — the point of the whole demo. */
|
|
117
|
+
function degreeReason(total, dc, nat) {
|
|
118
|
+
const base = total >= dc + 10 ? 3 : total >= dc ? 2 : total <= dc - 10 ? 0 : 1;
|
|
119
|
+
const by = total - dc;
|
|
120
|
+
let why;
|
|
121
|
+
if (base === 3) why = `beat DC ${dc} by ${by} — ten or more over is a critical success`;
|
|
122
|
+
else if (base === 2) why = `beat DC ${dc} by ${by}`;
|
|
123
|
+
else if (base === 1) why = `missed DC ${dc} by ${Math.abs(by)}`;
|
|
124
|
+
else why = `missed DC ${dc} by ${Math.abs(by)} — ten or more under is a critical failure`;
|
|
125
|
+
if (nat === 20 && base < 3) why += `; natural 20 moves it one step better`;
|
|
126
|
+
else if (nat === 20) why += `; already a critical success, the natural 20 adds nothing`;
|
|
127
|
+
else if (nat === 1 && base > 0) why += `; natural 1 moves it one step worse`;
|
|
128
|
+
else if (nat === 1) why += `; already a critical failure, the natural 1 adds nothing`;
|
|
129
|
+
return why;
|
|
130
|
+
}
|
|
131
|
+
function rollCheck(mod, dc) {
|
|
132
|
+
const nat = d20(), total = nat + mod;
|
|
133
|
+
const deg = degreeOf(total, dc, nat);
|
|
134
|
+
return { nat, mod, total, dc, deg, why: degreeReason(total, dc, nat) };
|
|
135
|
+
}
|
|
136
|
+
/* "1d8+4" / "2d6" -> {total, detail} */
|
|
137
|
+
function rollDamage(expr, bonusDmg) {
|
|
138
|
+
const m = /^(\d+)d(\d+)\s*([+-]\s*\d+)?$/.exec(String(expr).trim());
|
|
139
|
+
if (!m) return { total: 0, detail: expr };
|
|
140
|
+
const n = Number(m[1]), faces = Number(m[2]);
|
|
141
|
+
const flat = m[3] ? Number(m[3].replace(/\s+/g, "")) : 0;
|
|
142
|
+
const extra = Number(bonusDmg) || 0;
|
|
143
|
+
const dice = []; let sum = 0;
|
|
144
|
+
for (let i = 0; i < n; i++) { const r = dN(faces); dice.push(r); sum += r; }
|
|
145
|
+
const total = sum + flat + extra;
|
|
146
|
+
const parts = [`${n}d${faces} (${dice.join("+")})`];
|
|
147
|
+
if (flat) parts.push(sign(flat).replace("+", "+ ").replace("-", "− "));
|
|
148
|
+
if (extra) parts.push(`+ ${extra} status`);
|
|
149
|
+
return { total, detail: `${parts.join(" ")} = ${total}` };
|
|
150
|
+
}
|
|
151
|
+
function degChip(deg) { return `<span class="deg ${DEG_CLASS[deg]}">${DEG_LABEL[deg]}</span>`; }
|
|
152
|
+
function natSpan(nat) {
|
|
153
|
+
const cls = nat === 20 ? "nat nat20" : nat === 1 ? "nat nat1" : "nat";
|
|
154
|
+
return `<span class="${cls}">${nat}</span>`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ============================================================
|
|
158
|
+
NAVIGATION
|
|
159
|
+
============================================================ */
|
|
160
|
+
const VIEWS = CHAPTERS.map((c) => c.key);
|
|
161
|
+
let _view = VIEWS[0];
|
|
162
|
+
|
|
163
|
+
function go(key) {
|
|
164
|
+
if (key === "menu") return openMenu();
|
|
165
|
+
if (VIEWS.indexOf(key) === -1) key = VIEWS[0];
|
|
166
|
+
_view = key;
|
|
167
|
+
byId("view-menu").classList.add("hide");
|
|
168
|
+
VIEWS.forEach((k) => {
|
|
169
|
+
byId("view-" + k).classList.toggle("hide", k !== key);
|
|
170
|
+
const b = byId("nav-" + k); if (b) b.classList.toggle("on", k === key);
|
|
171
|
+
});
|
|
172
|
+
render(key);
|
|
173
|
+
window.scrollTo(0, 0);
|
|
174
|
+
}
|
|
175
|
+
function currentView() { return byId("view-menu").classList.contains("hide") ? _view : "menu"; }
|
|
176
|
+
function render(key) {
|
|
177
|
+
const ch = CHAPTERS.find((c) => c.key === key);
|
|
178
|
+
if (ch) setHTML("view-" + key, chapterHTML(ch));
|
|
179
|
+
mountDemos(key);
|
|
180
|
+
}
|
|
181
|
+
function renderAll() { renderHeader(); const v = currentView(); if (v === "menu") renderMenu(); else render(v); }
|
|
182
|
+
function openMenu() {
|
|
183
|
+
VIEWS.forEach((k) => byId("view-" + k).classList.add("hide"));
|
|
184
|
+
byId("view-menu").classList.remove("hide");
|
|
185
|
+
renderMenu(); window.scrollTo(0, 0);
|
|
186
|
+
}
|
|
187
|
+
function closeMenu() { go(_view); }
|
|
188
|
+
let _toastTimer = null;
|
|
189
|
+
function toast(msg) {
|
|
190
|
+
const t = byId("toast"); if (!t) return;
|
|
191
|
+
t.textContent = msg; t.classList.add("show");
|
|
192
|
+
clearTimeout(_toastTimer); _toastTimer = setTimeout(() => t.classList.remove("show"), 1800);
|
|
193
|
+
}
|
|
194
|
+
function renderHeader() {
|
|
195
|
+
const done = CHAPTERS.filter((c) => state.done[c.key]).length;
|
|
196
|
+
const pct = Math.round((done / CHAPTERS.length) * 100);
|
|
197
|
+
setHTML("progressBar", `<span style="width:${pct}%"></span>`);
|
|
198
|
+
const sub = byId("headSub");
|
|
199
|
+
if (sub) sub.innerHTML = gmMode()
|
|
200
|
+
? `<b>GM mode</b> — table notes are showing · ${done}/${CHAPTERS.length} tabs read`
|
|
201
|
+
: `Pathfinder 2e (Remaster), for people who've played something else · ${done}/${CHAPTERS.length} tabs read`;
|
|
202
|
+
const gb = byId("gmBtn");
|
|
203
|
+
if (gb) { gb.classList.toggle("on", gmMode()); gb.title = gmMode() ? "GM mode on" : "GM mode off"; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ============================================================
|
|
207
|
+
CHAPTER RENDERING
|
|
208
|
+
============================================================ */
|
|
209
|
+
function chapterHTML(ch) {
|
|
210
|
+
const body = ch.sections.map((s) => sectionHTML(s, ch)).join("");
|
|
211
|
+
const idx = VIEWS.indexOf(ch.key);
|
|
212
|
+
const next = CHAPTERS[idx + 1];
|
|
213
|
+
const isDone = !!state.done[ch.key];
|
|
214
|
+
const foot = `
|
|
215
|
+
<div class="chapfoot">
|
|
216
|
+
<button class="btn ${isDone ? "secondary" : ""}" onclick="markRead('${ch.key}')">
|
|
217
|
+
${iconSvg(isDone ? "check" : "check")} ${isDone ? "Read" : "Got it"}${next ? " — on to " + escapeHtml(next.label) : ""}
|
|
218
|
+
</button>
|
|
219
|
+
${next ? `<p class="meta center">Next: <b>${escapeHtml(next.title)}</b></p>` : `<p class="meta center">That's the lot. Go and play.</p>`}
|
|
220
|
+
</div>`;
|
|
221
|
+
return `
|
|
222
|
+
<div class="chaphead">
|
|
223
|
+
<h1>${iconSvg(ch.icon)} ${escapeHtml(ch.title)}</h1>
|
|
224
|
+
<p class="lede">${escapeHtml(ch.lede)}</p>
|
|
225
|
+
</div>
|
|
226
|
+
${body}${foot}`;
|
|
227
|
+
}
|
|
228
|
+
function markRead(key) {
|
|
229
|
+
state.done[key] = true; saveState(); renderHeader();
|
|
230
|
+
const idx = VIEWS.indexOf(key);
|
|
231
|
+
const next = CHAPTERS[idx + 1];
|
|
232
|
+
if (next) go(next.key); else { render(key); toast("Primer finished"); }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sectionHTML(s, ch) {
|
|
236
|
+
switch (s.type) {
|
|
237
|
+
case "prose": return proseHTML(s);
|
|
238
|
+
case "note": return noteHTML(s);
|
|
239
|
+
case "bridge": return bridgeHTML(s);
|
|
240
|
+
case "map": return mapHTML(s);
|
|
241
|
+
case "cards": return cardsHTML(s);
|
|
242
|
+
case "steps": return stepsHTML(s);
|
|
243
|
+
case "demo": return demoShellHTML(s);
|
|
244
|
+
case "ref": return refHTML(s);
|
|
245
|
+
case "gm": return gmHTML(s);
|
|
246
|
+
case "links": return linksHTML();
|
|
247
|
+
case "quickcard": return quickCardHTML();
|
|
248
|
+
case "glossary": return glossaryHTML(s);
|
|
249
|
+
case "browser": return browserShellHTML();
|
|
250
|
+
case "pregens": return pregensHTML(s);
|
|
251
|
+
case "reactions": return annotatedRefHTML(s, REACTIONS);
|
|
252
|
+
case "stops": return annotatedRefHTML(s, EXPLORATION_STOPS);
|
|
253
|
+
default: return "";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function h2(s) { return s.h ? `<h2>${escapeHtml(s.h)}</h2>` : ""; }
|
|
257
|
+
function proseHTML(s) {
|
|
258
|
+
return `<section class="sec">${h2(s)}${(s.p || []).map((p) => `<p>${p}</p>`).join("")}</section>`;
|
|
259
|
+
}
|
|
260
|
+
function noteHTML(s) {
|
|
261
|
+
return `<aside class="callout ${s.tone === "warn" ? "warn" : "tip"}">${(s.p || []).map((p) => `<p>${p}</p>`).join("")}</aside>`;
|
|
262
|
+
}
|
|
263
|
+
function bridgeHTML(s) {
|
|
264
|
+
const rows = BRIDGE.map(([a, b]) => `
|
|
265
|
+
<div class="brow">
|
|
266
|
+
<div class="bfrom">${escapeHtml(a)}</div>
|
|
267
|
+
<div class="barrow">${iconSvg("arrow")}</div>
|
|
268
|
+
<div class="bto">${b}</div>
|
|
269
|
+
</div>`).join("");
|
|
270
|
+
return `<section class="sec">${h2(s)}<div class="bridge">${rows}</div></section>`;
|
|
271
|
+
}
|
|
272
|
+
function mapHTML(s) {
|
|
273
|
+
const cols = [
|
|
274
|
+
{ pillar: "Fighting", mode: "Encounter mode", grain: "6-second rounds", note: "Initiative, three actions a turn, a grid if you like one.", tab: "fight" },
|
|
275
|
+
{ pillar: "Exploring", mode: "Exploration mode", grain: "minutes & hours", note: "Activities instead of actions. The GM rolls some of your dice.", tab: "explore" },
|
|
276
|
+
{ pillar: "Talking", mode: "any mode", grain: "however long it takes", note: "No gear of its own — a parley mid-fight is still encounter mode.", tab: "talk" },
|
|
277
|
+
];
|
|
278
|
+
return `<section class="sec">${h2(s)}
|
|
279
|
+
<div class="pmap">
|
|
280
|
+
${cols.map((c) => `
|
|
281
|
+
<button class="pcol" onclick="go('${c.tab}')">
|
|
282
|
+
<span class="ppillar">${escapeHtml(c.pillar)}</span>
|
|
283
|
+
<span class="pmode">${escapeHtml(c.mode)}</span>
|
|
284
|
+
<span class="pgrain">${escapeHtml(c.grain)}</span>
|
|
285
|
+
<span class="pnote">${escapeHtml(c.note)}</span>
|
|
286
|
+
</button>`).join("")}
|
|
287
|
+
</div>
|
|
288
|
+
<button class="pcol wide" onclick="go('ashore')">
|
|
289
|
+
<span class="ppillar">Everything else</span>
|
|
290
|
+
<span class="pmode">Downtime mode</span>
|
|
291
|
+
<span class="pgrain">days & weeks</span>
|
|
292
|
+
<span class="pnote">The gear your last game probably didn't have: earning a living, crafting, and un-picking a build you regret.</span>
|
|
293
|
+
</button>
|
|
294
|
+
</section>`;
|
|
295
|
+
}
|
|
296
|
+
function cardsHTML(s) {
|
|
297
|
+
return `<section class="sec">${h2(s)}
|
|
298
|
+
<div class="cardgrid">
|
|
299
|
+
${(s.items || []).map((i) => `<div class="ccard"><h3>${i.h}</h3><p>${i.p}</p></div>`).join("")}
|
|
300
|
+
</div></section>`;
|
|
301
|
+
}
|
|
302
|
+
function stepsHTML(s) {
|
|
303
|
+
return `<section class="sec">${h2(s)}
|
|
304
|
+
<ol class="steps">${(s.items || []).map((i) => `<li>${i}</li>`).join("")}</ol></section>`;
|
|
305
|
+
}
|
|
306
|
+
function gmHTML(s) {
|
|
307
|
+
return `<section class="sec gmsec">
|
|
308
|
+
<div class="gmhead">${iconSvg("eye")} ${escapeHtml(s.h || "At the table")}</div>
|
|
309
|
+
${(s.p || []).map((p) => `<p>${p}</p>`).join("")}
|
|
310
|
+
</section>`;
|
|
311
|
+
}
|
|
312
|
+
function linksHTML() {
|
|
313
|
+
/* Relative on purpose. On GitHub Pages this page is /pf2eprimer/, so "../pf2epartytracker/"
|
|
314
|
+
lands on the sibling; self-hosted in the PF2e Toolbox the same path is redirected to
|
|
315
|
+
/tracker/. Absolute GitHub URLs would send someone reading this offline, on a boat,
|
|
316
|
+
out to the internet for a tool sitting on the same server. */
|
|
317
|
+
const tools = [
|
|
318
|
+
{ name: "PF2e Party Tracker", url: "../pf2epartytracker/",
|
|
319
|
+
note: "GM-side: everyone's passive DCs on one screen, secret checks rolled for the whole party at once, and the exploration activity board from this tab." },
|
|
320
|
+
{ name: "PF2e Spellbook", url: "../pf2espellcards/",
|
|
321
|
+
note: "Player-side: prepare the day's spells, track castings and focus points, and see what a spell actually does at the rank you're casting it." },
|
|
322
|
+
];
|
|
323
|
+
return `<section class="sec">
|
|
324
|
+
<h2>The other two tools</h2>
|
|
325
|
+
<p class="meta">Same idea as this page — one file, works offline, nothing leaves your device.</p>
|
|
326
|
+
${tools.map((t) => `
|
|
327
|
+
<a class="toolcard" href="${t.url}" target="_blank" rel="noopener">
|
|
328
|
+
<span class="tname">${iconSvg("link")} ${escapeHtml(t.name)}</span>
|
|
329
|
+
<span class="tnote">${escapeHtml(t.note)}</span>
|
|
330
|
+
</a>`).join("")}
|
|
331
|
+
</section>`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* ---- The two pregens, with the arithmetic left in ---- */
|
|
335
|
+
function pregensHTML(s) {
|
|
336
|
+
return `<section class="sec">${h2(s)}
|
|
337
|
+
<div class="cardgrid">${PREGENS.map((pc) => {
|
|
338
|
+
const topSkills = Object.keys(pc.skills).slice(0, 4)
|
|
339
|
+
.map((k) => `${skillLabel(k)} ${sign(pc.skills[k])}`).join(" · ");
|
|
340
|
+
return `<div class="ccard pcard">
|
|
341
|
+
<h3>${escapeHtml(pc.name)}</h3>
|
|
342
|
+
<p class="meta">Level ${pc.level} ${escapeHtml(pc.ancestry)} ${escapeHtml(pc.cls)} — ${escapeHtml(pc.tag)}</p>
|
|
343
|
+
<p>${escapeHtml(pc.blurb)}</p>
|
|
344
|
+
<div class="statrow">
|
|
345
|
+
<span class="statbox"><b>${pc.hp}</b><span>HP</span></span>
|
|
346
|
+
<span class="statbox"><b>${pc.ac}</b><span>AC</span></span>
|
|
347
|
+
<span class="statbox"><b>${sign(pc.perception)}</b><span>Perception</span></span>
|
|
348
|
+
<span class="statbox"><b>${sign(pc.saves.fortitude)}/${sign(pc.saves.reflex)}/${sign(pc.saves.will)}</b><span>Fort / Ref / Will</span></span>
|
|
349
|
+
</div>
|
|
350
|
+
<p class="meta">${escapeHtml(topSkills)}${pc.spellDC ? ` · spell DC ${pc.spellDC}` : ""}</p>
|
|
351
|
+
<p class="meta"><b>AC ${pc.ac}</b> = ${escapeHtml(pc.acNote)}. <b>HP ${pc.hp}</b> = ${pc.hpParts.ancestry} ancestry + ${pc.hpParts.cls} class + ${pc.hpParts.con} Constitution.</p>
|
|
352
|
+
<p class="meta">${escapeHtml(pc.build)}</p>
|
|
353
|
+
</div>`;
|
|
354
|
+
}).join("")}</div>
|
|
355
|
+
<p class="meta">Made up for this page, and legal level-1 builds. Every modifier on them is ability + proficiency, and proficiency is your level plus the rank bonus.</p>
|
|
356
|
+
</section>`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* ---- Rules text pulled straight from the Foundry-derived data ---- */
|
|
360
|
+
function refHTML(s) {
|
|
361
|
+
const items = (s.slugs || []).map((slug) => {
|
|
362
|
+
const isCond = s.kind === "condition";
|
|
363
|
+
const rec = isCond ? CONDITION_BY_SLUG[slug] : ACTION_BY_SLUG[slug];
|
|
364
|
+
if (!rec) return "";
|
|
365
|
+
const kind = isCond ? (rec.valued ? "condition · has a value" : "condition") : actionCost(rec);
|
|
366
|
+
return `<details class="ref">
|
|
367
|
+
<summary><span class="refname">${escapeHtml(rec.name)}</span><span class="refkind">${escapeHtml(kind)}</span></summary>
|
|
368
|
+
<div class="refbody">${textToHtml(rec.description)}</div>
|
|
369
|
+
</details>`;
|
|
370
|
+
}).join("");
|
|
371
|
+
return `<section class="sec">${h2(s)}${s.p ? `<p class="meta">${s.p}</p>` : ""}${items}</section>`;
|
|
372
|
+
}
|
|
373
|
+
/* A ref list where each entry carries our own one-line note above the
|
|
374
|
+
book's text — used for the reaction and "party has stopped" lists. */
|
|
375
|
+
function annotatedRefHTML(s, list) {
|
|
376
|
+
const items = list.map((entry) => {
|
|
377
|
+
const rec = ACTION_BY_SLUG[entry.slug];
|
|
378
|
+
if (!rec) return "";
|
|
379
|
+
return `<details class="ref">
|
|
380
|
+
<summary>
|
|
381
|
+
<span class="refname">${escapeHtml(entry.label)} <span class="refnote">${escapeHtml(entry.note)}</span></span>
|
|
382
|
+
<span class="refkind">${escapeHtml(actionCost(rec))}</span>
|
|
383
|
+
</summary>
|
|
384
|
+
<div class="refbody">${textToHtml(rec.description)}</div>
|
|
385
|
+
</details>`;
|
|
386
|
+
}).join("");
|
|
387
|
+
return `<section class="sec">${h2(s)}${s.p ? `<p class="meta">${s.p}</p>` : ""}${items}</section>`;
|
|
388
|
+
}
|
|
389
|
+
function actionCost(a) {
|
|
390
|
+
const n = a.actions;
|
|
391
|
+
if (a.actionType === "reaction") return "⤳ reaction";
|
|
392
|
+
if (a.actionType === "free") return "◇ free";
|
|
393
|
+
if (n === 1) return "◆ 1 action";
|
|
394
|
+
if (n === 2) return "◆◆ 2 actions";
|
|
395
|
+
if (n === 3) return "◆◆◆ 3 actions";
|
|
396
|
+
if (a.traits && a.traits.indexOf("downtime") !== -1) return "downtime activity";
|
|
397
|
+
if (a.exploration) return "exploration activity";
|
|
398
|
+
return "activity";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* ============================================================
|
|
402
|
+
QUICK CARD & GLOSSARY
|
|
403
|
+
============================================================ */
|
|
404
|
+
function quickCardHTML() {
|
|
405
|
+
return `<section class="sec">
|
|
406
|
+
<h2>The quick card</h2>
|
|
407
|
+
<div class="qcard">
|
|
408
|
+
<div class="qbox">
|
|
409
|
+
<h3>Every roll</h3>
|
|
410
|
+
<p class="big">d20 + modifier vs DC</p>
|
|
411
|
+
<ul>
|
|
412
|
+
<li><b>Beat it by 10+</b> → critical success</li>
|
|
413
|
+
<li><b>Meet or beat it</b> → success</li>
|
|
414
|
+
<li><b>Miss by 1–9</b> → failure</li>
|
|
415
|
+
<li><b>Miss by 10+</b> → critical failure</li>
|
|
416
|
+
<li>Natural <b>20</b> moves one step better, natural <b>1</b> one step worse</li>
|
|
417
|
+
</ul>
|
|
418
|
+
</div>
|
|
419
|
+
<div class="qbox">
|
|
420
|
+
<h3>Your turn</h3>
|
|
421
|
+
<p class="big">◆ ◆ ◆ + ⤳</p>
|
|
422
|
+
<ul>
|
|
423
|
+
<li>Three actions, any mix, any order</li>
|
|
424
|
+
<li>One reaction, on someone else's turn</li>
|
|
425
|
+
<li>2nd attack <b>−5</b>, 3rd <b>−10</b> (agile <b>−4 / −8</b>)</li>
|
|
426
|
+
<li>The penalty resets at the start of your turn</li>
|
|
427
|
+
<li>Trip, Grapple, Shove and spell attacks are attacks too</li>
|
|
428
|
+
</ul>
|
|
429
|
+
</div>
|
|
430
|
+
<div class="qbox">
|
|
431
|
+
<h3>Modifiers</h3>
|
|
432
|
+
<p class="big">ability + level + rank</p>
|
|
433
|
+
<ul>
|
|
434
|
+
<li>Trained <b>+2</b> · expert <b>+4</b> · master <b>+6</b> · legendary <b>+8</b></li>
|
|
435
|
+
<li>Untrained adds <b>nothing</b> — not even your level</li>
|
|
436
|
+
<li>Any modifier becomes a DC by adding <b>10</b></li>
|
|
437
|
+
<li>Circumstance / status / item: same type doesn't stack</li>
|
|
438
|
+
</ul>
|
|
439
|
+
</div>
|
|
440
|
+
<div class="qbox">
|
|
441
|
+
<h3>Going down</h3>
|
|
442
|
+
<p class="big">dying 1 → 4 = dead</p>
|
|
443
|
+
<ul>
|
|
444
|
+
<li>0 HP: <b>dying 1</b> (dying 2 from a critical hit)</li>
|
|
445
|
+
<li>Start of your turn: flat check vs <b>DC 10 + dying</b></li>
|
|
446
|
+
<li>Success −1 dying, critical success −2, failure +1, critical failure +2</li>
|
|
447
|
+
<li>Shaking it off gives you <b>wounded 1</b> — it adds to your next dying value</li>
|
|
448
|
+
<li>Spend <b>all</b> your hero points to escape death</li>
|
|
449
|
+
</ul>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
</section>`;
|
|
453
|
+
}
|
|
454
|
+
function glossaryHTML(s) {
|
|
455
|
+
return `<section class="sec">${h2(s)}
|
|
456
|
+
<dl class="gloss">${GLOSSARY.map(([t, d]) => `<dt>${escapeHtml(t)}</dt><dd>${escapeHtml(d)}</dd>`).join("")}</dl>
|
|
457
|
+
</section>`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/* ============================================================
|
|
461
|
+
RULES BROWSER — every condition and action in the bundle.
|
|
462
|
+
============================================================ */
|
|
463
|
+
let _browseFilter = "all";
|
|
464
|
+
function browserShellHTML() {
|
|
465
|
+
return `<section class="sec">
|
|
466
|
+
<h2>Look something up</h2>
|
|
467
|
+
<p class="meta">${CONDITIONS.length} conditions and ${ACTIONS.length} actions — basic, skill, exploration and downtime — in Paizo's own words.</p>
|
|
468
|
+
<div class="searchbar">${iconSvg("search")}<input type="search" id="browseQ" placeholder="frightened, demoralize, treat wounds…" oninput="renderBrowseList()"></div>
|
|
469
|
+
<div class="chips" id="browseChips"></div>
|
|
470
|
+
<div id="browseList"></div>
|
|
471
|
+
</section>`;
|
|
472
|
+
}
|
|
473
|
+
const BROWSE_FILTERS = [
|
|
474
|
+
{ key: "all", label: "Everything" },
|
|
475
|
+
{ key: "condition", label: "Conditions" },
|
|
476
|
+
{ key: "basic", label: "Basic actions" },
|
|
477
|
+
{ key: "skill", label: "Skill actions" },
|
|
478
|
+
{ key: "exploration", label: "Exploration" },
|
|
479
|
+
{ key: "downtime", label: "Downtime" },
|
|
480
|
+
];
|
|
481
|
+
function setBrowseFilter(k) { _browseFilter = k; renderBrowseChips(); renderBrowseList(); }
|
|
482
|
+
function renderBrowseChips() {
|
|
483
|
+
setHTML("browseChips", BROWSE_FILTERS.map((f) =>
|
|
484
|
+
`<button class="chip${_browseFilter === f.key ? " on" : ""}" onclick="setBrowseFilter('${f.key}')">${escapeHtml(f.label)}</button>`).join(""));
|
|
485
|
+
}
|
|
486
|
+
function browseMatches() {
|
|
487
|
+
const q = ((byId("browseQ") || {}).value || "").trim().toLowerCase();
|
|
488
|
+
const hit = (name, desc) => !q || name.toLowerCase().indexOf(q) !== -1 || desc.toLowerCase().indexOf(q) !== -1;
|
|
489
|
+
const out = [];
|
|
490
|
+
if (_browseFilter === "all" || _browseFilter === "condition") {
|
|
491
|
+
CONDITIONS.forEach((c) => { if (hit(c.name, c.description)) out.push({ name: c.name, kind: c.valued ? "condition · has a value" : "condition", desc: c.description, sort: 0 }); });
|
|
492
|
+
}
|
|
493
|
+
ACTIONS.forEach((a) => {
|
|
494
|
+
const t = a.traits || [];
|
|
495
|
+
const isDown = t.indexOf("downtime") !== -1;
|
|
496
|
+
const isExp = !!a.exploration;
|
|
497
|
+
const cat = a.category || "";
|
|
498
|
+
let bucket = "skill";
|
|
499
|
+
if (isDown) bucket = "downtime";
|
|
500
|
+
else if (isExp) bucket = "exploration";
|
|
501
|
+
else if (cat === "basic" || cat === "interaction" || cat === "defensive" || cat === "offensive") bucket = "basic";
|
|
502
|
+
if (_browseFilter !== "all" && _browseFilter !== bucket) return;
|
|
503
|
+
if (_browseFilter === "condition") return;
|
|
504
|
+
if (hit(a.name, a.description)) out.push({ name: a.name, kind: actionCost(a), desc: a.description, sort: 1 });
|
|
505
|
+
});
|
|
506
|
+
out.sort((x, y) => x.name.localeCompare(y.name));
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
function renderBrowseList() {
|
|
510
|
+
const items = browseMatches();
|
|
511
|
+
if (!items.length) { setHTML("browseList", `<p class="empty">Nothing matches that.</p>`); return; }
|
|
512
|
+
setHTML("browseList", items.slice(0, 200).map((i) => `
|
|
513
|
+
<details class="ref">
|
|
514
|
+
<summary><span class="refname">${escapeHtml(i.name)}</span><span class="refkind">${escapeHtml(i.kind)}</span></summary>
|
|
515
|
+
<div class="refbody">${textToHtml(i.desc)}</div>
|
|
516
|
+
</details>`).join("")
|
|
517
|
+
+ (items.length > 200 ? `<p class="meta center">…and ${items.length - 200} more. Narrow the search.</p>` : ""));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/* ============================================================
|
|
521
|
+
DEMOS — shells are rendered with the chapter, then mounted.
|
|
522
|
+
============================================================ */
|
|
523
|
+
function demoShellHTML(s) {
|
|
524
|
+
return `<section class="sec demo">
|
|
525
|
+
<div class="demohead">${iconSvg("roll")}<h2>${escapeHtml(s.h || "Try it")}</h2></div>
|
|
526
|
+
${s.p ? `<p class="meta">${escapeHtml(s.p)}</p>` : ""}
|
|
527
|
+
<div id="demo-${s.id}" class="demobody"></div>
|
|
528
|
+
</section>`;
|
|
529
|
+
}
|
|
530
|
+
function mountDemos(key) {
|
|
531
|
+
const ch = CHAPTERS.find((c) => c.key === key);
|
|
532
|
+
if (!ch) return;
|
|
533
|
+
ch.sections.forEach((s) => {
|
|
534
|
+
if (s.type === "demo" && DEMOS[s.id]) DEMOS[s.id]();
|
|
535
|
+
if (s.type === "browser") { renderBrowseChips(); renderBrowseList(); }
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/* ---------------------------------------------------------------
|
|
540
|
+
1. THE CHECK — degrees of success, with the arithmetic shown.
|
|
541
|
+
--------------------------------------------------------------- */
|
|
542
|
+
let checkState = { who: "mari", what: null, dc: 15 };
|
|
543
|
+
function checkOptions(pc) {
|
|
544
|
+
const out = [];
|
|
545
|
+
out.push({ key: "perception", label: `Perception ${sign(pc.perception)}`, mod: pc.perception, rank: pc.perceptionRank });
|
|
546
|
+
Object.keys(pc.saves).forEach((k) => out.push({ key: "save-" + k, label: `${titleCase(k)} save ${sign(pc.saves[k])}`, mod: pc.saves[k], rank: pc.saveRanks[k] }));
|
|
547
|
+
Object.keys(pc.skills).forEach((k) => out.push({ key: "sk-" + k, label: `${skillLabel(k)} ${sign(pc.skills[k])}`, mod: pc.skills[k], rank: "trained" }));
|
|
548
|
+
if (pc.spellAttack) out.push({ key: "spellatk", label: `Spell attack ${sign(pc.spellAttack)}`, mod: pc.spellAttack, rank: "trained" });
|
|
549
|
+
out.push({ key: "untrained", label: `An untrained skill ${sign(0)}`, mod: 0, rank: "untrained" });
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
const DC_PRESETS = [
|
|
553
|
+
{ label: "Simple DC 15 (trained)", dc: 15 },
|
|
554
|
+
{ label: `${FOE.name}'s AC 16`, dc: 16 },
|
|
555
|
+
{ label: `${FOE.name}'s Will DC 13`, dc: 13 },
|
|
556
|
+
{ label: `${FOE.name}'s Fortitude DC 17`, dc: 17 },
|
|
557
|
+
{ label: "A hard one: DC 20", dc: 20 },
|
|
558
|
+
];
|
|
559
|
+
const DEMOS = {};
|
|
560
|
+
DEMOS.check = function () {
|
|
561
|
+
const host = byId("demo-check"); if (!host) return;
|
|
562
|
+
const pc = PC_BY_ID[checkState.who] || PREGENS[0];
|
|
563
|
+
const opts = checkOptions(pc);
|
|
564
|
+
if (!checkState.what || !opts.find((o) => o.key === checkState.what)) checkState.what = opts[0].key;
|
|
565
|
+
host.innerHTML = `
|
|
566
|
+
<div class="controls">
|
|
567
|
+
<label class="field inline"><span class="name">Who's rolling</span>
|
|
568
|
+
<select onchange="checkSet('who', this.value)">
|
|
569
|
+
${PREGENS.map((p) => `<option value="${p.id}"${p.id === checkState.who ? " selected" : ""}>${escapeHtml(p.short)} — ${escapeHtml(p.ancestry)} ${escapeHtml(p.cls)}</option>`).join("")}
|
|
570
|
+
</select></label>
|
|
571
|
+
<label class="field inline"><span class="name">Rolling what</span>
|
|
572
|
+
<select onchange="checkSet('what', this.value)">
|
|
573
|
+
${opts.map((o) => `<option value="${o.key}"${o.key === checkState.what ? " selected" : ""}>${escapeHtml(o.label)}</option>`).join("")}
|
|
574
|
+
</select></label>
|
|
575
|
+
<label class="field inline narrow"><span class="name">Against DC</span>
|
|
576
|
+
<input type="number" id="checkDC" value="${checkState.dc}" onchange="checkSet('dc', this.value)"></label>
|
|
577
|
+
</div>
|
|
578
|
+
<div class="chips">${DC_PRESETS.map((p) => `<button class="chip${p.dc === checkState.dc ? " on" : ""}" onclick="checkSet('dc', ${p.dc})">${escapeHtml(p.label)}</button>`).join("")}</div>
|
|
579
|
+
<div class="row tight">
|
|
580
|
+
<button class="btn sm" onclick="rollTheCheck(1)">${iconSvg("d20")} Roll once</button>
|
|
581
|
+
<button class="btn sm secondary" onclick="rollTheCheck(20)">Roll twenty</button>
|
|
582
|
+
</div>
|
|
583
|
+
<div id="checkOut" class="out"></div>`;
|
|
584
|
+
};
|
|
585
|
+
function checkSet(k, v) {
|
|
586
|
+
if (k === "dc") checkState.dc = clamp(Math.round(Number(v) || 0), 1, 60);
|
|
587
|
+
else checkState[k] = v;
|
|
588
|
+
if (k === "who") checkState.what = null;
|
|
589
|
+
DEMOS.check();
|
|
590
|
+
}
|
|
591
|
+
function rollTheCheck(times) {
|
|
592
|
+
const pc = PC_BY_ID[checkState.who];
|
|
593
|
+
const opt = checkOptions(pc).find((o) => o.key === checkState.what);
|
|
594
|
+
const dc = checkState.dc;
|
|
595
|
+
if (times === 1) {
|
|
596
|
+
const r = rollCheck(opt.mod, dc);
|
|
597
|
+
setHTML("checkOut", `
|
|
598
|
+
<div class="resultcard ${DEG_CLASS[r.deg]}">
|
|
599
|
+
<div class="rline"><span class="rmath">d20 ${natSpan(r.nat)} ${sign(r.mod)} =</span> <span class="rtotal">${r.total}</span> <span class="rmath">vs DC ${dc}</span></div>
|
|
600
|
+
<div class="rdeg">${degChip(r.deg)}</div>
|
|
601
|
+
<p class="rwhy">${escapeHtml(titleCase(r.why))}.</p>
|
|
602
|
+
<p class="meta">${escapeHtml(pc.short)}'s ${escapeHtml(opt.label.replace(/\s[+-]\d+$/, ""))} is ${sign(opt.mod)} — ${escapeHtml(opt.rank)}${opt.rank === "untrained" ? ", so nothing is added at all, not even her level" : `, so that's the rank bonus plus level ${pc.level}`}.</p>
|
|
603
|
+
</div>`);
|
|
604
|
+
} else {
|
|
605
|
+
const counts = [0, 0, 0, 0];
|
|
606
|
+
for (let i = 0; i < times; i++) counts[rollCheck(opt.mod, dc).deg]++;
|
|
607
|
+
const bars = [3, 2, 1, 0].map((d) => `
|
|
608
|
+
<div class="distrow">
|
|
609
|
+
<span class="dlbl">${DEG_LABEL[d]}</span>
|
|
610
|
+
<span class="dbar"><span class="dfill ${DEG_CLASS[d]}" style="width:${(counts[d] / times) * 100}%"></span></span>
|
|
611
|
+
<span class="dnum">${counts[d]}</span>
|
|
612
|
+
</div>`).join("");
|
|
613
|
+
setHTML("checkOut", `<div class="resultcard">
|
|
614
|
+
<p class="meta">Twenty rolls of ${sign(opt.mod)} against DC ${dc}:</p>
|
|
615
|
+
<div class="dist">${bars}</div>
|
|
616
|
+
<p class="rwhy">${counts[3] ? `${counts[3]} critical success${counts[3] === 1 ? "" : "es"} out of twenty — nobody rolled a 20 for most of those. Criticals come from beating the DC by ten.` : `No criticals that time. Drop the DC by five and roll again.`}</p>
|
|
617
|
+
</div>`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/* ---------------------------------------------------------------
|
|
622
|
+
2. THE TURN — three actions, the multiple attack penalty, and
|
|
623
|
+
what debuffs do to a target's numbers.
|
|
624
|
+
--------------------------------------------------------------- */
|
|
625
|
+
let turnState = { who: "mari", picks: [] };
|
|
626
|
+
function turnSpent() { return turnState.picks.reduce((n, a) => n + a.cost, 0); }
|
|
627
|
+
DEMOS.turn = function () {
|
|
628
|
+
const host = byId("demo-turn"); if (!host) return;
|
|
629
|
+
const pc = PC_BY_ID[turnState.who];
|
|
630
|
+
const palette = TURN_ACTIONS[turnState.who] || [];
|
|
631
|
+
const spent = turnSpent(), left = 3 - spent;
|
|
632
|
+
const pips = [0, 1, 2].map((i) => {
|
|
633
|
+
let label = "", used = false, k = 0;
|
|
634
|
+
turnState.picks.forEach((a) => { if (i >= k && i < k + a.cost) { label = a.label; used = true; } k += a.cost; });
|
|
635
|
+
return `<div class="pip${used ? " used" : ""}">${used ? `<span class="pipname">${escapeHtml(label)}</span>` : `<span class="pipname empty">◆ free</span>`}</div>`;
|
|
636
|
+
}).join("");
|
|
637
|
+
/* Live MAP preview, so the penalty is visible before it bites. */
|
|
638
|
+
let attacks = 0;
|
|
639
|
+
turnState.picks.forEach((a) => { if (a.attack) attacks++; });
|
|
640
|
+
host.innerHTML = `
|
|
641
|
+
<div class="controls">
|
|
642
|
+
<label class="field inline"><span class="name">Whose turn</span>
|
|
643
|
+
<select onchange="turnSet(this.value)">
|
|
644
|
+
${PREGENS.map((p) => `<option value="${p.id}"${p.id === turnState.who ? " selected" : ""}>${escapeHtml(p.short)} — ${escapeHtml(p.cls)}</option>`).join("")}
|
|
645
|
+
</select></label>
|
|
646
|
+
<div class="foecard">
|
|
647
|
+
<b>${escapeHtml(FOE.name)}</b>
|
|
648
|
+
<span class="meta">AC ${FOE.ac} · HP ${FOE.hp} · Fort DC ${10 + FOE.saves.fortitude} · Ref DC ${10 + FOE.saves.reflex} · Will DC ${10 + FOE.saves.will} · Perception DC ${10 + FOE.perception}</span>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
<div class="pips">${pips}<div class="pip react"><span class="pipname empty">⤳ reaction</span></div></div>
|
|
652
|
+
<p class="meta">${left ? `${left} action${left === 1 ? "" : "s"} left.` : "Turn full."} ${attacks ? `Next attack takes <b>${mapPenaltyLabel(attacks)}</b>.` : "No attacks yet — the first one is at full value."}</p>
|
|
653
|
+
<div class="palette">
|
|
654
|
+
${palette.map((a, i) => {
|
|
655
|
+
const dis = a.cost > left;
|
|
656
|
+
return `<button class="pbtn k-${a.kind}${dis ? " dis" : ""}" ${dis ? "disabled" : ""} onclick="turnPick(${i})">
|
|
657
|
+
<span class="pcost">${"◆".repeat(a.cost)}</span>
|
|
658
|
+
<span class="plabel">${escapeHtml(a.label)}</span>
|
|
659
|
+
${a.attack && !dis ? `<span class="pmap">${attacks ? mapPenaltyLabel(attacks, a.agile) : "full"}</span>` : ""}
|
|
660
|
+
</button>`;
|
|
661
|
+
}).join("")}
|
|
662
|
+
</div>
|
|
663
|
+
<div class="row tight">
|
|
664
|
+
<button class="btn sm" onclick="runTurn()" ${turnState.picks.length ? "" : "disabled"}>${iconSvg("d20")} Run the turn</button>
|
|
665
|
+
<button class="btn sm secondary" onclick="turnClear()">${iconSvg("reset")} Clear</button>
|
|
666
|
+
</div>
|
|
667
|
+
<div id="turnOut" class="out"></div>
|
|
668
|
+
${turnState.picks.length && turnState.picks[turnState.picks.length - 1].hint
|
|
669
|
+
? `<p class="hintline">${iconSvg("star")} ${escapeHtml(turnState.picks[turnState.picks.length - 1].hint)}</p>` : ""}`;
|
|
670
|
+
};
|
|
671
|
+
function mapPenalty(nAttacks, agile) {
|
|
672
|
+
if (nAttacks <= 0) return 0;
|
|
673
|
+
if (nAttacks === 1) return agile ? -4 : -5;
|
|
674
|
+
return agile ? -8 : -10;
|
|
675
|
+
}
|
|
676
|
+
function mapPenaltyLabel(nAttacks, agile) { const p = mapPenalty(nAttacks, agile); return p ? `${p}` : "full"; }
|
|
677
|
+
function turnSet(who) { turnState.who = who; turnState.picks = []; setHTML("turnOut", ""); DEMOS.turn(); }
|
|
678
|
+
function turnPick(i) {
|
|
679
|
+
const a = (TURN_ACTIONS[turnState.who] || [])[i];
|
|
680
|
+
if (!a || a.cost > 3 - turnSpent()) return;
|
|
681
|
+
turnState.picks.push(a); setHTML("turnOut", ""); DEMOS.turn();
|
|
682
|
+
}
|
|
683
|
+
function turnClear() { turnState.picks = []; setHTML("turnOut", ""); DEMOS.turn(); }
|
|
684
|
+
|
|
685
|
+
function runTurn() {
|
|
686
|
+
const pc = PC_BY_ID[turnState.who];
|
|
687
|
+
/* The foe's live state — this is the whole point of the demo: watch
|
|
688
|
+
the numbers you're rolling against move as the turn goes on. */
|
|
689
|
+
const foe = { frightened: 0, prone: false, grabbed: false, offGuardTo: false, hp: FOE.hp };
|
|
690
|
+
let attacks = 0, anthem = 0, dmgTotal = 0;
|
|
691
|
+
const log = [];
|
|
692
|
+
const foeAC = () => FOE.ac - foe.frightened - (foe.prone || foe.offGuardTo ? 2 : 0);
|
|
693
|
+
const foeDC = (save) => 10 + FOE.saves[save] - foe.frightened;
|
|
694
|
+
const acNote = () => {
|
|
695
|
+
const bits = [];
|
|
696
|
+
if (foe.frightened) bits.push(`−${foe.frightened} frightened (status)`);
|
|
697
|
+
if (foe.prone) bits.push("−2 prone/off-guard (circumstance)");
|
|
698
|
+
else if (foe.offGuardTo) bits.push("−2 off-guard (circumstance)");
|
|
699
|
+
return bits.length ? ` (AC ${FOE.ac} ${bits.join(" ")} = ${foeAC()})` : "";
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
turnState.picks.forEach((a) => {
|
|
703
|
+
/* --- attack rolls: Strikes and spell attacks --- */
|
|
704
|
+
if (a.attack && a.bonus != null) {
|
|
705
|
+
const pen = mapPenalty(attacks, a.agile);
|
|
706
|
+
const mod = a.bonus + pen + anthem;
|
|
707
|
+
const dc = foeAC();
|
|
708
|
+
const r = rollCheck(mod, dc);
|
|
709
|
+
attacks++;
|
|
710
|
+
const bits = [`${sign(a.bonus)} base`];
|
|
711
|
+
if (pen) bits.push(`${pen} multiple attack penalty`);
|
|
712
|
+
if (anthem) bits.push(`+${anthem} status (Courageous Anthem)`);
|
|
713
|
+
if (r.deg >= 2) {
|
|
714
|
+
const dmg = rollDamage(a.dmg, anthem);
|
|
715
|
+
const dealt = r.deg === 3 ? dmg.total * 2 : dmg.total;
|
|
716
|
+
dmgTotal += dealt;
|
|
717
|
+
log.push({ deg: r.deg, head: a.label,
|
|
718
|
+
math: `d20 ${natSpan(r.nat)} ${sign(mod)} = ${r.total} vs AC ${dc}${acNote()}`,
|
|
719
|
+
detail: `${bits.join(", ")}. Damage ${dmg.detail}${r.deg === 3 ? ` — <b>doubled for the critical hit: ${dealt}</b>` : ""}. ${escapeHtml(a.dtype)}.`,
|
|
720
|
+
why: r.why });
|
|
721
|
+
} else {
|
|
722
|
+
log.push({ deg: r.deg, head: a.label,
|
|
723
|
+
math: `d20 ${natSpan(r.nat)} ${sign(mod)} = ${r.total} vs AC ${dc}${acNote()}`,
|
|
724
|
+
detail: `${bits.join(", ")}. No damage.`, why: r.why });
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
/* --- skill checks against one of the foe's DCs --- */
|
|
729
|
+
if (a.check) {
|
|
730
|
+
const pen = a.attack ? mapPenalty(attacks, false) : 0;
|
|
731
|
+
const mod = a.check.bonus + pen;
|
|
732
|
+
const dc = a.check.vs === "perception" ? 10 + FOE.perception - foe.frightened : foeDC(a.check.vs);
|
|
733
|
+
const r = rollCheck(mod, dc);
|
|
734
|
+
if (a.attack) attacks++;
|
|
735
|
+
let outcome = "";
|
|
736
|
+
if (a.effect === "demoralize") {
|
|
737
|
+
if (r.deg === 3) { foe.frightened = Math.max(foe.frightened, 2); outcome = `${FOE.name} is <b>frightened 2</b> — every check and DC it has drops by 2, including its AC.`; }
|
|
738
|
+
else if (r.deg === 2) { foe.frightened = Math.max(foe.frightened, 1); outcome = `${FOE.name} is <b>frightened 1</b> — that's −1 to its AC as well as its rolls.`; }
|
|
739
|
+
else outcome = "No effect — and it's immune to your Demoralize for ten minutes either way.";
|
|
740
|
+
} else if (a.effect === "trip") {
|
|
741
|
+
if (r.deg === 3) { foe.prone = true; const d = rollDamage("1d6"); dmgTotal += d.total; outcome = `Knocked <b>prone</b> and takes ${d.detail} bludgeoning.`; }
|
|
742
|
+
else if (r.deg === 2) { foe.prone = true; outcome = "Knocked <b>prone</b> — that's off-guard, so −2 to its AC until it stands, and standing costs it an action."; }
|
|
743
|
+
else if (r.deg === 0) outcome = "You lose your footing and fall prone yourself.";
|
|
744
|
+
else outcome = "It keeps its feet.";
|
|
745
|
+
} else if (a.effect === "grapple") {
|
|
746
|
+
if (r.deg === 3) { foe.grabbed = true; outcome = "<b>Restrained</b> until the end of your next turn."; }
|
|
747
|
+
else if (r.deg === 2) { foe.grabbed = true; outcome = "<b>Grabbed</b> until the end of your next turn."; }
|
|
748
|
+
else if (r.deg === 0) outcome = "It reverses the grip — you're the one who's grabbed.";
|
|
749
|
+
else outcome = "It shrugs you off.";
|
|
750
|
+
} else if (a.effect === "feint") {
|
|
751
|
+
if (r.deg === 3) { foe.offGuardTo = true; outcome = "<b>Off-guard</b> to your melee attacks until the end of your next turn."; }
|
|
752
|
+
else if (r.deg === 2) { foe.offGuardTo = true; outcome = "<b>Off-guard</b> against your next melee attack this turn."; }
|
|
753
|
+
else if (r.deg === 0) outcome = "It reads you completely — you're off-guard to it instead.";
|
|
754
|
+
else outcome = "It doesn't buy it.";
|
|
755
|
+
}
|
|
756
|
+
log.push({ deg: r.deg, head: a.label,
|
|
757
|
+
math: `${a.check.skill} d20 ${natSpan(r.nat)} ${sign(mod)} = ${r.total} vs DC ${dc}${pen ? ` (includes ${pen} multiple attack penalty)` : ""}`,
|
|
758
|
+
detail: outcome, why: r.why });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
/* --- saves the target rolls against your spell DC --- */
|
|
762
|
+
if (a.save) {
|
|
763
|
+
const dc = a.save.dc;
|
|
764
|
+
const mod = FOE.saves.will - foe.frightened;
|
|
765
|
+
const nat = d20(), total = nat + mod;
|
|
766
|
+
const deg = degreeOf(total, dc, nat);
|
|
767
|
+
/* The target's degree is what happened to it, so a good save is bad news for you. */
|
|
768
|
+
let outcome = "";
|
|
769
|
+
if (a.effect === "fear") {
|
|
770
|
+
if (deg === 3) outcome = "Unaffected — it critically succeeded on its save.";
|
|
771
|
+
else if (deg === 2) { foe.frightened = Math.max(foe.frightened, 1); outcome = "It saved, but it's still <b>frightened 1</b>. Even a success usually leaves something behind."; }
|
|
772
|
+
else if (deg === 1) { foe.frightened = Math.max(foe.frightened, 2); outcome = "<b>Frightened 2</b> — −2 to everything it rolls and every DC it has."; }
|
|
773
|
+
else { foe.frightened = Math.max(foe.frightened, 3); outcome = "<b>Frightened 3 and fleeing</b> for a round. It runs."; }
|
|
774
|
+
}
|
|
775
|
+
log.push({ deg: 3 - deg, head: a.label,
|
|
776
|
+
math: `${FOE.name} rolls Will: d20 ${natSpan(nat)} ${sign(mod)} = ${total} vs your spell DC ${dc}${foe.frightened ? " (its modifier already includes being frightened)" : ""}`,
|
|
777
|
+
detail: outcome, why: degreeReason(total, dc, nat) + " — for the target", saveRoll: true, targetDeg: deg });
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
/* --- everything else: effects and flavour --- */
|
|
781
|
+
if (a.effect === "anthem") {
|
|
782
|
+
anthem = 1;
|
|
783
|
+
log.push({ deg: 2, head: a.label, math: "No roll", detail: "You and every ally in earshot get <b>+1 status to attack rolls, damage rolls and saves against fear</b> for the round. One action, and it makes both of your attacks better.", why: "" });
|
|
784
|
+
} else if (a.effect === "raise") {
|
|
785
|
+
log.push({ deg: 2, head: a.label, math: "No roll", detail: `Your AC goes from ${pc.ac} to <b>${pc.ac + pc.shieldBonus}</b> until the start of your next turn — a +${pc.shieldBonus} circumstance bonus.`, why: "" });
|
|
786
|
+
} else if (a.effect === "soothe") {
|
|
787
|
+
const h = rollDamage("1d10+4");
|
|
788
|
+
log.push({ deg: 2, head: a.label, math: "No roll to hit — healing just lands", detail: `Restores ${h.detail} Hit Points and gives +2 status to saves against mental effects for a minute.`, why: "" });
|
|
789
|
+
} else if (a.effect === "seek") {
|
|
790
|
+
log.push({ deg: 2, head: a.label, math: "The GM rolls this one in secret", detail: "Perception against the Stealth DC of anything hiding. You'll be told what you find, not what you rolled.", why: "" });
|
|
791
|
+
} else {
|
|
792
|
+
log.push({ deg: 2, head: a.label, math: "No roll", detail: a.hint || "Spent an action.", why: "" });
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const state2 = [];
|
|
797
|
+
if (foe.frightened) state2.push(`frightened ${foe.frightened}`);
|
|
798
|
+
if (foe.prone) state2.push("prone");
|
|
799
|
+
if (foe.grabbed) state2.push("grabbed");
|
|
800
|
+
if (foe.offGuardTo) state2.push("off-guard to you");
|
|
801
|
+
setHTML("turnOut", `
|
|
802
|
+
<ol class="turnlog">
|
|
803
|
+
${log.map((l) => `
|
|
804
|
+
<li class="tstep ${DEG_CLASS[l.deg]}">
|
|
805
|
+
<div class="tshead">${escapeHtml(l.head)} ${l.math !== "No roll" && !l.saveRoll ? degChip(l.deg) : l.saveRoll ? `<span class="deg ${DEG_CLASS[l.targetDeg]}">target: ${DEG_LABEL[l.targetDeg].toLowerCase()}</span>` : ""}</div>
|
|
806
|
+
<div class="tsmath">${l.math}</div>
|
|
807
|
+
${l.why ? `<div class="tswhy">${escapeHtml(titleCase(l.why))}.</div>` : ""}
|
|
808
|
+
${l.detail ? `<div class="tsdetail">${l.detail}</div>` : ""}
|
|
809
|
+
</li>`).join("")}
|
|
810
|
+
</ol>
|
|
811
|
+
<div class="turnsum">
|
|
812
|
+
<b>${dmgTotal}</b> damage to a ${FOE.hp}-hit-point enemy${state2.length ? `, and it's now <b>${escapeHtml(state2.join(", "))}</b>` : ""}.
|
|
813
|
+
${dmgTotal >= FOE.hp ? " That's it down." : ""}
|
|
814
|
+
<span class="meta">Your multiple attack penalty resets at the start of your next turn.</span>
|
|
815
|
+
</div>`);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/* ---------------------------------------------------------------
|
|
819
|
+
3. GOING DOWN — dying, recovery checks, wounded, hero points.
|
|
820
|
+
--------------------------------------------------------------- */
|
|
821
|
+
let dyingState = null;
|
|
822
|
+
function freshDying() { const pc = PC_BY_ID.mari; return { hp: pc.hp, max: pc.hp, dying: 0, wounded: 0, hero: 1, log: [] }; }
|
|
823
|
+
DEMOS.dying = function () {
|
|
824
|
+
const host = byId("demo-dying"); if (!host) return;
|
|
825
|
+
if (!dyingState) dyingState = freshDying();
|
|
826
|
+
const s = dyingState, pc = PC_BY_ID.mari;
|
|
827
|
+
const dead = s.dying >= 4;
|
|
828
|
+
const pct = (s.hp / s.max) * 100;
|
|
829
|
+
host.innerHTML = `
|
|
830
|
+
<div class="dywrap">
|
|
831
|
+
<div class="dyhead">
|
|
832
|
+
<b>${escapeHtml(pc.short)}</b>
|
|
833
|
+
<span class="hpbar"><span class="hpfill ${pct > 50 ? "" : pct > 0 ? "mid" : "low"}" style="width:${pct}%"></span></span>
|
|
834
|
+
<span class="hpnum">${s.hp} / ${s.max}</span>
|
|
835
|
+
</div>
|
|
836
|
+
<div class="dychips">
|
|
837
|
+
<span class="statchip${s.dying ? " bad" : ""}">dying ${s.dying}</span>
|
|
838
|
+
<span class="statchip${s.wounded ? " warn" : ""}">wounded ${s.wounded}</span>
|
|
839
|
+
<span class="statchip${s.hero ? " good" : ""}">${s.hero} hero point${s.hero === 1 ? "" : "s"}</span>
|
|
840
|
+
${dead ? `<span class="statchip bad">dead</span>` : s.dying ? `<span class="statchip bad">unconscious</span>` : ""}
|
|
841
|
+
</div>
|
|
842
|
+
<div class="palette">
|
|
843
|
+
<button class="pbtn k-attack" onclick="dyingHit(12)" ${dead ? "disabled" : ""}><span class="plabel">Take a 12-damage hit</span></button>
|
|
844
|
+
<button class="pbtn k-attack" onclick="dyingHit(25, true)" ${dead ? "disabled" : ""}><span class="plabel">Take a critical hit (25)</span></button>
|
|
845
|
+
<button class="pbtn k-skill" onclick="dyingRecover()" ${!s.dying || dead ? "disabled" : ""}><span class="plabel">Roll a recovery check</span></button>
|
|
846
|
+
<button class="pbtn k-spell" onclick="dyingHero()" ${!s.dying || !s.hero || dead ? "disabled" : ""}><span class="plabel">Spend all hero points</span></button>
|
|
847
|
+
<button class="pbtn k-defend" onclick="dyingHeal()" ${dead ? "disabled" : ""}><span class="plabel">Someone casts Soothe</span></button>
|
|
848
|
+
<button class="pbtn k-other" onclick="dyingReset()"><span class="plabel">Reset</span></button>
|
|
849
|
+
</div>
|
|
850
|
+
<ol class="turnlog">${s.log.map((l) => `<li class="tstep ${l.cls}"><div class="tsmath">${l.text}</div></li>`).join("")}</ol>
|
|
851
|
+
</div>`;
|
|
852
|
+
};
|
|
853
|
+
function dyLog(text, cls) { dyingState.log.unshift({ text, cls: cls || "succ" }); DEMOS.dying(); }
|
|
854
|
+
function dyingHit(amount, crit) {
|
|
855
|
+
const s = dyingState;
|
|
856
|
+
if (s.dying) {
|
|
857
|
+
s.dying += crit ? 2 : 1;
|
|
858
|
+
dyLog(`Hit while already down. Dying goes up by ${crit ? "2 (it was a critical hit)" : "1"} — <b>dying ${s.dying}</b>.${s.dying >= 4 ? " That's four. She's dead." : ""}`, s.dying >= 4 ? "cfail" : "fail");
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
s.hp = Math.max(0, s.hp - amount);
|
|
862
|
+
if (s.hp > 0) { dyLog(`Takes ${amount}. Down to <b>${s.hp}</b> hit points.`, "fail"); return; }
|
|
863
|
+
const start = (crit ? 2 : 1) + s.wounded;
|
|
864
|
+
s.dying = start;
|
|
865
|
+
dyLog(`Dropped to 0 by ${crit ? "a critical hit" : "the hit"}. She's <b>dying ${start}</b> and unconscious`
|
|
866
|
+
+ (crit ? " — a critical hit starts you at dying 2" : "")
|
|
867
|
+
+ (s.wounded ? `, plus ${s.wounded} for already being wounded ${s.wounded}` : "") + ".", "cfail");
|
|
868
|
+
}
|
|
869
|
+
function dyingRecover() {
|
|
870
|
+
const s = dyingState;
|
|
871
|
+
const dc = 10 + s.dying;
|
|
872
|
+
const nat = d20();
|
|
873
|
+
const deg = degreeOf(nat, dc, nat); /* a flat check: no modifiers at all */
|
|
874
|
+
let msg = `Recovery check — a <b>flat check</b>, no modifiers: d20 ${natSpan(nat)} vs DC ${dc} (10 + dying ${s.dying}). ${DEG_LABEL[deg]}. `;
|
|
875
|
+
if (deg === 3) s.dying -= 2;
|
|
876
|
+
else if (deg === 2) s.dying -= 1;
|
|
877
|
+
else if (deg === 1) s.dying += 1;
|
|
878
|
+
else s.dying += 2;
|
|
879
|
+
if (s.dying <= 0) {
|
|
880
|
+
s.dying = 0; s.wounded += 1;
|
|
881
|
+
msg += `Dying is gone — but losing it always leaves you <b>wounded ${s.wounded}</b>, which gets added to the dying value next time she drops. Still at 0 hit points and unconscious until someone heals her.`;
|
|
882
|
+
dyLog(msg, "succ");
|
|
883
|
+
} else if (s.dying >= 4) {
|
|
884
|
+
s.dying = 4;
|
|
885
|
+
msg += `That's <b>dying 4</b>. She's dead.`;
|
|
886
|
+
dyLog(msg, "cfail");
|
|
887
|
+
} else {
|
|
888
|
+
msg += `Now <b>dying ${s.dying}</b>.`;
|
|
889
|
+
dyLog(msg, deg >= 2 ? "succ" : "fail");
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function dyingHero() {
|
|
893
|
+
const s = dyingState;
|
|
894
|
+
if (!s.hero) return;
|
|
895
|
+
s.hero = 0; s.dying = 0; s.wounded += 1;
|
|
896
|
+
dyLog(`Spent every hero point to cheat death: the dying condition goes away and she's stable at 0 hit points. Losing dying still leaves her <b>wounded ${s.wounded}</b>. This is why you start each session with one and why you should spend it.`, "csucc");
|
|
897
|
+
}
|
|
898
|
+
function dyingHeal() {
|
|
899
|
+
const s = dyingState;
|
|
900
|
+
const h = rollDamage("1d10+4");
|
|
901
|
+
s.hp = Math.min(s.max, s.hp + h.total);
|
|
902
|
+
const wasDying = s.dying;
|
|
903
|
+
if (s.dying) s.dying = 0;
|
|
904
|
+
dyLog(`Soothe restores ${h.detail} hit points — she's on <b>${s.hp}</b>.`
|
|
905
|
+
+ (wasDying ? ` Any hit points at all end dying and unconsciousness: she's awake, and can act normally next turn. She keeps wounded ${s.wounded}.` : ""), "csucc");
|
|
906
|
+
}
|
|
907
|
+
function dyingReset() { dyingState = freshDying(); DEMOS.dying(); }
|
|
908
|
+
|
|
909
|
+
/* ---------------------------------------------------------------
|
|
910
|
+
4. EXPLORATION — everyone declares an activity.
|
|
911
|
+
--------------------------------------------------------------- */
|
|
912
|
+
let exploreState = { mari: "search", sable: "avoid-notice", order: ["mari", "sable"] };
|
|
913
|
+
DEMOS.explore = function () {
|
|
914
|
+
const host = byId("demo-explore"); if (!host) return;
|
|
915
|
+
const rows = exploreState.order.map((id, i) => {
|
|
916
|
+
const pc = PC_BY_ID[id];
|
|
917
|
+
const act = EXPLORATION_ACTIVITIES.find((a) => a.slug === exploreState[id]) || EXPLORATION_ACTIVITIES[0];
|
|
918
|
+
const mod = explorationMod(pc, act);
|
|
919
|
+
return `<div class="exprow">
|
|
920
|
+
<div class="expord">
|
|
921
|
+
<button class="ordbtn" onclick="expMove(${i},-1)" ${i === 0 ? "disabled" : ""}>${iconSvg("up")}</button>
|
|
922
|
+
<span class="ordnum">${i + 1}</span>
|
|
923
|
+
<button class="ordbtn" onclick="expMove(${i},1)" ${i === exploreState.order.length - 1 ? "disabled" : ""}>${iconSvg("down")}</button>
|
|
924
|
+
</div>
|
|
925
|
+
<div class="expwho"><b>${escapeHtml(pc.short)}</b><span class="meta">${escapeHtml(pc.cls)}</span></div>
|
|
926
|
+
<div class="expsel">
|
|
927
|
+
<select onchange="expSet('${id}', this.value)">
|
|
928
|
+
${EXPLORATION_ACTIVITIES.map((a) => `<option value="${a.slug}"${a.slug === act.slug ? " selected" : ""}>${escapeHtml(a.label)}</option>`).join("")}
|
|
929
|
+
</select>
|
|
930
|
+
</div>
|
|
931
|
+
<div class="expgov">${mod == null ? `<span class="meta">${escapeHtml(act.governing)}</span>` : `<b class="govmod">${sign(mod)}</b><span class="meta"> ${escapeHtml(act.governing)}</span>`}</div>
|
|
932
|
+
<div class="expnote">${escapeHtml(act.blurb)}${act.secret ? ` <span class="secretchip">${iconSvg("secret")} the GM rolls this</span>` : ""}</div>
|
|
933
|
+
</div>`;
|
|
934
|
+
}).join("");
|
|
935
|
+
const stealthy = exploreState.order.every((id) => exploreState[id] === "avoid-notice");
|
|
936
|
+
const searching = exploreState.order.some((id) => exploreState[id] === "search");
|
|
937
|
+
const scouting = exploreState.order.some((id) => exploreState[id] === "scout");
|
|
938
|
+
const notes = [];
|
|
939
|
+
notes.push(stealthy
|
|
940
|
+
? "Everyone is Avoiding Notice, so everyone rolls <b>Stealth</b> for initiative when the fight starts — and starts it hidden."
|
|
941
|
+
: "Whoever is Avoiding Notice rolls <b>Stealth</b> for initiative; everyone else rolls Perception.");
|
|
942
|
+
notes.push(searching
|
|
943
|
+
? "Someone is Searching, so the GM gets a secret Perception check against anything hidden in your path. That tripwire has a chance of being spotted."
|
|
944
|
+
: "<b>Nobody is Searching.</b> The GM makes no check for hidden doors or traps in your path — you walk into them.");
|
|
945
|
+
if (scouting) notes.push("Scouting gives the whole party <b>+1 to initiative</b>.");
|
|
946
|
+
notes.push("Marching order matters: the front of the line meets whatever is in the corridor first.");
|
|
947
|
+
host.innerHTML = `
|
|
948
|
+
<div class="exphead">
|
|
949
|
+
<div>Order</div><div>Who</div><div>Activity</div><div>Rolls</div><div>What it buys</div>
|
|
950
|
+
</div>
|
|
951
|
+
<div class="expboard">${rows}</div>
|
|
952
|
+
<ul class="notelist">${notes.map((n) => `<li>${n}</li>`).join("")}</ul>`;
|
|
953
|
+
};
|
|
954
|
+
function explorationMod(pc, act) {
|
|
955
|
+
const g = (act.governing || "").toLowerCase();
|
|
956
|
+
if (g === "perception") return pc.perception;
|
|
957
|
+
if (pc.skills && pc.skills[g] != null) return pc.skills[g];
|
|
958
|
+
if (g === "constitution") return null;
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
function expSet(id, slug) { exploreState[id] = slug; DEMOS.explore(); }
|
|
962
|
+
function expMove(i, d) {
|
|
963
|
+
const o = exploreState.order, j = i + d;
|
|
964
|
+
if (j < 0 || j >= o.length) return;
|
|
965
|
+
const t = o[i]; o[i] = o[j]; o[j] = t;
|
|
966
|
+
DEMOS.explore();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/* ---------------------------------------------------------------
|
|
970
|
+
5. ATTITUDE — the social ladder, and what each move costs.
|
|
971
|
+
--------------------------------------------------------------- */
|
|
972
|
+
let socialState = { who: "sable", attitude: 2, log: [], immune: false, lieBonus: 0 };
|
|
973
|
+
DEMOS.attitude = function () {
|
|
974
|
+
const host = byId("demo-attitude"); if (!host) return;
|
|
975
|
+
const s = socialState, pc = PC_BY_ID[s.who];
|
|
976
|
+
const ladder = ATTITUDES.map((a, i) => `
|
|
977
|
+
<div class="rung${i === s.attitude ? " on" : ""}${i < s.attitude ? " below" : ""}">
|
|
978
|
+
<span class="rname">${titleCase(a)}</span>
|
|
979
|
+
${i === s.attitude ? `<span class="rhere">${escapeHtml(FLAVOR.npc)} is here</span>` : ""}
|
|
980
|
+
</div>`).reverse().join("");
|
|
981
|
+
host.innerHTML = `
|
|
982
|
+
<div class="controls">
|
|
983
|
+
<label class="field inline"><span class="name">Who's talking</span>
|
|
984
|
+
<select onchange="socialSet(this.value)">
|
|
985
|
+
${PREGENS.map((p) => `<option value="${p.id}"${p.id === s.who ? " selected" : ""}>${escapeHtml(p.short)} — ${escapeHtml(p.cls)}</option>`).join("")}
|
|
986
|
+
</select></label>
|
|
987
|
+
<div class="foecard">
|
|
988
|
+
<b>${escapeHtml(FLAVOR.npc)}</b>
|
|
989
|
+
<span class="meta">${escapeHtml(FLAVOR.npcRole)} in ${escapeHtml(FLAVOR.port)} · Will DC 15 · Perception DC 15</span>
|
|
990
|
+
</div>
|
|
991
|
+
</div>
|
|
992
|
+
<div class="ladder">${ladder}</div>
|
|
993
|
+
<div class="palette">
|
|
994
|
+
${SOCIAL_MOVES.filter((m) => m.key !== "demoralize").map((m) => {
|
|
995
|
+
const mod = socialMod(pc, m);
|
|
996
|
+
const blocked = m.needs && s.attitude < ATTITUDES.indexOf(m.needs);
|
|
997
|
+
const immune = m.key === "coerce" && s.immune;
|
|
998
|
+
return `<button class="pbtn k-skill${blocked || immune ? " dis" : ""}" ${blocked || immune ? "disabled" : ""} onclick="socialDo('${m.key}')">
|
|
999
|
+
<span class="plabel">${escapeHtml(m.label)}</span>
|
|
1000
|
+
<span class="pmap">${escapeHtml(m.skill)} ${sign(mod)}${mod === 0 ? " untrained" : ""}</span>
|
|
1001
|
+
${blocked ? `<span class="pblock">needs friendly</span>` : immune ? `<span class="pblock">immune for a week</span>` : ""}
|
|
1002
|
+
</button>`;
|
|
1003
|
+
}).join("")}
|
|
1004
|
+
<button class="pbtn k-other" onclick="socialReset()"><span class="plabel">Reset the scene</span></button>
|
|
1005
|
+
</div>
|
|
1006
|
+
<ol class="turnlog">${s.log.map((l) => `<li class="tstep ${l.cls}"><div class="tsmath">${l.text}</div></li>`).join("")}</ol>`;
|
|
1007
|
+
};
|
|
1008
|
+
function socialMod(pc, move) {
|
|
1009
|
+
const key = move.skill.toLowerCase();
|
|
1010
|
+
return (pc.skills && pc.skills[key] != null) ? pc.skills[key] : 0;
|
|
1011
|
+
}
|
|
1012
|
+
function socialSet(who) { socialState.who = who; DEMOS.attitude(); }
|
|
1013
|
+
function socialReset() { socialState = { who: socialState.who, attitude: 2, log: [], immune: false, lieBonus: 0 }; DEMOS.attitude(); }
|
|
1014
|
+
function socLog(text, cls) { socialState.log.unshift({ text, cls: cls || "succ" }); DEMOS.attitude(); }
|
|
1015
|
+
function socialDo(key) {
|
|
1016
|
+
const s = socialState, pc = PC_BY_ID[s.who];
|
|
1017
|
+
const move = SOCIAL_MOVES.find((m) => m.key === key);
|
|
1018
|
+
const mod = socialMod(pc, move);
|
|
1019
|
+
const dc = (key === "lie" ? 15 + s.lieBonus : 15);
|
|
1020
|
+
const r = rollCheck(mod, dc);
|
|
1021
|
+
const before = s.attitude;
|
|
1022
|
+
const untrained = mod === 0 ? ` <span class="meta">(${escapeHtml(pc.short)} is untrained in ${escapeHtml(move.skill)} — untrained adds nothing, not even her level)</span>` : "";
|
|
1023
|
+
let outcome = "";
|
|
1024
|
+
if (key === "impression") {
|
|
1025
|
+
if (r.deg === 3) s.attitude = clamp(s.attitude + 2, 0, 4);
|
|
1026
|
+
else if (r.deg === 2) s.attitude = clamp(s.attitude + 1, 0, 4);
|
|
1027
|
+
else if (r.deg === 0) s.attitude = clamp(s.attitude - 1, 0, 4);
|
|
1028
|
+
outcome = r.deg === 3 ? "Two steps better." : r.deg === 2 ? "One step better." : r.deg === 1 ? "No change — a plain failure just doesn't move them." : "One step worse. You've made it awkward.";
|
|
1029
|
+
} else if (key === "request") {
|
|
1030
|
+
if (r.deg === 3) outcome = "Agreed, no conditions.";
|
|
1031
|
+
else if (r.deg === 2) outcome = "Agreed — but they want something in return.";
|
|
1032
|
+
else if (r.deg === 1) outcome = "Refused. They might suggest something smaller.";
|
|
1033
|
+
else { s.attitude = clamp(s.attitude - 1, 0, 4); outcome = "Refused, and one step worse for the cheek of asking."; }
|
|
1034
|
+
} else if (key === "coerce") {
|
|
1035
|
+
if (r.deg >= 2) {
|
|
1036
|
+
s.attitude = Math.min(s.attitude, 1);
|
|
1037
|
+
outcome = r.deg === 3
|
|
1038
|
+
? "They talk — and then they're <b>unfriendly</b>, permanently. Even the critical success costs you the relationship."
|
|
1039
|
+
: "They talk — and then they're <b>unfriendly</b>, and may well report you.";
|
|
1040
|
+
} else if (r.deg === 1) { s.attitude = Math.min(s.attitude, 1); outcome = "Nothing, and now they're <b>unfriendly</b>."; }
|
|
1041
|
+
else { s.attitude = 0; s.immune = true; outcome = "<b>Hostile</b>, and immune to your threats for a week. This is the worst outcome in the social rules."; }
|
|
1042
|
+
} else if (key === "lie") {
|
|
1043
|
+
if (r.deg >= 2) outcome = "Believed — for now. If they find evidence later they get a Perception check to catch it.";
|
|
1044
|
+
else { s.lieBonus = 4; outcome = "Not believed, and they get <b>+4 against everything else you say</b> this conversation."; }
|
|
1045
|
+
}
|
|
1046
|
+
const moved = s.attitude !== before ? ` ${titleCase(ATTITUDES[before])} → <b>${titleCase(ATTITUDES[s.attitude])}</b>.` : "";
|
|
1047
|
+
socLog(`<b>${escapeHtml(move.label)}</b> — ${escapeHtml(move.skill)}: d20 ${natSpan(r.nat)} ${sign(mod)} = ${r.total} vs DC ${dc}. ${degChip(r.deg)} ${escapeHtml(titleCase(r.why))}. ${outcome}${moved}${untrained}`,
|
|
1048
|
+
DEG_CLASS[r.deg]);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/* ---------------------------------------------------------------
|
|
1052
|
+
6. DOWNTIME — a week ashore.
|
|
1053
|
+
--------------------------------------------------------------- */
|
|
1054
|
+
let downState = { days: 5, mari: "earn-income", sable: "learn-a-spell" };
|
|
1055
|
+
DEMOS.downtime = function () {
|
|
1056
|
+
const host = byId("demo-downtime"); if (!host) return;
|
|
1057
|
+
const rows = PREGENS.map((pc) => {
|
|
1058
|
+
const act = DOWNTIME_ACTIVITIES.find((a) => a.slug === downState[pc.id]) || DOWNTIME_ACTIVITIES[0];
|
|
1059
|
+
return `<div class="exprow down">
|
|
1060
|
+
<div class="expwho"><b>${escapeHtml(pc.short)}</b><span class="meta">${escapeHtml(pc.cls)}</span></div>
|
|
1061
|
+
<div class="expsel">
|
|
1062
|
+
<select onchange="downSet('${pc.id}', this.value)">
|
|
1063
|
+
${DOWNTIME_ACTIVITIES.map((a) => `<option value="${a.slug}"${a.slug === act.slug ? " selected" : ""}>${escapeHtml(a.label)}</option>`).join("")}
|
|
1064
|
+
</select>
|
|
1065
|
+
</div>
|
|
1066
|
+
<div class="expgov"><span class="meta">${escapeHtml(act.skill)}</span></div>
|
|
1067
|
+
<div class="expnote">${escapeHtml(act.blurb)}</div>
|
|
1068
|
+
</div>`;
|
|
1069
|
+
}).join("");
|
|
1070
|
+
const asks = [];
|
|
1071
|
+
PREGENS.forEach((pc) => {
|
|
1072
|
+
const slug = downState[pc.id];
|
|
1073
|
+
if (slug === "earn-income") asks.push(`<b>${escapeHtml(pc.short)}</b> needs a <b>task level</b> from the GM — that plus her proficiency rank sets the daily rate.`);
|
|
1074
|
+
if (slug === "craft") asks.push(`<b>${escapeHtml(pc.short)}</b> needs the formula and half the item's price up front, then days of work.`);
|
|
1075
|
+
if (slug === "learn-a-spell") asks.push(`<b>${escapeHtml(pc.short)}</b> needs access to the spell — a teacher, a scroll, or someone's spellbook — and the materials.`);
|
|
1076
|
+
if (slug === "retraining") asks.push(`<b>${escapeHtml(pc.short)}</b> needs the GM to agree what she's retraining into, and usually a teacher.`);
|
|
1077
|
+
if (slug === "long-term-rest") asks.push(`<b>${escapeHtml(pc.short)}</b> just needs somewhere safe and boring.`);
|
|
1078
|
+
if (slug === "research") asks.push(`<b>${escapeHtml(pc.short)}</b> needs a library worth the name, and a question worth asking.`);
|
|
1079
|
+
});
|
|
1080
|
+
host.innerHTML = `
|
|
1081
|
+
<div class="controls">
|
|
1082
|
+
<label class="field inline narrow"><span class="name">Days ashore</span>
|
|
1083
|
+
<input type="number" min="1" max="60" value="${downState.days}" onchange="downSet('days', this.value)"></label>
|
|
1084
|
+
<div class="foecard"><b>${escapeHtml(FLAVOR.port)}</b><span class="meta">${downState.days} day${downState.days === 1 ? "" : "s"} before the ${escapeHtml(FLAVOR.ship)} sails</span></div>
|
|
1085
|
+
</div>
|
|
1086
|
+
<div class="expboard">${rows}</div>
|
|
1087
|
+
<ul class="notelist">
|
|
1088
|
+
${asks.map((a) => `<li>${a}</li>`).join("")}
|
|
1089
|
+
<li>Everyone also heals: ${downState.days} full day${downState.days === 1 ? "" : "s"} of rest is a lot of hit points, and it clears <b>wounded</b> once you're back to full.</li>
|
|
1090
|
+
</ul>`;
|
|
1091
|
+
};
|
|
1092
|
+
function downSet(k, v) {
|
|
1093
|
+
if (k === "days") downState.days = clamp(Math.round(Number(v) || 1), 1, 60);
|
|
1094
|
+
else downState[k] = v;
|
|
1095
|
+
DEMOS.downtime();
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/* ============================================================
|
|
1099
|
+
MENU
|
|
1100
|
+
============================================================ */
|
|
1101
|
+
function renderMenu() {
|
|
1102
|
+
const done = CHAPTERS.filter((c) => state.done[c.key]).length;
|
|
1103
|
+
setHTML("menuBody", `
|
|
1104
|
+
<h2>How you're using this</h2>
|
|
1105
|
+
<div class="seg">
|
|
1106
|
+
<button class="${!gmMode() ? "on" : ""}" onclick="setGmMode(false)">Player</button>
|
|
1107
|
+
<button class="${gmMode() ? "on" : ""}" onclick="setGmMode(true)">GM</button>
|
|
1108
|
+
</div>
|
|
1109
|
+
<p class="meta">GM mode adds an <b>At the table</b> block to the bottom of every tab: what to run, in what order, and what to say. Player mode hides them, so you can hand the same link to your players.</p>
|
|
1110
|
+
|
|
1111
|
+
<h2 style="margin-top:26px">Progress</h2>
|
|
1112
|
+
<p class="meta">${done} of ${CHAPTERS.length} tabs marked read. Kept in this browser only.</p>
|
|
1113
|
+
<button class="btn secondary" onclick="resetProgress()">${iconSvg("reset")} Clear progress</button>
|
|
1114
|
+
|
|
1115
|
+
<h2 style="margin-top:26px">Appearance</h2>
|
|
1116
|
+
<div class="seg">
|
|
1117
|
+
${["auto", "light", "dark"].map((m) => `<button class="${state.settings.themeMode === m ? "on" : ""}" onclick="setThemeMode('${m}')">${titleCase(m)}</button>`).join("")}
|
|
1118
|
+
</div>
|
|
1119
|
+
<div class="swatchrow"><label for="accentPick">Accent colour</label>
|
|
1120
|
+
<input type="color" id="accentPick" value="${(state.settings.custom && state.settings.custom.accent) || DEFAULT_ACCENT}" oninput="setCustomColor('accent', this.value)"></div>
|
|
1121
|
+
<button class="btn secondary" onclick="resetTheme()">Reset colours</button>
|
|
1122
|
+
|
|
1123
|
+
<h2 style="margin-top:26px">Take it with you</h2>
|
|
1124
|
+
<p class="meta">This page is one self-contained file. Save it and it works on a plane, in a basement, or on a boat.</p>
|
|
1125
|
+
<div class="row tight">
|
|
1126
|
+
<button class="btn secondary" onclick="downloadOffline()">${iconSvg("install")} Save offline copy</button>
|
|
1127
|
+
<button class="btn secondary" id="installBtn" style="display:none" onclick="installApp()">Install app</button>
|
|
1128
|
+
</div>
|
|
1129
|
+
|
|
1130
|
+
<h2 style="margin-top:26px">Where the rules text comes from</h2>
|
|
1131
|
+
<p class="meta">The quoted condition and action text in this app is Paizo content under the ORC and OGL licences, taken from the open-source Foundry VTT <b>pf2e</b> project — ${REF_META.conditions || 0} conditions and ${REF_META.actions || 0} actions, generated ${escapeHtml(REF_META.generated || "")}${REF_META.sourceCommit ? ` from commit ${escapeHtml(REF_META.sourceCommit)}` : ""}. The explanations around it are ours, and the two pregenerated characters are made up. Unofficial fan tool, not affiliated with Paizo.</p>
|
|
1132
|
+
<p class="meta">${(REF_META.sources || []).map((s) => `${escapeHtml(s.title)} (${escapeHtml(s.license)})`).join(" · ")}</p>
|
|
1133
|
+
|
|
1134
|
+
<button class="btn secondary" style="margin-top:22px" onclick="closeMenu()">← Back to the primer</button>`);
|
|
1135
|
+
}
|
|
1136
|
+
function resetProgress() {
|
|
1137
|
+
state.done = {}; saveState(); renderHeader(); renderMenu(); toast("Progress cleared");
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/* ============================================================
|
|
1141
|
+
THEME
|
|
1142
|
+
============================================================ */
|
|
1143
|
+
const DEFAULT_ACCENT = "#3f7d8c";
|
|
1144
|
+
function hexToRgb(h) { h = (h || "").replace("#", "").trim(); if (h.length === 3) h = h.split("").map((c) => c + c).join(""); const n = parseInt(h || "000000", 16); return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; }
|
|
1145
|
+
function relLuminance(hex) { const { r, g, b } = hexToRgb(hex); const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }
|
|
1146
|
+
function inkFor(hex) { return relLuminance(hex) < 0.48 ? "#ffffff" : "#15171c"; }
|
|
1147
|
+
function resolveThemeMode() {
|
|
1148
|
+
const m = state.settings.themeMode;
|
|
1149
|
+
if (m === "light" || m === "dark") return m;
|
|
1150
|
+
return (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
|
|
1151
|
+
}
|
|
1152
|
+
function applyTheme() {
|
|
1153
|
+
const root = document.documentElement;
|
|
1154
|
+
root.dataset.theme = resolveThemeMode();
|
|
1155
|
+
const c = state.settings.custom || {};
|
|
1156
|
+
const accent = c.accent || DEFAULT_ACCENT;
|
|
1157
|
+
root.style.setProperty("--accent", accent);
|
|
1158
|
+
root.style.setProperty("--accent-ink", inkFor(accent));
|
|
1159
|
+
const meta = document.querySelector('meta[name="theme-color"]');
|
|
1160
|
+
if (meta) meta.setAttribute("content", resolveThemeMode() === "dark" ? "#14161b" : "#f4f5f8");
|
|
1161
|
+
}
|
|
1162
|
+
function setThemeMode(m) { state.settings.themeMode = m; saveState(); applyTheme(); renderMenu(); }
|
|
1163
|
+
function setCustomColor(key, val) { state.settings.custom = state.settings.custom || {}; state.settings.custom[key] = val; saveState(); applyTheme(); }
|
|
1164
|
+
function resetTheme() { state.settings.custom = null; saveState(); applyTheme(); renderMenu(); }
|
|
1165
|
+
|
|
1166
|
+
/* ============================================================
|
|
1167
|
+
PWA / OFFLINE
|
|
1168
|
+
============================================================ */
|
|
1169
|
+
let deferredInstall = null;
|
|
1170
|
+
function isHeadless() { try { return /jsdom/i.test(navigator.userAgent) || !("onbeforeinstallprompt" in window || "serviceWorker" in navigator); } catch (e) { return true; } }
|
|
1171
|
+
function setupInstall() {
|
|
1172
|
+
if (isHeadless()) return;
|
|
1173
|
+
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferredInstall = e; showInstallButton(true); });
|
|
1174
|
+
window.addEventListener("appinstalled", () => { deferredInstall = null; showInstallButton(false); toast("App installed"); });
|
|
1175
|
+
if ("serviceWorker" in navigator && location.protocol.startsWith("http")) {
|
|
1176
|
+
navigator.serviceWorker.register("sw.js").catch(() => {});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
function showInstallButton(show) { const b = byId("installBtn"); if (b) b.style.display = show ? "" : "none"; }
|
|
1180
|
+
function installApp() { if (!deferredInstall) return; deferredInstall.prompt(); deferredInstall.userChoice.finally(() => { deferredInstall = null; showInstallButton(false); }); }
|
|
1181
|
+
function downloadOffline() {
|
|
1182
|
+
const doIt = (html) => {
|
|
1183
|
+
const blob = new Blob([html], { type: "text/html" });
|
|
1184
|
+
const a = document.createElement("a"); a.href = URL.createObjectURL(blob);
|
|
1185
|
+
a.download = "pf2e-primer.html"; document.body.appendChild(a); a.click();
|
|
1186
|
+
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
|
1187
|
+
};
|
|
1188
|
+
fetch(location.href).then((r) => r.text()).then(doIt).catch(() => doIt("<!doctype html>" + document.documentElement.outerHTML));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/* ============================================================
|
|
1192
|
+
BOOT
|
|
1193
|
+
============================================================ */
|
|
1194
|
+
function buildNav() {
|
|
1195
|
+
setHTML("tabs", CHAPTERS.map((c) =>
|
|
1196
|
+
`<button id="nav-${c.key}" onclick="go('${c.key}')">${iconSvg(c.icon)}<span>${escapeHtml(c.label)}</span></button>`).join(""));
|
|
1197
|
+
setHTML("views", CHAPTERS.map((c) => `<section id="view-${c.key}" class="hide"></section>`).join("")
|
|
1198
|
+
+ `<section id="view-menu" class="hide"><div id="menuBody"></div></section>`);
|
|
1199
|
+
}
|
|
1200
|
+
function boot() {
|
|
1201
|
+
applyTheme();
|
|
1202
|
+
document.body.classList.toggle("gmon", gmMode());
|
|
1203
|
+
buildNav();
|
|
1204
|
+
renderHeader();
|
|
1205
|
+
go(VIEWS[0]);
|
|
1206
|
+
setupInstall();
|
|
1207
|
+
}
|
|
1208
|
+
/* Injected at the end of <body>, so the DOM is already there — boot
|
|
1209
|
+
synchronously and the first paint is the rendered app. */
|
|
1210
|
+
if (typeof document !== "undefined") boot();
|