pf2e-spellbook 1.0.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 +30 -0
- package/README.md +196 -0
- package/data/legacy_aliases.js +38 -0
- package/data/overrides.js +276 -0
- package/data/spells.generated.js +57844 -0
- package/dist/icon.svg +8 -0
- package/dist/index.html +60088 -0
- package/dist/manifest.webmanifest +14 -0
- package/dist/sw.js +55 -0
- package/notice.md +248 -0
- package/package.json +28 -0
- package/src/classes.js +312 -0
- package/src/engine.js +1111 -0
- package/src/icon.svg +8 -0
- package/src/manifest.webmanifest +14 -0
- package/src/styles.css +353 -0
- package/src/sw.js +55 -0
- package/src/template.html +154 -0
- package/tools/build.py +62 -0
- package/tools/build_spells.py +391 -0
- package/tools/migrate_cleric_overrides.py +102 -0
- package/tools/test.mjs +536 -0
package/src/engine.js
ADDED
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
ENGINE — data-driven PF2e spellbook
|
|
3
|
+
Spell data: GENERATED_SPELLS (from Foundry pf2e, OGL/ORC)
|
|
4
|
+
Curated text: SPELL_OVERRIDES · Class config: CLASSES
|
|
5
|
+
============================================================ */
|
|
6
|
+
|
|
7
|
+
/* ---- Legacy (pre-Remaster) name support ---- */
|
|
8
|
+
const ALIASES = (typeof LEGACY_ALIASES!=="undefined") ? LEGACY_ALIASES : {};
|
|
9
|
+
/* Pull "(Remaster of X)" / "(Formerly X)" notes out of a description. */
|
|
10
|
+
function extractLegacyNames(desc){
|
|
11
|
+
const out=[];
|
|
12
|
+
const re=/\((?:Remaster(?:\s+replacement)?\s+of|Formerly|Previously)\s+([^)]+?)\)/gi;
|
|
13
|
+
let m;
|
|
14
|
+
while((m=re.exec(desc||""))){
|
|
15
|
+
m[1].split(/\s*(?:\/|,|\band\b)\s*/).forEach(n=>{
|
|
16
|
+
n=n.replace(/^[\s.,;:]+|[\s.,;:]+$/g,"").trim();
|
|
17
|
+
if(n) out.push(n);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* ---- Build the spell index (apply curated overrides + legacy names) ---- */
|
|
24
|
+
const SPELLS = GENERATED_SPELLS.map(s=>{
|
|
25
|
+
const o = (typeof SPELL_OVERRIDES!=="undefined") && SPELL_OVERRIDES[s.slug];
|
|
26
|
+
if(o){
|
|
27
|
+
s = Object.assign({}, s, {
|
|
28
|
+
description: o.description!==undefined ? o.description : s.description,
|
|
29
|
+
heightened: o.heightened!==undefined ? o.heightened : s.heightened,
|
|
30
|
+
curated: true
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const legacy=[].concat(ALIASES[s.slug]||[], extractLegacyNames(s.description));
|
|
34
|
+
// de-dupe (case-insensitive), drop any that equal the current name
|
|
35
|
+
const seen={}; s.legacy=[];
|
|
36
|
+
legacy.forEach(n=>{ const k=n.toLowerCase(); if(k && k!==s.name.toLowerCase() && !seen[k]){ seen[k]=1; s.legacy.push(n); } });
|
|
37
|
+
return s;
|
|
38
|
+
});
|
|
39
|
+
const SPELL_BY_SLUG = {};
|
|
40
|
+
SPELLS.forEach(s=>{ SPELL_BY_SLUG[s.slug]=s; });
|
|
41
|
+
function legacyNote(s){ return (s.legacy&&s.legacy.length) ? ("formerly "+s.legacy.join(" / ")) : ""; }
|
|
42
|
+
function ritualLine(s){
|
|
43
|
+
if(!s.ritual) return "";
|
|
44
|
+
const p=s.ritualPrimary?` <b>Primary</b> ${escapeHtml(s.ritualPrimary)}`:"";
|
|
45
|
+
const sec=s.ritualSecondary?` <b>Secondary</b> ${escapeHtml(s.ritualSecondary)}`:"";
|
|
46
|
+
return `<div class="meta"><span class="ritualtag">Ritual</span>${p}${sec}</div>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ---- Data version stamp ---- */
|
|
50
|
+
const META = (typeof GENERATED_META!=="undefined") ? GENERATED_META : {};
|
|
51
|
+
function dataStampText(){
|
|
52
|
+
if(!META.generated) return "";
|
|
53
|
+
return `Spell data ${META.generated}${META.sourceCommit?" · "+META.sourceCommit:""} · ${META.count} entries`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ============================================================
|
|
57
|
+
STATE
|
|
58
|
+
============================================================ */
|
|
59
|
+
const LS_KEY="pf2eSpellbook.v3";
|
|
60
|
+
const OLD_KEY="pf2eSpellbook.v2";
|
|
61
|
+
|
|
62
|
+
function uid(){ return "c"+Date.now().toString(36)+Math.random().toString(36).slice(2,6); }
|
|
63
|
+
function defaultStateFields(){
|
|
64
|
+
return { classId:null, name:"", level:3, keyMod:3, fontMod:3, font:"heal",
|
|
65
|
+
bloodline:"draconic", patronTradition:"occult", focusPool:1, doctrine:"cloistered",
|
|
66
|
+
prepared:null, cast:{}, sustaining:[] };
|
|
67
|
+
}
|
|
68
|
+
function blankChar(){ return Object.assign({id:uid()}, defaultStateFields()); }
|
|
69
|
+
|
|
70
|
+
let library = loadLibrary();
|
|
71
|
+
let state = library.characters[library.activeId];
|
|
72
|
+
|
|
73
|
+
function loadLibrary(){
|
|
74
|
+
try{
|
|
75
|
+
const v3=JSON.parse(localStorage.getItem(LS_KEY));
|
|
76
|
+
if(v3 && v3.characters && Object.keys(v3.characters).length) return normalizeLib(v3);
|
|
77
|
+
}catch(e){}
|
|
78
|
+
try{ // migrate a single v2 character
|
|
79
|
+
const v2=JSON.parse(localStorage.getItem(OLD_KEY));
|
|
80
|
+
if(v2 && v2.classId){ const c=Object.assign(blankChar(), v2); return {characters:{[c.id]:c}, activeId:c.id}; }
|
|
81
|
+
}catch(e){}
|
|
82
|
+
const c=blankChar();
|
|
83
|
+
return {characters:{[c.id]:c}, activeId:c.id};
|
|
84
|
+
}
|
|
85
|
+
function normalizeLib(lib){
|
|
86
|
+
Object.keys(lib.characters).forEach(id=>{ lib.characters[id]=Object.assign(defaultStateFields(), lib.characters[id], {id}); });
|
|
87
|
+
if(!lib.characters[lib.activeId]) lib.activeId=Object.keys(lib.characters)[0];
|
|
88
|
+
return lib;
|
|
89
|
+
}
|
|
90
|
+
let _storageWarned=false;
|
|
91
|
+
function saveState(){
|
|
92
|
+
library.characters[state.id]=state;
|
|
93
|
+
try{ localStorage.setItem(LS_KEY, JSON.stringify(library)); }
|
|
94
|
+
catch(e){
|
|
95
|
+
if(!_storageWarned && typeof toast==="function"){ toast("⚠ Couldn't save — storage full or disabled"); _storageWarned=true; }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ============================================================
|
|
100
|
+
CLASS-AWARE HELPERS
|
|
101
|
+
============================================================ */
|
|
102
|
+
function activeClass(){ return CLASSES[state.classId] || null; }
|
|
103
|
+
function activeTradition(){
|
|
104
|
+
const c=activeClass(); if(!c) return null;
|
|
105
|
+
if(c.traditionFrom==="bloodline"){ const b=BLOODLINES[state.bloodline]; return b?b.tradition:"arcane"; }
|
|
106
|
+
if(c.traditionFrom==="patron"){ return state.patronTradition||c.defaultTradition||"occult"; }
|
|
107
|
+
return c.tradition;
|
|
108
|
+
}
|
|
109
|
+
function isSpontaneous(){ const c=activeClass(); return !!c && c.casting==="spontaneous"; }
|
|
110
|
+
function hasFeature(type){ const c=activeClass(); return !!(c && c.features && c.features.some(f=>f.type===type)); }
|
|
111
|
+
function getFeature(type){ const c=activeClass(); return c && c.features && c.features.find(f=>f.type===type); }
|
|
112
|
+
function getFeatures(type){ const c=activeClass(); return (c && c.features || []).filter(f=>f.type===type); }
|
|
113
|
+
|
|
114
|
+
/* Spells of the active tradition, excluding focus spells. */
|
|
115
|
+
function classSpells(){ const t=activeTradition(); if(!t) return []; return SPELLS.filter(s=> !s.focus && s.traditions.includes(t)); }
|
|
116
|
+
function spellsByRank(r){ return classSpells().filter(s=>s.rank===r); }
|
|
117
|
+
function findSpell(slug){ return SPELL_BY_SLUG[slug]; }
|
|
118
|
+
|
|
119
|
+
/* Focus spells available to the active class. */
|
|
120
|
+
function classFocusSpells(){ const c=activeClass(); if(!c) return []; return SPELLS.filter(s=> s.focus && s.classTrait===c.id); }
|
|
121
|
+
function hasFocus(){ return classFocusSpells().length>0; }
|
|
122
|
+
/* Focus spells auto-heighten to half your level, rounded up. */
|
|
123
|
+
function focusRank(){ return Math.max(1, Math.min(10, Math.ceil(Number(state.level)/2))); }
|
|
124
|
+
|
|
125
|
+
function classSlots(level){
|
|
126
|
+
const c=activeClass(); if(!c) return {};
|
|
127
|
+
if(c.slots==="full") return fullCasterSlots(level);
|
|
128
|
+
if(c.slots==="partial") return partialCasterSlots(level);
|
|
129
|
+
if(c.slots==="psychic") return psychicSlots(level);
|
|
130
|
+
return c.slots[level]||{};
|
|
131
|
+
}
|
|
132
|
+
function highestRank(level){ const slots=classSlots(level); const ks=Object.keys(slots).map(Number); return ks.length?Math.max(...ks):0; }
|
|
133
|
+
|
|
134
|
+
function profBonus(){
|
|
135
|
+
const c=activeClass();
|
|
136
|
+
let tbl=(c&&c.prof)||FULL_CASTER_PROF;
|
|
137
|
+
if(c && c.doctrines && c.doctrines[state.doctrine]) tbl=c.doctrines[state.doctrine].prof;
|
|
138
|
+
const lvl=Number(state.level);
|
|
139
|
+
for(const [min,bonus] of tbl){ if(lvl>=min) return bonus; } return 2;
|
|
140
|
+
}
|
|
141
|
+
function spellDC(){ return 10 + Number(state.level) + profBonus() + Number(state.keyMod); }
|
|
142
|
+
function spellAtk(){ const v=Number(state.level)+profBonus()+Number(state.keyMod); return (v>=0?"+":"")+v; }
|
|
143
|
+
function fontCount(){ return Math.max(1, 1 + Number(state.fontMod)); }
|
|
144
|
+
|
|
145
|
+
/* ============================================================
|
|
146
|
+
FORMATTING HELPERS
|
|
147
|
+
============================================================ */
|
|
148
|
+
const ACTION_GLYPH={1:"◆",2:"◆◆",3:"◆◆◆"};
|
|
149
|
+
function actionLabel(a){
|
|
150
|
+
if(!a) return "";
|
|
151
|
+
switch(a){
|
|
152
|
+
case "1": return "◆ 1 action";
|
|
153
|
+
case "2": return "◆◆ 2 actions";
|
|
154
|
+
case "3": return "◆◆◆ 3 actions";
|
|
155
|
+
case "reaction": return "⤳ Reaction";
|
|
156
|
+
case "free": return "◇ Free action";
|
|
157
|
+
}
|
|
158
|
+
const m=String(a).match(/^([1-3])\s*(?:to|or|–|-)\s*([1-3])$/);
|
|
159
|
+
if(m) return `${ACTION_GLYPH[m[1]]}–${ACTION_GLYPH[m[2]]} ${m[1]} to ${m[2]} actions`;
|
|
160
|
+
return "🕑 "+a;
|
|
161
|
+
}
|
|
162
|
+
function titleCaseTrait(t){ return t.charAt(0).toUpperCase()+t.slice(1); }
|
|
163
|
+
function escapeHtml(s){ return (s||"").replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c])); }
|
|
164
|
+
function textToHtml(s){ return escapeHtml(s).replace(/\n/g,"<br>"); }
|
|
165
|
+
function rankLabelOf(s){ return s.rank===0 ? "Cantrip" : ("Rank "+s.rank); }
|
|
166
|
+
function cardClass(s){ return "rank"+s.rank; }
|
|
167
|
+
|
|
168
|
+
/* ---- Computed damage/healing at the cast rank (heightening) ---- */
|
|
169
|
+
function parseDice(f){ const m=String(f).match(/^(\d+)d(\d+)\s*([+-]\s*\d+)?$/); return m?{n:+m[1],d:+m[2],mod:m[3]?parseInt(m[3].replace(/\s/g,""),10):0}:null; }
|
|
170
|
+
function addDice(base, add, steps){
|
|
171
|
+
const b=parseDice(base), a=parseDice(add);
|
|
172
|
+
if(!b||!a||b.d!==a.d) return null; // can't cleanly scale (e.g. mixed dice)
|
|
173
|
+
const n=b.n+a.n*steps, mod=b.mod+a.mod*steps;
|
|
174
|
+
return `${n}d${b.d}${mod>0?"+"+mod:mod<0?mod:""}`;
|
|
175
|
+
}
|
|
176
|
+
function scaleDamage(s, castRank){
|
|
177
|
+
if(!s.damage||!s.damage.length) return null;
|
|
178
|
+
const steps = s.heighten ? Math.floor((Number(castRank)-s.rank)/s.heighten.interval) : 0;
|
|
179
|
+
return s.damage.map((bd,i)=>{
|
|
180
|
+
let f=bd.formula;
|
|
181
|
+
const add=s.heighten&&s.heighten.add&&s.heighten.add[i];
|
|
182
|
+
if(steps>0&&add){ const sc=addDice(bd.formula,add,steps); if(sc) f=sc; }
|
|
183
|
+
return {formula:f, type:bd.type, healing:bd.healing};
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function damageChipHTML(s, castRank){
|
|
187
|
+
const ds=scaleDamage(s, castRank); if(!ds) return "";
|
|
188
|
+
const parts=ds.map(d=>{
|
|
189
|
+
const icon=d.healing?"✚":"💥";
|
|
190
|
+
const ty=(d.type&&!d.healing)?" "+escapeHtml(d.type):"";
|
|
191
|
+
return `${icon} ${escapeHtml(d.formula)}${ty}`;
|
|
192
|
+
});
|
|
193
|
+
return `<span class="dmgchip">${parts.join(" ")}</span>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function spellCardHTML(s, opts){
|
|
197
|
+
opts=opts||{};
|
|
198
|
+
const cls = opts.cls || cardClass(s);
|
|
199
|
+
const m=[];
|
|
200
|
+
if(s.range) m.push(`<b>Range</b> ${escapeHtml(s.range)}`);
|
|
201
|
+
if(s.area) m.push(`<b>Area</b> ${escapeHtml(s.area)}`);
|
|
202
|
+
if(s.targets) m.push(`<b>Targets</b> ${escapeHtml(s.targets)}`);
|
|
203
|
+
if(s.duration) m.push(`<b>Duration</b> ${escapeHtml(s.duration)}`);
|
|
204
|
+
const meta = m.length?`<div class="meta">${m.join(" · ")}</div>`:"";
|
|
205
|
+
const traits=`<div class="traits">${s.traits.map(t=>`<span class="trait">${escapeHtml(titleCaseTrait(t))}</span>`).join("")}</div>`;
|
|
206
|
+
const saveTag = s.save ? `<span class="save-tag">🛡 ${escapeHtml(s.save)}</span>` : "";
|
|
207
|
+
const heighten = s.heightened ? `<div class="heighten"><b>Heightened</b> ${textToHtml(s.heightened)}</div>` : "";
|
|
208
|
+
const note=legacyNote(s)?`<div class="meta" style="margin:-2px 0 4px"><span class="formerly">${escapeHtml(legacyNote(s))}</span></div>`:"";
|
|
209
|
+
return `
|
|
210
|
+
<div class="card ${cls}">
|
|
211
|
+
<div class="spell-head">
|
|
212
|
+
<div class="spell-name">${escapeHtml(s.name)}</div>
|
|
213
|
+
<div class="rankpill">${rankLabelOf(s)}</div>
|
|
214
|
+
</div>
|
|
215
|
+
${note}
|
|
216
|
+
<div><span class="actions">${actionLabel(s.actions)}</span> ${saveTag}</div>
|
|
217
|
+
${damageChipHTML(s,s.rank)?`<div class="dmgline">${damageChipHTML(s,s.rank)}</div>`:""}
|
|
218
|
+
${traits}
|
|
219
|
+
${meta}
|
|
220
|
+
${ritualLine(s)}
|
|
221
|
+
<div class="desc">${textToHtml(s.description)}</div>
|
|
222
|
+
${heighten}
|
|
223
|
+
</div>`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function spellPreviewHTML(s, castRank){
|
|
227
|
+
if(!s) return "";
|
|
228
|
+
const heightNote=(castRank>s.rank)?` · cast at rank ${castRank}`:"";
|
|
229
|
+
const saveTag=s.save?` · 🛡 ${escapeHtml(s.save)}`:"";
|
|
230
|
+
const m=[];
|
|
231
|
+
if(s.range) m.push(`<b>Range</b> ${escapeHtml(s.range)}`);
|
|
232
|
+
if(s.area) m.push(`<b>Area</b> ${escapeHtml(s.area)}`);
|
|
233
|
+
if(s.targets) m.push(`<b>Targets</b> ${escapeHtml(s.targets)}`);
|
|
234
|
+
if(s.duration) m.push(`<b>Duration</b> ${escapeHtml(s.duration)}`);
|
|
235
|
+
const heighten=s.heightened?`<div class="heighten"><b>Heightened</b> ${textToHtml(s.heightened)}</div>`:"";
|
|
236
|
+
return `<div class="prep-preview-inner">
|
|
237
|
+
<div class="meta">${actionLabel(s.actions)}${saveTag}${heightNote}</div>
|
|
238
|
+
${m.length?`<div class="meta">${m.join(" · ")}</div>`:""}
|
|
239
|
+
<div class="desc">${textToHtml(s.description)}</div>
|
|
240
|
+
${heighten}
|
|
241
|
+
</div>`;
|
|
242
|
+
}
|
|
243
|
+
function updatePreview(sel){
|
|
244
|
+
const box=document.getElementById(sel.dataset.preview);
|
|
245
|
+
if(!box) return;
|
|
246
|
+
const s=findSpell(sel.value);
|
|
247
|
+
box.innerHTML = s ? spellPreviewHTML(s, Number(sel.dataset.castrank)) : "";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ============================================================
|
|
251
|
+
NAVIGATION
|
|
252
|
+
============================================================ */
|
|
253
|
+
const VIEWS=["today","prepare","browse","help"];
|
|
254
|
+
function go(view){
|
|
255
|
+
document.getElementById("view-classpick").classList.add("hide");
|
|
256
|
+
document.getElementById("view-menu").classList.add("hide");
|
|
257
|
+
document.querySelector("nav.bottom").classList.remove("hide");
|
|
258
|
+
document.querySelector("header.top").classList.remove("hide");
|
|
259
|
+
VIEWS.forEach(v=>{
|
|
260
|
+
document.getElementById("view-"+v).classList.toggle("hide", v!==view);
|
|
261
|
+
document.getElementById("nav-"+v).classList.toggle("on", v===view);
|
|
262
|
+
});
|
|
263
|
+
window.scrollTo(0,0);
|
|
264
|
+
if(view==="prepare") renderPrepare();
|
|
265
|
+
if(view==="today") renderToday();
|
|
266
|
+
if(view==="browse") renderBrowse();
|
|
267
|
+
if(view==="help") renderHelp();
|
|
268
|
+
}
|
|
269
|
+
function toast(msg){
|
|
270
|
+
const t=document.getElementById("toast");
|
|
271
|
+
t.textContent=msg; t.classList.add("show");
|
|
272
|
+
clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove("show"),1600);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ============================================================
|
|
276
|
+
CHARACTER MENU (multiple characters · export / import)
|
|
277
|
+
============================================================ */
|
|
278
|
+
function charSummary(c){
|
|
279
|
+
if(!c.classId) return "New character — no class yet";
|
|
280
|
+
const cl=CLASSES[c.classId];
|
|
281
|
+
return `${cl.icon} ${cl.name} · Level ${c.level}`;
|
|
282
|
+
}
|
|
283
|
+
function openMenu(){
|
|
284
|
+
VIEWS.forEach(v=>document.getElementById("view-"+v).classList.add("hide"));
|
|
285
|
+
document.getElementById("view-classpick").classList.add("hide");
|
|
286
|
+
document.querySelector("header.top").classList.remove("hide");
|
|
287
|
+
document.getElementById("view-menu").classList.remove("hide");
|
|
288
|
+
renderMenu();
|
|
289
|
+
window.scrollTo(0,0);
|
|
290
|
+
}
|
|
291
|
+
function closeMenu(){ if(!state.classId) showClassPicker(); else go(state.prepared?"today":"prepare"); }
|
|
292
|
+
function renderMenu(){
|
|
293
|
+
const ids=Object.keys(library.characters);
|
|
294
|
+
const list=ids.map(id=>{
|
|
295
|
+
const c=library.characters[id];
|
|
296
|
+
const active=id===library.activeId;
|
|
297
|
+
return `<div class="classcard ${active?"activechar":""}">
|
|
298
|
+
<div class="ic">${c.classId?CLASSES[c.classId].icon:"+"}</div>
|
|
299
|
+
<div class="txt" onclick="switchCharacter('${id}')" style="cursor:pointer">
|
|
300
|
+
<div class="nm">${escapeHtml(c.name||(c.classId?CLASSES[c.classId].name:"Unnamed"))}${active?` <span class="previewtag" style="background:var(--gold);color:#2a1c0c;border-color:var(--gold)">active</span>`:""}</div>
|
|
301
|
+
<div class="tl">${charSummary(c)}</div>
|
|
302
|
+
</div>
|
|
303
|
+
${ids.length>1?`<button class="rmrow" title="delete" onclick="deleteCharacter('${id}')">✕</button>`:""}
|
|
304
|
+
</div>`;
|
|
305
|
+
}).join("");
|
|
306
|
+
document.getElementById("menuList").innerHTML=list;
|
|
307
|
+
showInstallButton(!!deferredInstall);
|
|
308
|
+
const ds=document.getElementById("dataStamp"); if(ds) ds.textContent=dataStampText();
|
|
309
|
+
}
|
|
310
|
+
function newCharacter(){ const c=blankChar(); library.characters[c.id]=c; library.activeId=c.id; state=c; saveState(); showClassPicker(); }
|
|
311
|
+
function switchCharacter(id){
|
|
312
|
+
if(!library.characters[id]) return;
|
|
313
|
+
library.activeId=id; state=library.characters[id]; saveState();
|
|
314
|
+
if(!state.classId){ showClassPicker(); } else { renderHeader(); go(state.prepared?"today":"prepare"); }
|
|
315
|
+
}
|
|
316
|
+
function deleteCharacter(id){
|
|
317
|
+
if(!confirm("Delete this character? This can't be undone.")) return;
|
|
318
|
+
delete library.characters[id];
|
|
319
|
+
if(library.activeId===id){
|
|
320
|
+
const ids=Object.keys(library.characters);
|
|
321
|
+
if(ids.length){ library.activeId=ids[0]; state=library.characters[ids[0]]; }
|
|
322
|
+
else { const c=blankChar(); library.characters[c.id]=c; library.activeId=c.id; state=c; }
|
|
323
|
+
}
|
|
324
|
+
saveState(); renderHeader(); renderMenu();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* Export / import a single character as a portable code. */
|
|
328
|
+
function b64encode(str){ return btoa(unescape(encodeURIComponent(str))); }
|
|
329
|
+
function b64decode(str){ return decodeURIComponent(escape(atob(str))); }
|
|
330
|
+
function exportCharacter(){
|
|
331
|
+
const c=Object.assign({}, state); delete c.id;
|
|
332
|
+
const code="PF2E1:"+b64encode(JSON.stringify(c));
|
|
333
|
+
const io=document.getElementById("menuIO");
|
|
334
|
+
io.value=code; io.focus(); io.select();
|
|
335
|
+
if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(code).then(()=>toast("📋 Copied to clipboard")).catch(()=>toast("Code ready — copy it")); }
|
|
336
|
+
else toast("Code ready — copy it");
|
|
337
|
+
}
|
|
338
|
+
function importCharacter(){
|
|
339
|
+
let code=(document.getElementById("menuIO").value||"").trim();
|
|
340
|
+
if(!code){ toast("Paste a character code first"); return; }
|
|
341
|
+
try{
|
|
342
|
+
if(code.startsWith("PF2E1:")) code=code.slice(6);
|
|
343
|
+
const obj=JSON.parse(b64decode(code));
|
|
344
|
+
if(typeof obj!=="object"||!("level" in obj)) throw new Error("bad");
|
|
345
|
+
const c=Object.assign(blankChar(), obj, {id:uid()});
|
|
346
|
+
library.characters[c.id]=c; library.activeId=c.id; state=c; saveState();
|
|
347
|
+
toast("📥 Character imported");
|
|
348
|
+
if(!state.classId){ showClassPicker(); } else { renderHeader(); go(state.prepared?"today":"prepare"); }
|
|
349
|
+
}catch(e){ alert("That code didn't work — make sure you pasted the whole thing."); }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* ============================================================
|
|
353
|
+
CLASS PICKER
|
|
354
|
+
============================================================ */
|
|
355
|
+
function showClassPicker(){
|
|
356
|
+
VIEWS.forEach(v=>document.getElementById("view-"+v).classList.add("hide"));
|
|
357
|
+
document.querySelector("nav.bottom").classList.add("hide");
|
|
358
|
+
document.querySelector("header.top").classList.toggle("hide", !state.classId);
|
|
359
|
+
const sec=document.getElementById("view-classpick");
|
|
360
|
+
sec.classList.remove("hide");
|
|
361
|
+
sec.innerHTML=`
|
|
362
|
+
<h1 style="text-align:center">📖 PF2e Spellbook</h1>
|
|
363
|
+
<p class="meta" style="text-align:center;margin-bottom:18px">Choose your class to begin.</p>
|
|
364
|
+
${CLASS_ORDER.map(id=>{
|
|
365
|
+
const c=CLASSES[id];
|
|
366
|
+
return `<button class="classcard" onclick="chooseClass('${id}')">
|
|
367
|
+
<div class="ic">${c.icon}</div>
|
|
368
|
+
<div class="txt"><div class="nm">${c.name}${c.preview?` <span class="previewtag">preview</span>`:""}</div>
|
|
369
|
+
<div class="tl">${c.tagline}</div></div>
|
|
370
|
+
<div class="arr">→</div>
|
|
371
|
+
</button>`;
|
|
372
|
+
}).join("")}
|
|
373
|
+
<p class="meta" style="text-align:center;margin-top:18px;font-size:.8rem">Pathfinder 2e (Remaster) · unofficial fan tool<br>${escapeHtml(dataStampText())}</p>`;
|
|
374
|
+
window.scrollTo(0,0);
|
|
375
|
+
}
|
|
376
|
+
function chooseClass(id){
|
|
377
|
+
const switching = state.classId && state.classId!==id;
|
|
378
|
+
state.classId=id;
|
|
379
|
+
const c=CLASSES[id];
|
|
380
|
+
if(switching || !state.prepared){ state.prepared=null; state.cast={}; }
|
|
381
|
+
if(!hasFeature("divineFont")) state.font="heal";
|
|
382
|
+
if(c.traditionFrom==="patron" && !state.patronTradition) state.patronTradition=c.defaultTradition||"occult";
|
|
383
|
+
saveState();
|
|
384
|
+
renderHeader();
|
|
385
|
+
toast(`${c.icon} ${c.name} selected`);
|
|
386
|
+
go(state.prepared ? "today" : "prepare");
|
|
387
|
+
}
|
|
388
|
+
function openClassPicker(){ showClassPicker(); }
|
|
389
|
+
|
|
390
|
+
/* ============================================================
|
|
391
|
+
HEADER
|
|
392
|
+
============================================================ */
|
|
393
|
+
function renderHeader(){
|
|
394
|
+
const c=activeClass(); if(!c) return;
|
|
395
|
+
document.getElementById("charIcon").textContent=c.icon;
|
|
396
|
+
document.getElementById("charNameHead").textContent=state.name||c.name;
|
|
397
|
+
document.getElementById("charSub").textContent=
|
|
398
|
+
`${c.name} · Pathfinder 2e (Remaster) · ${TRADITION_LABEL[activeTradition()]} list`;
|
|
399
|
+
document.getElementById("stDC").textContent=spellDC();
|
|
400
|
+
document.getElementById("stAtk").textContent=spellAtk();
|
|
401
|
+
document.getElementById("stLvl").textContent=state.level;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* ============================================================
|
|
405
|
+
PREPARE VIEW (prepared loadout OR spontaneous repertoire)
|
|
406
|
+
============================================================ */
|
|
407
|
+
function buildModOptions(sel, val){
|
|
408
|
+
let html="";
|
|
409
|
+
for(let i=-2;i<=8;i++){ html+=`<option value="${i}" ${i==val?"selected":""}>${i>=0?"+":""}${i}</option>`; }
|
|
410
|
+
sel.innerHTML=html;
|
|
411
|
+
}
|
|
412
|
+
function setFont(f){ state.font=f; setFontButtons(); renderPrepSummary(); renderPrepDynamic(); }
|
|
413
|
+
function setFontButtons(){ document.querySelectorAll("#fontSeg button").forEach(b=>b.classList.toggle("on", b.dataset.font===state.font)); }
|
|
414
|
+
function setFontMod(v){ state.fontMod=Number(v); renderHeader(); renderPrepSummary(); renderPrepDynamic(); }
|
|
415
|
+
function setBloodline(v){ state.bloodline=v; renderHeader(); renderPrepSummary(); renderPrepDynamic(); }
|
|
416
|
+
function setDoctrine(v){ state.doctrine=v; renderHeader(); renderPrepSummary(); }
|
|
417
|
+
function setPatronTradition(v){ state.patronTradition=v; renderHeader(); renderPrepSummary(); renderPrepDynamic(); }
|
|
418
|
+
|
|
419
|
+
function renderPrepare(){
|
|
420
|
+
const c=activeClass();
|
|
421
|
+
document.getElementById("prepClassLine").innerHTML=
|
|
422
|
+
`Playing a <b>${c.name}</b> (${TRADITION_LABEL[activeTradition()]} · ${c.keyAbility}). `+
|
|
423
|
+
`<button class="linklike" onclick="openClassPicker()">Change class</button>`;
|
|
424
|
+
document.getElementById("prepPreviewBanner").classList.toggle("hide", !c.preview);
|
|
425
|
+
document.getElementById("keyAbilityName").textContent=c.keyAbility+" mod";
|
|
426
|
+
document.getElementById("prepDynamicHead").textContent = isSpontaneous() ? "② Build your repertoire" : "② Prepare your spells";
|
|
427
|
+
|
|
428
|
+
const lvlSel=document.getElementById("inLevel");
|
|
429
|
+
lvlSel.innerHTML=Array.from({length:20},(_,i)=>i+1)
|
|
430
|
+
.map(l=>`<option value="${l}" ${l==state.level?"selected":""}>Level ${l}</option>`).join("");
|
|
431
|
+
buildModOptions(document.getElementById("inKey"), state.keyMod);
|
|
432
|
+
document.getElementById("inName").value=state.name;
|
|
433
|
+
|
|
434
|
+
lvlSel.onchange=e=>{ state.level=Number(e.target.value); refreshPrepDynamic(); };
|
|
435
|
+
document.getElementById("inKey").onchange=e=>{ state.keyMod=Number(e.target.value); renderHeader(); renderPrepSummary(); };
|
|
436
|
+
document.getElementById("inName").oninput=e=>{ state.name=e.target.value; renderHeader(); };
|
|
437
|
+
|
|
438
|
+
renderCharExtra();
|
|
439
|
+
refreshPrepDynamic();
|
|
440
|
+
}
|
|
441
|
+
function refreshPrepDynamic(){ renderHeader(); renderPrepSummary(); renderPrepDynamic(); }
|
|
442
|
+
|
|
443
|
+
/* Character-section extras: font (cleric), bloodline (sorcerer), patron tradition (witch) */
|
|
444
|
+
function renderCharExtra(){
|
|
445
|
+
const c=activeClass();
|
|
446
|
+
let html="";
|
|
447
|
+
if(c.doctrines){
|
|
448
|
+
html+=`<label class="field"><span class="name">Doctrine</span>
|
|
449
|
+
<span class="hint">changes your spell DC at levels 7+</span>
|
|
450
|
+
<select id="inDoctrine" onchange="setDoctrine(this.value)">
|
|
451
|
+
${Object.keys(c.doctrines).map(k=>`<option value="${k}" ${k===state.doctrine?"selected":""}>${c.doctrines[k].label}</option>`).join("")}
|
|
452
|
+
</select></label>`;
|
|
453
|
+
}
|
|
454
|
+
if(hasFeature("divineFont")){
|
|
455
|
+
html+=`<label class="field"><span class="name">Charisma mod</span>
|
|
456
|
+
<span class="hint">sets your number of free Divine Font Heals/Harms</span>
|
|
457
|
+
<select id="inFontMod" onchange="setFontMod(this.value)"></select></label>
|
|
458
|
+
<label class="field"><span class="name">Divine Font</span>
|
|
459
|
+
<span class="hint">Which one did you choose at level 1?</span></label>
|
|
460
|
+
<div class="seg" id="fontSeg">
|
|
461
|
+
<button data-font="heal" class="heal ${state.font==="heal"?"on":""}" onclick="setFont('heal')">✚ Healing Font</button>
|
|
462
|
+
<button data-font="harm" class="harm ${state.font==="harm"?"on":""}" onclick="setFont('harm')">☠ Harmful Font</button>
|
|
463
|
+
</div>`;
|
|
464
|
+
}
|
|
465
|
+
if(c.traditionFrom==="bloodline"){
|
|
466
|
+
html+=`<label class="field"><span class="name">Bloodline</span>
|
|
467
|
+
<span class="hint">sets your magical tradition</span>
|
|
468
|
+
<select id="inBloodline" onchange="setBloodline(this.value)">
|
|
469
|
+
${Object.keys(BLOODLINES).map(k=>`<option value="${k}" ${k===state.bloodline?"selected":""}>${BLOODLINES[k].label} (${TRADITION_LABEL[BLOODLINES[k].tradition]})</option>`).join("")}
|
|
470
|
+
</select></label>`;
|
|
471
|
+
}
|
|
472
|
+
if(c.traditionFrom==="patron"){
|
|
473
|
+
const lbl=c.traditionChoiceLabel||"Patron's tradition";
|
|
474
|
+
const hint=c.traditionChoiceHint||"your patron decides which list you cast from";
|
|
475
|
+
html+=`<label class="field"><span class="name">${lbl}</span>
|
|
476
|
+
<span class="hint">${hint}</span>
|
|
477
|
+
<select id="inPatron" onchange="setPatronTradition(this.value)">
|
|
478
|
+
${["arcane","divine","occult","primal"].map(t=>`<option value="${t}" ${t===state.patronTradition?"selected":""}>${TRADITION_LABEL[t]}</option>`).join("")}
|
|
479
|
+
</select></label>`;
|
|
480
|
+
}
|
|
481
|
+
document.getElementById("charExtra").innerHTML=html;
|
|
482
|
+
if(hasFeature("divineFont")){ buildModOptions(document.getElementById("inFontMod"), state.fontMod); }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function renderPrepSummary(){
|
|
486
|
+
const c=activeClass();
|
|
487
|
+
const slots=classSlots(state.level);
|
|
488
|
+
const hr=highestRank(state.level);
|
|
489
|
+
const slotText=Object.keys(slots).map(Number).sort((a,b)=>a-b).map(r=>`<b>${slots[r]}×</b> rank ${r}`).join(" · ");
|
|
490
|
+
let extra="";
|
|
491
|
+
if(hasFeature("divineFont")){
|
|
492
|
+
const fontName=state.font==="heal"?"Heal":"Harm";
|
|
493
|
+
extra+=`<div style="margin-top:4px">${state.font==="heal"?"✚":"☠"} Divine Font: <b>${fontCount()}× ${fontName}</b> (free, heightened to rank ${hr})</div>`;
|
|
494
|
+
}
|
|
495
|
+
getFeatures("extraSlots").forEach(f=>{
|
|
496
|
+
extra+=`<div style="margin-top:4px">${f.icon} ${f.label}: <b>+1</b> per rank</div>`;
|
|
497
|
+
});
|
|
498
|
+
getFeatures("dailyResource").forEach(f=>{
|
|
499
|
+
extra+=`<div style="margin-top:4px">${f.icon} ${f.label}: <b>${f.uses}×</b>/day</div>`;
|
|
500
|
+
});
|
|
501
|
+
if(hasFocus()){
|
|
502
|
+
extra+=`<div style="margin-top:4px">🔵 Focus pool: <b>${state.focusPool||1}</b> Focus Point${(state.focusPool||1)>1?"s":""} (focus spells heighten to rank ${focusRank()})</div>`;
|
|
503
|
+
}
|
|
504
|
+
document.getElementById("prepSummary").innerHTML=`
|
|
505
|
+
<div class="meta" style="font-size:1rem">
|
|
506
|
+
<div>🎯 <b>Spell DC ${spellDC()}</b> · Spell attack ${spellAtk()}</div>
|
|
507
|
+
<div style="margin-top:8px">🪄 Cantrips ${isSpontaneous()?"known":""}: <b>${c.cantrips}</b></div>
|
|
508
|
+
<div style="margin-top:4px">📜 ${isSpontaneous()?"Slots (cast any known spell of that rank)":"Spell slots"}: ${slotText||"—"}</div>
|
|
509
|
+
${extra}
|
|
510
|
+
</div>`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function spellOptions(maxRank, selected, includeRank0){
|
|
514
|
+
let html=`<option value="">— choose —</option>`;
|
|
515
|
+
const start=includeRank0?0:1;
|
|
516
|
+
for(let r=maxRank;r>=start;r--){
|
|
517
|
+
const list=spellsByRank(r);
|
|
518
|
+
if(!list.length) continue;
|
|
519
|
+
const sorted=list.slice().sort((a,b)=>a.name.localeCompare(b.name));
|
|
520
|
+
const label = r===0?"Cantrips":(r===maxRank?`Rank ${r}`:`Rank ${r} (heightened to ${maxRank})`);
|
|
521
|
+
html+=`<optgroup label="${label}">`;
|
|
522
|
+
sorted.forEach(s=>{ const fm=(s.legacy&&s.legacy.length)?` (formerly ${s.legacy[0]})`:""; html+=`<option value="${s.slug}" ${s.slug===selected?"selected":""}>${escapeHtml(s.name+fm)}</option>`; });
|
|
523
|
+
html+=`</optgroup>`;
|
|
524
|
+
}
|
|
525
|
+
return html;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/* ---- Prepare body: cantrips + (slots | repertoire) + focus ---- */
|
|
529
|
+
function renderPrepDynamic(){
|
|
530
|
+
let html="";
|
|
531
|
+
getFeatures("note").forEach(f=>{ html+=`<div class="banner"><b>${f.title}</b> — ${f.text}</div>`; });
|
|
532
|
+
html+=cantripPicksHTML();
|
|
533
|
+
html+= isSpontaneous() ? repertoireHTML() : preparedSlotsHTML();
|
|
534
|
+
if(hasFocus()) html+=focusPicksHTML();
|
|
535
|
+
document.getElementById("prepDynamic").innerHTML=html;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function setFocusPool(v){ state.focusPool=Number(v); renderPrepSummary(); }
|
|
539
|
+
/* Searchable checklist — type your domain/bloodline/order to filter a long list. */
|
|
540
|
+
function focusPicksHTML(){
|
|
541
|
+
const prev=(state.prepared&&state.prepared.focus)||{};
|
|
542
|
+
const known=new Set(prev.spells||[]);
|
|
543
|
+
const pool=state.focusPool||1;
|
|
544
|
+
const list=classFocusSpells().slice().sort((a,b)=> a.rank-b.rank || a.name.localeCompare(b.name));
|
|
545
|
+
const rows=list.map(s=>{
|
|
546
|
+
const rank=s.rank===0?"cantrip":("R"+s.rank);
|
|
547
|
+
return `<label class="fcheck" data-hay="${escapeHtml((s.name+" "+s.traits.join(" ")).toLowerCase())}">
|
|
548
|
+
<input type="checkbox" class="focusChk" value="${s.slug}" ${known.has(s.slug)?"checked":""}>
|
|
549
|
+
<span class="fcname">${escapeHtml(s.name)}</span><span class="fcrank">${rank}</span></label>`;
|
|
550
|
+
}).join("");
|
|
551
|
+
return `<div class="slotgroup">
|
|
552
|
+
<h3>🔵 Focus spells <span class="count" style="font-weight:600;color:var(--muted)">(pool of ${pool})</span></h3>
|
|
553
|
+
<p class="meta">Focus spells cost <b>Focus Points</b> (shared pool, max 3) and auto-heighten to rank ${focusRank()}. <b>Refocus</b> restores points. Check the ones you know — type below to filter to your domain / bloodline / order.</p>
|
|
554
|
+
<label class="field" style="margin:8px 0"><span class="name">Focus Point pool</span>
|
|
555
|
+
<span class="hint">1 + 1 per extra focus feat, up to 3</span>
|
|
556
|
+
<select id="inFocusPool" onchange="setFocusPool(this.value)">
|
|
557
|
+
${[1,2,3].map(n=>`<option value="${n}" ${n===pool?"selected":""}>${n} Focus Point${n>1?"s":""}</option>`).join("")}
|
|
558
|
+
</select></label>
|
|
559
|
+
<input type="text" id="focusFilter" placeholder="🔍 filter focus spells (name or trait)…" oninput="filterFocusChecklist(this.value)" aria-label="filter focus spells">
|
|
560
|
+
<div class="focus-checklist" id="focusChecklist">${rows}</div>
|
|
561
|
+
</div>`;
|
|
562
|
+
}
|
|
563
|
+
function filterFocusChecklist(q){
|
|
564
|
+
q=(q||"").toLowerCase().trim();
|
|
565
|
+
document.querySelectorAll("#focusChecklist .fcheck").forEach(el=>{
|
|
566
|
+
el.classList.toggle("hide", !!q && !el.dataset.hay.includes(q));
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
function gatherFocus(){
|
|
570
|
+
const spells=[...document.querySelectorAll(".focusChk:checked")].map(c=>c.value);
|
|
571
|
+
return spells.length ? { pool:Number(state.focusPool||1), spells } : null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function cantripPicksHTML(){
|
|
575
|
+
const c=activeClass();
|
|
576
|
+
const prev=(state.prepared&&state.prepared.cantrips)||[];
|
|
577
|
+
let html=`<div class="slotgroup"><h3>🪄 Cantrips (pick ${c.cantrips})</h3>`;
|
|
578
|
+
for(let i=0;i<c.cantrips;i++){
|
|
579
|
+
const sel=prev[i]||"", pid="cprev"+i;
|
|
580
|
+
html+=`<div class="prep-slot">
|
|
581
|
+
<div class="slotrow"><div class="num">∞</div>
|
|
582
|
+
<select class="cantripSel" data-i="${i}" data-castrank="0" data-preview="${pid}" onchange="updatePreview(this)">${spellOptions(0, sel, true)}</select></div>
|
|
583
|
+
<div class="prep-preview" id="${pid}">${spellPreviewHTML(findSpell(sel),0)}</div></div>`;
|
|
584
|
+
}
|
|
585
|
+
return html+`</div>`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function preparedSlotsHTML(){
|
|
589
|
+
const slots=classSlots(state.level);
|
|
590
|
+
const hr=highestRank(state.level);
|
|
591
|
+
const prevSlots=(state.prepared&&state.prepared.slots)||{};
|
|
592
|
+
const prevExtra=(state.prepared&&state.prepared.extra)||{};
|
|
593
|
+
let html="";
|
|
594
|
+
|
|
595
|
+
if(hasFeature("divineFont")){
|
|
596
|
+
const feat=getFeature("divineFont");
|
|
597
|
+
const fontName=state.font==="heal"?"Heal":"Harm";
|
|
598
|
+
const fontSpell=findSpell(state.font==="heal"?feat.healSlug:feat.harmSlug);
|
|
599
|
+
html+=`<div class="slotgroup">
|
|
600
|
+
<h3>${state.font==="heal"?"✚":"☠"} Divine Font — ${fontName} (free slots)</h3>
|
|
601
|
+
<p class="meta">These ${fontCount()} castings are free and separate from your normal slots, usable only for <b>${fontName}</b>, auto-heightened to rank ${hr}.</p>`;
|
|
602
|
+
for(let i=0;i<fontCount();i++){
|
|
603
|
+
html+=`<div class="slotrow"><div class="num">${state.font==="heal"?"✚":"☠"}</div>
|
|
604
|
+
<select disabled><option>${fontName} (rank ${hr})</option></select></div>`;
|
|
605
|
+
}
|
|
606
|
+
html+=`<div class="prep-preview">${spellPreviewHTML(fontSpell, hr)}</div></div>`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const extraFeats=getFeatures("extraSlots");
|
|
610
|
+
Object.keys(slots).map(Number).sort((a,b)=>b-a).forEach(r=>{
|
|
611
|
+
const n=slots[r];
|
|
612
|
+
const prev=prevSlots[r]||[];
|
|
613
|
+
html+=`<div class="slotgroup"><h3>📜 Rank ${r} slots (${n})</h3>`;
|
|
614
|
+
for(let i=0;i<n;i++){
|
|
615
|
+
const sel=prev[i]||"", pid="sprev"+r+"_"+i;
|
|
616
|
+
html+=`<div class="prep-slot">
|
|
617
|
+
<div class="slotrow"><div class="num">${i+1}</div>
|
|
618
|
+
<select class="slotSel" data-rank="${r}" data-i="${i}" data-castrank="${r}" data-preview="${pid}" onchange="updatePreview(this)">${spellOptions(r, sel, false)}</select></div>
|
|
619
|
+
<div class="prep-preview" id="${pid}">${spellPreviewHTML(findSpell(sel),r)}</div></div>`;
|
|
620
|
+
}
|
|
621
|
+
// extra (e.g. wizard school) slots for this rank
|
|
622
|
+
extraFeats.forEach(f=>{
|
|
623
|
+
const ex=(prevExtra[f.key]&&prevExtra[f.key][r]&&prevExtra[f.key][r][0])||"";
|
|
624
|
+
const pid="xprev"+f.key+r;
|
|
625
|
+
html+=`<div class="prep-slot">
|
|
626
|
+
<div class="slotrow"><div class="num">${f.icon}</div>
|
|
627
|
+
<select class="extraSel" data-key="${f.key}" data-rank="${r}" data-castrank="${r}" data-preview="${pid}" onchange="updatePreview(this)">${spellOptions(r, ex, false)}</select></div>
|
|
628
|
+
<div class="prep-preview" id="${pid}">${spellPreviewHTML(findSpell(ex),r)}</div></div>`;
|
|
629
|
+
});
|
|
630
|
+
html+=`</div>`;
|
|
631
|
+
});
|
|
632
|
+
return html;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let repUID=0;
|
|
636
|
+
function repRowHTML(rank, sel, sig){
|
|
637
|
+
const pid="rprev"+(repUID++);
|
|
638
|
+
const sigBtn = rank>=1 ? `<button type="button" class="sigstar ${sig?"on":""}" data-sig="${sig?1:0}" title="signature spell" aria-label="toggle signature spell" aria-pressed="${sig?"true":"false"}" onclick="toggleSig(this)">${sig?"★":"☆"}</button>` : "";
|
|
639
|
+
return `<div class="prep-slot rep-row">
|
|
640
|
+
<div class="slotrow">
|
|
641
|
+
${sigBtn}
|
|
642
|
+
<select class="repSel" data-rank="${rank}" data-castrank="${rank}" data-preview="${pid}" onchange="updatePreview(this)">${spellOptions(rank, sel, rank===0)}</select>
|
|
643
|
+
<button type="button" class="rmrow" title="remove" aria-label="remove spell" onclick="removeRepRow(this)">✕</button>
|
|
644
|
+
</div>
|
|
645
|
+
<div class="prep-preview" id="${pid}">${spellPreviewHTML(findSpell(sel),rank)}</div>
|
|
646
|
+
</div>`;
|
|
647
|
+
}
|
|
648
|
+
function repertoireHTML(){
|
|
649
|
+
const slots=classSlots(state.level);
|
|
650
|
+
const hr=highestRank(state.level);
|
|
651
|
+
const prevRep=(state.prepared&&state.prepared.repertoire)||{};
|
|
652
|
+
let html=`<p class="meta">Add the spells your character knows of each rank. ★ marks a <b>signature spell</b> (castable in higher slots). There's no fixed limit here — match your character sheet.</p>`;
|
|
653
|
+
for(let r=hr;r>=1;r--){
|
|
654
|
+
const known=prevRep[r]||[];
|
|
655
|
+
const count=Math.max(known.length, slots[r]||0);
|
|
656
|
+
if(!count) continue; // no slots of this rank (e.g. a summoner's shed low ranks) → no repertoire of that rank
|
|
657
|
+
html+=`<div class="slotgroup" data-reprank="${r}">
|
|
658
|
+
<h3>📜 Rank ${r} spells known <span class="count" style="font-weight:600;color:var(--muted)">(${slots[r]||0} slot${(slots[r]||0)===1?"":"s"}/day)</span></h3>
|
|
659
|
+
<div class="repRows" id="repRows-${r}">`;
|
|
660
|
+
for(let i=0;i<count;i++){
|
|
661
|
+
const e=known[i]||{};
|
|
662
|
+
html+=repRowHTML(r, e.slug||"", !!e.sig);
|
|
663
|
+
}
|
|
664
|
+
html+=`</div><button type="button" class="btn secondary addrow" onclick="addRepertoireRow(${r})">+ Add a rank ${r} spell</button></div>`;
|
|
665
|
+
}
|
|
666
|
+
return html;
|
|
667
|
+
}
|
|
668
|
+
function addRepertoireRow(rank){
|
|
669
|
+
const box=document.getElementById("repRows-"+rank);
|
|
670
|
+
if(box) box.insertAdjacentHTML("beforeend", repRowHTML(rank, "", false));
|
|
671
|
+
}
|
|
672
|
+
function removeRepRow(btn){ const row=btn.closest(".rep-row"); if(row) row.remove(); }
|
|
673
|
+
function toggleSig(btn){ const on=btn.dataset.sig==="1"; btn.dataset.sig=on?"0":"1"; btn.classList.toggle("on",!on); btn.textContent=on?"☆":"★"; }
|
|
674
|
+
|
|
675
|
+
/* ---- Save ---- */
|
|
676
|
+
function gatherRepertoire(){
|
|
677
|
+
const rep={};
|
|
678
|
+
document.querySelectorAll(".repSel").forEach(sel=>{
|
|
679
|
+
if(!sel.value) return;
|
|
680
|
+
const r=sel.dataset.rank;
|
|
681
|
+
const star=sel.closest(".slotrow").querySelector(".sigstar");
|
|
682
|
+
const sig = star ? star.dataset.sig==="1" : false;
|
|
683
|
+
(rep[r]=rep[r]||[]).push({slug:sel.value, sig});
|
|
684
|
+
});
|
|
685
|
+
return rep;
|
|
686
|
+
}
|
|
687
|
+
function savePrep(){
|
|
688
|
+
const cantrips=[...document.querySelectorAll(".cantripSel")].map(s=>s.value).filter(Boolean);
|
|
689
|
+
const focus=hasFocus()?gatherFocus():null;
|
|
690
|
+
state.cast={};
|
|
691
|
+
if(isSpontaneous()){
|
|
692
|
+
state.prepared={ type:"spontaneous", cantrips, repertoire:gatherRepertoire() };
|
|
693
|
+
}else{
|
|
694
|
+
const slots={};
|
|
695
|
+
document.querySelectorAll(".slotSel").forEach(sel=>{ const r=sel.dataset.rank; (slots[r]=slots[r]||[]).push(sel.value); });
|
|
696
|
+
const extra={};
|
|
697
|
+
document.querySelectorAll(".extraSel").forEach(sel=>{ const k=sel.dataset.key,r=sel.dataset.rank; extra[k]=extra[k]||{}; (extra[k][r]=extra[k][r]||[]).push(sel.value); });
|
|
698
|
+
const hr=highestRank(state.level);
|
|
699
|
+
state.prepared={ type:"prepared", cantrips, slots, extra };
|
|
700
|
+
if(hasFeature("divineFont")){
|
|
701
|
+
const feat=getFeature("divineFont");
|
|
702
|
+
state.prepared.divineFont={ font:state.font, fontName:state.font==="heal"?"Heal":"Harm",
|
|
703
|
+
fontSlug:(state.font==="heal"?feat.healSlug:feat.harmSlug), fontRank:hr, fontCount:fontCount() };
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if(focus) state.prepared.focus=focus;
|
|
707
|
+
saveState();
|
|
708
|
+
toast("✨ Prepared for the day!");
|
|
709
|
+
go("today");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/* ============================================================
|
|
713
|
+
TODAY VIEW
|
|
714
|
+
============================================================ */
|
|
715
|
+
function castKey(kind,rank,i){ return kind+":"+rank+":"+i; }
|
|
716
|
+
function todayHeaderHTML(){
|
|
717
|
+
const c=activeClass();
|
|
718
|
+
return `<div class="card" style="text-align:center">
|
|
719
|
+
<div style="font-size:1.1rem"><b>${escapeHtml(state.name||c.name)}</b> · ${c.name} · Level ${state.level}</div>
|
|
720
|
+
<div class="meta" style="font-size:1rem;margin-top:6px">Spell DC <b>${spellDC()}</b> · Spell attack <b>${spellAtk()}</b></div>
|
|
721
|
+
<button class="btn secondary" style="margin-top:12px" onclick="newDay()">🌅 Rest & reset the day</button>
|
|
722
|
+
</div>`;
|
|
723
|
+
}
|
|
724
|
+
function dailyResourceHTML(){
|
|
725
|
+
let html="";
|
|
726
|
+
getFeatures("dailyResource").forEach(f=>{
|
|
727
|
+
const key="resource:"+f.key;
|
|
728
|
+
const spent=state.cast[key]||0, remaining=f.uses-spent, done=remaining<=0;
|
|
729
|
+
let pips=""; for(let k=0;k<f.uses;k++){ pips+=`<span class="pip ${k<remaining?"full":"spent"}"></span>`; }
|
|
730
|
+
const btn=done?`<button class="castbtn zero" onclick="uncast('${key}')">↩ undo</button>`
|
|
731
|
+
:`<button class="castbtn" onclick="doCast('${key}',${f.uses})">Use</button>`;
|
|
732
|
+
html+=`<div class="cast-card ${done?"spent":""}">
|
|
733
|
+
<div class="cast-top"><div class="nm">${f.icon} ${f.label}</div><div class="uses">${pips} ${btn}</div></div>
|
|
734
|
+
<div class="meta">${escapeHtml(f.note||"")}</div></div>`;
|
|
735
|
+
});
|
|
736
|
+
return html;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function renderToday(){
|
|
740
|
+
renderHeader();
|
|
741
|
+
const p=state.prepared;
|
|
742
|
+
const empty=document.getElementById("todayEmpty");
|
|
743
|
+
const content=document.getElementById("todayContent");
|
|
744
|
+
if(!p){ empty.classList.remove("hide"); content.classList.add("hide"); return; }
|
|
745
|
+
empty.classList.add("hide"); content.classList.remove("hide");
|
|
746
|
+
state.cast=state.cast||{};
|
|
747
|
+
content.innerHTML = sustainingBarHTML() + ((p.type==="spontaneous") ? renderTodaySpontaneous(p) : renderTodayPrepared(p)) + focusSectionHTML(p);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/* Focus spells: a shared Focus Point pool + at-will focus cantrips. */
|
|
751
|
+
function focusSectionHTML(p){
|
|
752
|
+
const f=p.focus;
|
|
753
|
+
if(!f || !f.spells || !f.spells.length) return "";
|
|
754
|
+
const pool=f.pool||1, key="focuspool";
|
|
755
|
+
const spent=state.cast[key]||0, remaining=pool-spent;
|
|
756
|
+
const fr=focusRank();
|
|
757
|
+
let pips=""; for(let k=0;k<pool;k++){ pips+=`<span class="pip ${k<remaining?"full":"spent"}"></span>`; }
|
|
758
|
+
const refocus=`<button class="castbtn ${spent>0?"":"zero"}" ${spent>0?"":"disabled"} style="margin-left:8px" onclick="refocus()">↻ Refocus</button>`;
|
|
759
|
+
let html=`<div class="sectionhead">🔵 Focus spells <span class="count">${remaining}/${pool} point${pool>1?"s":""}</span></div>
|
|
760
|
+
<div style="margin:2px 0 8px"><span class="uses">${pips}${refocus}</span></div><div class="divider"></div>`;
|
|
761
|
+
f.spells.forEach((slug,i)=>{
|
|
762
|
+
const s=findSpell(slug); if(!s) return;
|
|
763
|
+
const atWill = s.rank===0;
|
|
764
|
+
const out = !atWill && remaining<=0;
|
|
765
|
+
const castRank=fr;
|
|
766
|
+
const heightNote=(castRank>s.rank)?`<span class="meta"> · ↑ heightened to rank ${castRank}</span>`:"";
|
|
767
|
+
const btn = atWill ? `<span class="unlim">∞ at will</span>`
|
|
768
|
+
: (out ? `<button class="castbtn zero" disabled>No points</button>`
|
|
769
|
+
: `<button class="castbtn" onclick="castFocus(${pool})">Cast</button>`);
|
|
770
|
+
const detailsId="f_"+i;
|
|
771
|
+
html+=`<div class="cast-card ${out?"spent":""}" style="border-left-color:#9b7fd6">
|
|
772
|
+
<div class="cast-top"><div class="nm">🔵 ${escapeHtml(s.name)} ${heightNote}</div><div class="uses">${btn}</div></div>
|
|
773
|
+
<div class="meta">${actionLabel(s.actions)}${s.save?` · 🛡 ${escapeHtml(s.save)}`:""}${s.range?` · ${escapeHtml(s.range)}`:""}</div>${damageChipHTML(s,castRank)?`<div class="dmgline">${damageChipHTML(s,castRank)}</div>`:""}
|
|
774
|
+
<button class="detailsbtn" onclick="toggleDetails('${detailsId}',this)">Show details ▾</button>
|
|
775
|
+
<div id="${detailsId}" class="hide" style="margin-top:10px">${spellCardHTML(s)}</div></div>`;
|
|
776
|
+
});
|
|
777
|
+
return html;
|
|
778
|
+
}
|
|
779
|
+
function castFocus(pool){ const key="focuspool"; state.cast[key]=Math.min(pool,(state.cast[key]||0)+1); saveState(); renderToday(); }
|
|
780
|
+
function refocus(){ state.cast["focuspool"]=0; saveState(); renderToday(); toast("🔵 Refocused"); }
|
|
781
|
+
|
|
782
|
+
function renderTodayPrepared(p){
|
|
783
|
+
let html=todayHeaderHTML();
|
|
784
|
+
html+=dailyResourceHTML();
|
|
785
|
+
|
|
786
|
+
if(p.divineFont){
|
|
787
|
+
const f=p.divineFont; const fontSpell=findSpell(f.fontSlug);
|
|
788
|
+
html+=`<div class="sectionhead">${f.font==="heal"?"✚":"☠"} Divine Font — ${f.fontName} <span class="count">free</span></div><div class="divider"></div>`;
|
|
789
|
+
html+=castableCard(fontSpell,"font",f.fontRank,f.fontCount,f.fontRank,0,{cls:f.font});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if(p.cantrips && p.cantrips.length){
|
|
793
|
+
html+=`<div class="sectionhead">🪄 Cantrips <span class="count">unlimited</span></div><div class="divider"></div>`;
|
|
794
|
+
p.cantrips.forEach((slug,i)=>{ const s=findSpell(slug); if(!s) return; html+=castableCard(s,"cantrip",0,Infinity,0,i); });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const extraFeats=getFeatures("extraSlots");
|
|
798
|
+
Object.keys(p.slots||{}).map(Number).sort((a,b)=>b-a).forEach(r=>{
|
|
799
|
+
const arr=(p.slots[r]||[]).filter(Boolean);
|
|
800
|
+
const extras=[];
|
|
801
|
+
extraFeats.forEach(f=>{ const v=p.extra&&p.extra[f.key]&&p.extra[f.key][r]&&p.extra[f.key][r][0]; if(v) extras.push({f,slug:v}); });
|
|
802
|
+
if(!arr.length && !extras.length) return;
|
|
803
|
+
html+=`<div class="sectionhead">📜 Rank ${r} spells <span class="count">${arr.length+extras.length} slot${(arr.length+extras.length)>1?"s":""}</span></div><div class="divider"></div>`;
|
|
804
|
+
(p.slots[r]||[]).forEach((slug,i)=>{ if(!slug) return; const s=findSpell(slug); if(!s) return; html+=castableCard(s,"slot",r,1,r,i); });
|
|
805
|
+
extras.forEach(({f,slug})=>{ const s=findSpell(slug); if(!s) return; html+=castableCard(s,"extra_"+f.key,r,1,r,0,{label:f.icon}); });
|
|
806
|
+
});
|
|
807
|
+
return html;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function renderTodaySpontaneous(p){
|
|
811
|
+
let html=todayHeaderHTML();
|
|
812
|
+
html+=dailyResourceHTML();
|
|
813
|
+
|
|
814
|
+
if(p.cantrips && p.cantrips.length){
|
|
815
|
+
html+=`<div class="sectionhead">🪄 Cantrips <span class="count">unlimited</span></div><div class="divider"></div>`;
|
|
816
|
+
p.cantrips.forEach((slug,i)=>{ const s=findSpell(slug); if(!s) return; html+=castableCard(s,"cantrip",0,Infinity,0,i); });
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const slots=classSlots(state.level);
|
|
820
|
+
Object.keys(slots).map(Number).sort((a,b)=>b-a).forEach(R=>{
|
|
821
|
+
const max=slots[R];
|
|
822
|
+
const key="pool:"+R;
|
|
823
|
+
const spent=state.cast[key]||0, remaining=max-spent;
|
|
824
|
+
// available: spells known at R + signature spells from lower ranks (heightened)
|
|
825
|
+
const known=(p.repertoire[R]||[]);
|
|
826
|
+
const sigLower=[];
|
|
827
|
+
for(let r=R-1;r>=1;r--){ (p.repertoire[r]||[]).forEach(e=>{ if(e.sig) sigLower.push({slug:e.slug, from:r}); }); }
|
|
828
|
+
if(!known.length && !sigLower.length) return;
|
|
829
|
+
|
|
830
|
+
let pips=""; for(let k=0;k<max;k++){ pips+=`<span class="pip ${k<remaining?"full":"spent"}"></span>`; }
|
|
831
|
+
const undo = spent>0 ? `<button class="castbtn zero" style="margin-left:8px" onclick="uncastPool(${R})">↩</button>` : "";
|
|
832
|
+
html+=`<div class="sectionhead">📜 Rank ${R} <span class="count">${remaining}/${max} slot${max>1?"s":""} left</span></div>
|
|
833
|
+
<div style="margin:2px 0 8px"><span class="uses">${pips}${undo}</span></div><div class="divider"></div>`;
|
|
834
|
+
|
|
835
|
+
known.forEach((e,i)=>{ const s=findSpell(e.slug); if(!s) return; html+=poolSpellCard(s,R,R,remaining,max,"k"+i,e.sig); });
|
|
836
|
+
sigLower.forEach((e,i)=>{ const s=findSpell(e.slug); if(!s) return; html+=poolSpellCard(s,R,R,remaining,max,"s"+i,true); });
|
|
837
|
+
});
|
|
838
|
+
return html;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/* Card for a single prepared/font slot (uses=1) or unlimited cantrip. */
|
|
842
|
+
function castableCard(s, kind, rank, uses, castRank, idx, opts){
|
|
843
|
+
opts=opts||{};
|
|
844
|
+
const idStr=(idx!==undefined?idx:0);
|
|
845
|
+
const key=castKey(kind,rank,idStr);
|
|
846
|
+
const spent=(state.cast[key]||0);
|
|
847
|
+
const unlimited=uses===Infinity;
|
|
848
|
+
const remaining=unlimited?Infinity:(uses-spent);
|
|
849
|
+
const isSpent=!unlimited && remaining<=0;
|
|
850
|
+
const cls = opts.cls || (isSpent?"":cardClass(s));
|
|
851
|
+
const heightNote=(castRank>s.rank)?`<span class="meta"> · cast at rank ${castRank}</span>`:"";
|
|
852
|
+
const tag = opts.label?`<span class="slottag">${opts.label}</span> `:"";
|
|
853
|
+
|
|
854
|
+
let usesHtml="";
|
|
855
|
+
if(unlimited){ usesHtml=`<span class="unlim">∞ at will</span>`; }
|
|
856
|
+
else if(uses===1){ usesHtml=isSpent?`<span class="meta">used</span>`:`<span class="pip full"></span>`; }
|
|
857
|
+
else{ for(let k=0;k<uses;k++){ usesHtml+=`<span class="pip ${k<remaining?"full":"spent"}"></span>`; } }
|
|
858
|
+
|
|
859
|
+
let btn="";
|
|
860
|
+
if(!unlimited){
|
|
861
|
+
btn=isSpent?`<button class="castbtn zero" onclick="uncast('${key}')">↩ undo</button>`
|
|
862
|
+
:`<button class="castbtn" onclick="doCast('${key}',${uses})">Cast</button>`;
|
|
863
|
+
}
|
|
864
|
+
const detailsId="d_"+kind.replace(/[^a-z0-9]/gi,"")+"_"+rank+"_"+idStr;
|
|
865
|
+
return `
|
|
866
|
+
<div class="cast-card ${cls} ${isSpent?"spent":""}">
|
|
867
|
+
<div class="cast-top">
|
|
868
|
+
<div class="nm">${tag}${escapeHtml(s.name)} ${heightNote}</div>
|
|
869
|
+
<div class="uses">${usesHtml} ${btn} ${sustainBtn(s)}</div>
|
|
870
|
+
</div>
|
|
871
|
+
<div class="meta">${actionLabel(s.actions)}${s.save?` · 🛡 ${escapeHtml(s.save)}`:""}${s.range?` · ${escapeHtml(s.range)}`:""}</div>${damageChipHTML(s,castRank)?`<div class="dmgline">${damageChipHTML(s,castRank)}</div>`:""}
|
|
872
|
+
<button class="detailsbtn" onclick="toggleDetails('${detailsId}',this)">Show details ▾</button>
|
|
873
|
+
<div id="${detailsId}" class="hide" style="margin-top:10px">${spellCardHTML(s,{cls})}</div>
|
|
874
|
+
</div>`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/* Card for a spontaneous spell drawing from a shared rank pool. */
|
|
878
|
+
function poolSpellCard(s, poolRank, castRank, remaining, max, uid, sig){
|
|
879
|
+
const cls=cardClass(s);
|
|
880
|
+
const out=remaining<=0;
|
|
881
|
+
const heightNote=(castRank>s.rank)?`<span class="meta"> · ↑ heightened to rank ${castRank}</span>`:"";
|
|
882
|
+
const star=sig?`<span class="sigstar on" style="border:none;background:none;padding:0">★</span> `:"";
|
|
883
|
+
const btn=out?`<button class="castbtn zero" disabled>No slots</button>`
|
|
884
|
+
:`<button class="castbtn" onclick="castPool(${poolRank},${max})">Cast</button>`;
|
|
885
|
+
const detailsId="p_"+poolRank+"_"+uid;
|
|
886
|
+
return `
|
|
887
|
+
<div class="cast-card ${cls} ${out?"spent":""}">
|
|
888
|
+
<div class="cast-top">
|
|
889
|
+
<div class="nm">${star}${escapeHtml(s.name)} ${heightNote}</div>
|
|
890
|
+
<div class="uses">${btn} ${sustainBtn(s)}</div>
|
|
891
|
+
</div>
|
|
892
|
+
<div class="meta">${actionLabel(s.actions)}${s.save?` · 🛡 ${escapeHtml(s.save)}`:""}${s.range?` · ${escapeHtml(s.range)}`:""}</div>${damageChipHTML(s,castRank)?`<div class="dmgline">${damageChipHTML(s,castRank)}</div>`:""}
|
|
893
|
+
<button class="detailsbtn" onclick="toggleDetails('${detailsId}',this)">Show details ▾</button>
|
|
894
|
+
<div id="${detailsId}" class="hide" style="margin-top:10px">${spellCardHTML(s)}</div>
|
|
895
|
+
</div>`;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function toggleDetails(id,btn){ const el=document.getElementById(id); const open=el.classList.toggle("hide")===false; btn.textContent=open?"Hide details ▴":"Show details ▾"; }
|
|
899
|
+
function doCast(key,max){ state.cast[key]=(state.cast[key]||0)+1; if(state.cast[key]>max) state.cast[key]=max; saveState(); renderToday(); }
|
|
900
|
+
function uncast(key){ state.cast[key]=Math.max(0,(state.cast[key]||0)-1); saveState(); renderToday(); }
|
|
901
|
+
function castPool(rank,max){ const key="pool:"+rank; state.cast[key]=Math.min(max,(state.cast[key]||0)+1); saveState(); renderToday(); }
|
|
902
|
+
function uncastPool(rank){ const key="pool:"+rank; state.cast[key]=Math.max(0,(state.cast[key]||0)-1); saveState(); renderToday(); }
|
|
903
|
+
function newDay(){ if(confirm("Reset all spent slots for a new day? Your prepared list stays the same.")){ state.cast={}; state.sustaining=[]; saveState(); renderToday(); toast("🌅 A new day dawns!"); } }
|
|
904
|
+
|
|
905
|
+
/* ---- Sustained-spell tracker ---- */
|
|
906
|
+
function sustainBtn(s){
|
|
907
|
+
if(!s || !s.sustained) return "";
|
|
908
|
+
const on=(state.sustaining||[]).includes(s.slug);
|
|
909
|
+
return on ? `<button class="susbtn on" onclick="endSustain('${s.slug}')">⏳ End</button>`
|
|
910
|
+
: `<button class="susbtn" onclick="startSustain('${s.slug}')">⏳ Sustain</button>`;
|
|
911
|
+
}
|
|
912
|
+
function startSustain(slug){ state.sustaining=state.sustaining||[]; if(!state.sustaining.includes(slug)){ state.sustaining.push(slug); saveState(); renderToday(); toast("⏳ Now sustaining"); } }
|
|
913
|
+
function endSustain(slug){ state.sustaining=(state.sustaining||[]).filter(x=>x!==slug); saveState(); renderToday(); }
|
|
914
|
+
function sustainingBarHTML(){
|
|
915
|
+
const list=(state.sustaining||[]).filter(slug=>findSpell(slug));
|
|
916
|
+
if(!list.length) return "";
|
|
917
|
+
return `<div class="card" style="border-left:6px solid var(--accent)">
|
|
918
|
+
<div class="sectionhead" style="margin:0 0 6px">⏳ Sustaining now <span class="count">spend an action each turn</span></div>
|
|
919
|
+
${list.map(slug=>{const s=findSpell(slug); return `<div class="susrow"><span>${escapeHtml(s.name)}</span><button class="castbtn zero" onclick="endSustain('${slug}')">✓ End</button></div>`;}).join("")}
|
|
920
|
+
</div>`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/* ============================================================
|
|
924
|
+
BROWSE VIEW
|
|
925
|
+
============================================================ */
|
|
926
|
+
let browseRank="all", browseSave="all", browseAction="all", browseTrait="", browseFiltersOpen=false;
|
|
927
|
+
function toggleBrowseFilters(){
|
|
928
|
+
browseFiltersOpen=!browseFiltersOpen;
|
|
929
|
+
const box=document.getElementById("browseFilters"), tog=document.getElementById("filterToggle");
|
|
930
|
+
box.classList.toggle("hide", !browseFiltersOpen);
|
|
931
|
+
tog.classList.toggle("on", browseFiltersOpen);
|
|
932
|
+
tog.setAttribute("aria-expanded", browseFiltersOpen?"true":"false");
|
|
933
|
+
if(browseFiltersOpen) renderBrowseFilters();
|
|
934
|
+
}
|
|
935
|
+
function setBrowseSave(v){ browseSave=v; renderBrowseFilters(); renderBrowse(); }
|
|
936
|
+
function setBrowseAction(v){ browseAction=v; renderBrowseFilters(); renderBrowse(); }
|
|
937
|
+
function setBrowseTrait(v){ browseTrait=(v||"").toLowerCase().trim(); renderBrowse(); }
|
|
938
|
+
function actionMatch(actions, f){
|
|
939
|
+
if(f==="all") return true;
|
|
940
|
+
if(f==="reaction") return actions==="reaction";
|
|
941
|
+
if(!actions) return false;
|
|
942
|
+
if(actions===f) return true;
|
|
943
|
+
const m=String(actions).match(/^([1-3])\s*(?:to|or|–|-)\s*([1-3])$/);
|
|
944
|
+
return m ? (+m[1]<=+f && +f<=+m[2]) : false;
|
|
945
|
+
}
|
|
946
|
+
function chipRow(label, current, opts, fn){
|
|
947
|
+
return `<div class="fgroup"><span class="flabel">${label}</span>`+
|
|
948
|
+
opts.map(([v,l])=>`<button class="chip ${current===v?"on":""}" onclick="${fn}('${v}')">${l}</button>`).join("")+`</div>`;
|
|
949
|
+
}
|
|
950
|
+
function renderBrowseFilters(){
|
|
951
|
+
const box=document.getElementById("browseFilters");
|
|
952
|
+
box.innerHTML =
|
|
953
|
+
chipRow("Save", browseSave, [["all","Any"],["fortitude","Fort"],["reflex","Reflex"],["will","Will"],["none","No save"]], "setBrowseSave")+
|
|
954
|
+
chipRow("Actions", browseAction, [["all","Any"],["1","◆"],["2","◆◆"],["3","◆◆◆"],["reaction","⤳"]], "setBrowseAction")+
|
|
955
|
+
`<label class="field" style="margin:8px 0 2px"><span class="name" style="font-size:.9rem">Trait contains</span>
|
|
956
|
+
<input type="text" id="traitFilter" placeholder="e.g. fire, healing, incapacitation" value="${escapeHtml(browseTrait)}" oninput="setBrowseTrait(this.value)"></label>`;
|
|
957
|
+
}
|
|
958
|
+
function renderRankChips(){
|
|
959
|
+
const ranks=[["all","All"],["0","Cantrips"]];
|
|
960
|
+
for(let r=1;r<=10;r++) ranks.push([String(r),"Rank "+r]);
|
|
961
|
+
if(hasFocus()) ranks.push(["focus","🔵 Focus"]);
|
|
962
|
+
ranks.push(["rituals","📜 Rituals"]);
|
|
963
|
+
document.getElementById("rankChips").innerHTML=
|
|
964
|
+
ranks.map(([v,l])=>`<button class="chip ${browseRank===v?"on":""}" onclick="setBrowseRank('${v}')">${l}</button>`).join("");
|
|
965
|
+
}
|
|
966
|
+
function setBrowseRank(r){ browseRank=r; renderRankChips(); renderBrowse(); }
|
|
967
|
+
function renderBrowse(){
|
|
968
|
+
renderRankChips();
|
|
969
|
+
const focusMode = browseRank==="focus";
|
|
970
|
+
const ritualMode = browseRank==="rituals";
|
|
971
|
+
document.getElementById("browseTitle").textContent= ritualMode
|
|
972
|
+
? "Rituals (any class)"
|
|
973
|
+
: focusMode
|
|
974
|
+
? `${activeClass().name} focus spells`
|
|
975
|
+
: `All ${TRADITION_LABEL[activeTradition()]} spells`;
|
|
976
|
+
const q=(document.getElementById("search").value||"").toLowerCase().trim();
|
|
977
|
+
let list = ritualMode ? SPELLS.filter(s=>s.ritual) : focusMode ? classFocusSpells() : classSpells();
|
|
978
|
+
if(!focusMode && !ritualMode && browseRank!=="all") list=list.filter(s=>s.rank===Number(browseRank));
|
|
979
|
+
if(q) list=list.filter(s=> s.name.toLowerCase().includes(q) || (s.description||"").toLowerCase().includes(q) || s.traits.join(" ").toLowerCase().includes(q) || (s.legacy||[]).some(n=>n.toLowerCase().includes(q)));
|
|
980
|
+
if(browseSave!=="all") list = (browseSave==="none") ? list.filter(s=>!s.save) : list.filter(s=>s.save && s.save.toLowerCase().includes(browseSave));
|
|
981
|
+
if(browseAction!=="all") list=list.filter(s=>actionMatch(s.actions, browseAction));
|
|
982
|
+
if(browseTrait) list=list.filter(s=>s.traits.some(t=>t.toLowerCase().includes(browseTrait)));
|
|
983
|
+
list.sort((a,b)=> a.rank-b.rank || a.name.localeCompare(b.name));
|
|
984
|
+
const out=document.getElementById("browseList");
|
|
985
|
+
if(!list.length){ out.innerHTML=`<div class="empty">No spells match “${escapeHtml(q)}”.</div>`; return; }
|
|
986
|
+
let html=`<p class="meta" style="margin:0 0 8px">${list.length} spell${list.length>1?"s":""} · tap one to expand</p>`;
|
|
987
|
+
let curRank=null;
|
|
988
|
+
list.forEach(s=>{
|
|
989
|
+
if(s.rank!==curRank){ curRank=s.rank; const lbl=curRank===0?(focusMode?"Focus cantrips":"Cantrips"):("Rank "+curRank);
|
|
990
|
+
html+=`<div class="sectionhead">${lbl} <span class="count">${list.filter(x=>x.rank===curRank).length}</span></div><div class="divider"></div>`; }
|
|
991
|
+
html+=browseRowHTML(s);
|
|
992
|
+
});
|
|
993
|
+
out.innerHTML=html;
|
|
994
|
+
}
|
|
995
|
+
/* Lightweight collapsed row; the full card renders on demand when tapped. */
|
|
996
|
+
function browseRowHTML(s){
|
|
997
|
+
const save=s.save?` · 🛡 ${escapeHtml(s.save)}`:"";
|
|
998
|
+
const note=legacyNote(s)?` <span class="formerly">${escapeHtml(legacyNote(s))}</span>`:"";
|
|
999
|
+
return `<div class="browse-row rank${s.rank}">
|
|
1000
|
+
<button class="browse-row-head" onclick="toggleBrowse('${s.slug}',this)">
|
|
1001
|
+
<span class="brn">${escapeHtml(s.name)}${s.focus?' <span class="brfocus">focus</span>':''}${note}</span>
|
|
1002
|
+
<span class="brmeta">${actionLabel(s.actions)}${save}</span>
|
|
1003
|
+
</button>
|
|
1004
|
+
<div class="browse-card hide" id="bc_${s.slug}"></div>
|
|
1005
|
+
</div>`;
|
|
1006
|
+
}
|
|
1007
|
+
function toggleBrowse(slug,btn){
|
|
1008
|
+
const box=document.getElementById("bc_"+slug);
|
|
1009
|
+
if(box.classList.contains("hide")){
|
|
1010
|
+
if(!box.innerHTML) box.innerHTML=spellCardHTML(findSpell(slug));
|
|
1011
|
+
box.classList.remove("hide"); btn.classList.add("open");
|
|
1012
|
+
} else { box.classList.add("hide"); btn.classList.remove("open"); }
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/* ============================================================
|
|
1016
|
+
HELP VIEW
|
|
1017
|
+
============================================================ */
|
|
1018
|
+
function renderHelp(){
|
|
1019
|
+
const c=activeClass();
|
|
1020
|
+
const items=(c.help||[]).concat(CORE_HELP);
|
|
1021
|
+
document.getElementById("helpClassName").textContent=c.name;
|
|
1022
|
+
document.getElementById("helpContent").innerHTML=items.map(([q,a])=>
|
|
1023
|
+
`<details class="help"><summary>${q}</summary><div class="body">${a}</div></details>`).join("");
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/* ============================================================
|
|
1027
|
+
INIT
|
|
1028
|
+
============================================================ */
|
|
1029
|
+
/* PWA / offline support.
|
|
1030
|
+
When hosted over http(s) (e.g. GitHub Pages) we register a service worker so
|
|
1031
|
+
the app keeps working offline and can be installed ("Add to Home Screen").
|
|
1032
|
+
The browser fires `beforeinstallprompt` when install is available; we capture
|
|
1033
|
+
it and reveal our own Install button instead of relying on a browser banner. */
|
|
1034
|
+
let deferredInstall=null;
|
|
1035
|
+
function isHeadless(){ return /jsdom/i.test((navigator&&navigator.userAgent)||""); }
|
|
1036
|
+
|
|
1037
|
+
function setupInstall(){
|
|
1038
|
+
try{
|
|
1039
|
+
if(isHeadless()) return; // test harness: skip browser-only wiring
|
|
1040
|
+
const proto=(location&&location.protocol)||"";
|
|
1041
|
+
if((proto==="http:"||proto==="https:")&&"serviceWorker" in navigator){
|
|
1042
|
+
window.addEventListener("load",()=>{
|
|
1043
|
+
navigator.serviceWorker.register("sw.js").catch(()=>{});
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
window.addEventListener("beforeinstallprompt",(e)=>{
|
|
1047
|
+
e.preventDefault(); // suppress the default mini-infobar
|
|
1048
|
+
deferredInstall=e; // stash it for our own button
|
|
1049
|
+
showInstallButton(true);
|
|
1050
|
+
});
|
|
1051
|
+
window.addEventListener("appinstalled",()=>{
|
|
1052
|
+
deferredInstall=null;
|
|
1053
|
+
showInstallButton(false);
|
|
1054
|
+
});
|
|
1055
|
+
}catch(e){ /* unsupported environment — offline copy still works */ }
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function showInstallButton(on){
|
|
1059
|
+
const b=document.getElementById("installBtn");
|
|
1060
|
+
if(b) b.style.display=on?"":"none";
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/* Trigger the browser's native install flow (only available after the browser
|
|
1064
|
+
has fired beforeinstallprompt — desktop Chrome/Edge, Android). */
|
|
1065
|
+
function installApp(){
|
|
1066
|
+
if(deferredInstall&&deferredInstall.prompt){
|
|
1067
|
+
deferredInstall.prompt();
|
|
1068
|
+
if(deferredInstall.userChoice&&deferredInstall.userChoice.then){
|
|
1069
|
+
deferredInstall.userChoice.then(()=>{ deferredInstall=null; showInstallButton(false); });
|
|
1070
|
+
}else{ deferredInstall=null; showInstallButton(false); }
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
alert("To install: open your browser menu and choose “Add to Home Screen” (or “Install app”). On iPhone/iPad use Safari’s Share button → “Add to Home Screen”.");
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/* Download the whole spellbook as one self-contained file the player can keep
|
|
1077
|
+
and open offline forever. Prefer fetching the served page (the clean inlined
|
|
1078
|
+
build); fall back to serialising the live DOM when fetch is unavailable
|
|
1079
|
+
(e.g. already opened from a file://). */
|
|
1080
|
+
function downloadOffline(){
|
|
1081
|
+
const save=(html)=>{
|
|
1082
|
+
try{
|
|
1083
|
+
const blob=new Blob([html],{type:"text/html"});
|
|
1084
|
+
const url=URL.createObjectURL(blob);
|
|
1085
|
+
const a=document.createElement("a");
|
|
1086
|
+
a.href=url; a.download="pf2e-spellbook.html";
|
|
1087
|
+
document.body.appendChild(a); a.click();
|
|
1088
|
+
document.body.removeChild(a);
|
|
1089
|
+
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
|
1090
|
+
}catch(e){ alert("Couldn’t start the download automatically. Use your browser’s “Save page as…” to keep an offline copy."); }
|
|
1091
|
+
};
|
|
1092
|
+
const fallback=()=>{
|
|
1093
|
+
const dt=document.doctype?"<!DOCTYPE html>\n":"";
|
|
1094
|
+
save(dt+document.documentElement.outerHTML);
|
|
1095
|
+
};
|
|
1096
|
+
try{
|
|
1097
|
+
if(typeof fetch==="function"){
|
|
1098
|
+
fetch(location.href,{cache:"no-store"})
|
|
1099
|
+
.then(r=>r.ok?r.text():Promise.reject())
|
|
1100
|
+
.then(t=>save(t))
|
|
1101
|
+
.catch(fallback);
|
|
1102
|
+
}else{ fallback(); }
|
|
1103
|
+
}catch(e){ fallback(); }
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function init(){
|
|
1107
|
+
setupInstall();
|
|
1108
|
+
if(!state.classId){ showClassPicker(); }
|
|
1109
|
+
else { renderHeader(); go(state.prepared?"today":"prepare"); }
|
|
1110
|
+
}
|
|
1111
|
+
init();
|