您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Color-codes PF2e creature stats on AoN (Monsters/NPCs/Hazards).
// ==UserScript== // @name AoN PF2e Creature Stat Tier Highlighter // @namespace https://2e.aonprd.com/ // @version 1.1.1 // @description Color-codes PF2e creature stats on AoN (Monsters/NPCs/Hazards). // @author leonissenbaum & ChatGPT // @match https://2e.aonprd.com/Monsters.aspx* // @match https://2e.aonprd.com/Creatures.aspx* // @match https://2e.aonprd.com/NPCs.aspx* // @match https://2e.aonprd.com/Hazards.aspx* // @run-at document-idle // @grant none // ==/UserScript== (function () { "use strict"; // ---------- CONFIG ---------- const DEBUG = false; const COLORS = { terrible: "#ff0000", low: "#ff8000", moderate: "#ffff54", high: "#3cff00", extreme: "#6cd8ff" }; const LIGHT_BG_ALPHA = 0.18, UNDERLINE_ALPHA = 0.8; const LEGEND_TOP_PX = 80; const LEGEND_RIGHT_PX = 12; // ---------- UTILS ---------- const log = (...a) => DEBUG && console.log("[PF2e tiers]", ...a); const norm = s => (s||"").replace(/\u00A0/g," ").replace(/[–−]/g,"-"); const readText = el => norm(el?.innerText || el?.textContent || ""); const fmt = x => (x>=0?`+${x}`:`${x}`); const rng = ([a,b]) => `${a}–${b}`; function hexToRgba(hex,a){const m=hex.replace("#","");const b=parseInt(m.length===3?m.split("").map(c=>c+c).join(""):m,16);return `rgba(${(b>>16)&255},${(b>>8)&255},${b&255},${a})`;} function makeBadge(text, tier, title="") { const color = COLORS[tier] || "#888"; const span = document.createElement("span"); span.className = `pf2-tier pf2-${tier}`; span.style.cssText = `padding:0 .20em;border-bottom:2px solid ${hexToRgba(color,UNDERLINE_ALPHA)};`+ `background:${hexToRgba(color,LIGHT_BG_ALPHA)};border-radius:.25em;color:inherit`; if (title) span.title = title; span.textContent = text; return span; } // ---------- GM CORE BENCHMARKS ---------- // Perception/Save mods reuse same table const PERC={ "-1":{e:9,h:8,m:5,l:2,t:0},0:{e:10,h:9,m:6,l:3,t:1},1:{e:11,h:10,m:7,l:4,t:2},2:{e:12,h:11,m:8,l:5,t:3},3:{e:14,h:12,m:9,l:6,t:4},4:{e:15,h:14,m:11,l:8,t:6},5:{e:17,h:15,m:12,l:9,t:7},6:{e:18,h:17,m:14,l:11,t:8},7:{e:20,h:18,m:15,l:12,t:10},8:{e:21,h:19,m:16,l:13,t:11},9:{e:23,h:21,m:18,l:15,t:12},10:{e:24,h:22,m:19,l:16,t:14},11:{e:26,h:24,m:21,l:18,t:15},12:{e:27,h:25,m:22,l:19,t:16},13:{e:29,h:26,m:23,l:20,t:18},14:{e:30,h:28,m:25,l:22,t:19},15:{e:32,h:29,m:26,l:23,t:20},16:{e:33,h:30,m:28,l:25,t:22},17:{e:35,h:32,m:29,l:26,t:23},18:{e:36,h:33,m:30,l:27,t:24},19:{e:38,h:35,m:32,l:29,t:26},20:{e:39,h:36,m:33,l:30,t:27},21:{e:41,h:38,m:35,l:32,t:28},22:{e:43,h:39,m:36,l:33,t:30},23:{e:44,h:40,m:37,l:34,t:31},24:{e:46,h:42,m:38,l:36,t:32} }; const AC={ "-1":{e:18,h:15,m:14,l:12},0:{e:19,h:16,m:15,l:13},1:{e:19,h:16,m:15,l:13},2:{e:21,h:18,m:17,l:15},3:{e:22,h:19,m:18,l:16},4:{e:24,h:21,m:20,l:18},5:{e:25,h:22,m:21,l:19},6:{e:27,h:24,m:23,l:21},7:{e:28,h:25,m:24,l:22},8:{e:30,h:27,m:26,l:24},9:{e:31,h:28,m:27,l:25},10:{e:33,h:30,m:29,l:27},11:{e:34,h:31,m:30,l:28},12:{e:36,h:33,m:32,l:30},13:{e:37,h:34,m:33,l:31},14:{e:39,h:36,m:35,l:33},15:{e:40,h:37,m:36,l:34},16:{e:42,h:39,m:38,l:36},17:{e:43,h:40,m:39,l:37},18:{e:45,h:42,m:41,l:39},19:{e:46,h:43,m:42,l:40},20:{e:48,h:45,m:44,l:42},21:{e:49,h:46,m:45,l:43},22:{e:51,h:48,m:47,l:45},23:{e:52,h:49,m:48,l:46},24:{e:54,h:51,m:50,l:48} }; const SAVES=PERC; const HP={ "-1":{h:[9,9],m:[7,8],l:[5,6]},0:{h:[17,20],m:[14,16],l:[11,13]},1:{h:[24,26],m:[19,21],l:[14,16]},2:{h:[36,40],m:[28,32],l:[21,25]},3:{h:[53,59],m:[42,48],l:[31,37]},4:{h:[72,78],m:[57,63],l:[42,48]},5:{h:[91,97],m:[72,78],l:[53,59]},6:{h:[115,123],m:[91,99],l:[67,75]},7:{h:[140,148],m:[111,119],l:[82,90]},8:{h:[165,173],m:[131,139],l:[97,105]},9:{h:[190,198],m:[151,159],l:[112,120]},10:{h:[215,223],m:[171,179],l:[127,135]},11:{h:[240,248],m:[191,199],l:[142,150]},12:{h:[265,273],m:[211,219],l:[157,165]},13:{h:[290,298],m:[231,239],l:[172,180]},14:{h:[315,323],m:[251,259],l:[187,195]},15:{h:[340,348],m:[271,279],l:[202,210]},16:{h:[365,373],m:[291,299],l:[217,225]},17:{h:[390,398],m:[311,319],l:[232,240]},18:{h:[415,423],m:[331,339],l:[247,255]},19:{h:[440,448],m:[351,359],l:[262,270]},20:{h:[465,473],m:[371,379],l:[277,285]},21:{h:[495,505],m:[395,405],l:[295,305]},22:{h:[532,544],m:[424,436],l:[317,329]},23:{h:[569,581],m:[454,466],l:[339,351]},24:{h:[617,633],m:[492,508],l:[367,383]} }; const SKILLS={ "-1":{e:8,h:5,m:4,l:[1,2]},0:{e:9,h:6,m:5,l:[2,3]},1:{e:10,h:7,m:6,l:[3,4]},2:{e:11,h:8,m:7,l:[4,5]},3:{e:13,h:10,m:9,l:[5,7]},4:{e:15,h:12,m:10,l:[7,8]},5:{e:16,h:13,m:12,l:[8,10]},6:{e:18,h:15,m:13,l:[9,11]},7:{e:20,h:17,m:15,l:[11,13]},8:{e:21,h:18,m:16,l:[12,14]},9:{e:23,h:20,m:18,l:[13,16]},10:{e:25,h:22,m:19,l:[15,17]},11:{e:26,h:23,m:21,l:[16,19]},12:{e:28,h:25,m:22,l:[17,20]},13:{e:30,h:27,m:24,l:[19,22]},14:{e:31,h:28,m:25,l:[20,23]},15:{e:33,h:30,m:27,l:[21,25]},16:{e:35,h:32,m:28,l:[23,26]},17:{e:36,h:33,m:30,l:[24,28]},18:{e:38,h:35,m:31,l:[25,29]},19:{e:40,h:37,m:33,l:[27,31]},20:{e:41,h:38,m:34,l:[28,32]},21:{e:43,h:40,m:36,l:[29,34]},22:{e:45,h:42,m:37,l:[31,35]},23:{e:46,h:43,m:38,l:[32,36]},24:{e:48,h:45,m:40,l:[33,38]} }; const ATK={ "-1":{e:10,h:8,m:6,l:4},0:{e:10,h:8,m:6,l:4},1:{e:11,h:9,m:7,l:5},2:{e:13,h:11,m:9,l:7},3:{e:14,h:12,m:10,l:8},4:{e:16,h:14,m:12,l:9},5:{e:17,h:15,m:13,l:11},6:{e:19,h:17,m:15,l:12},7:{e:20,h:18,m:16,l:13},8:{e:22,h:20,m:18,l:15},9:{e:23,h:21,m:19,l:16},10:{e:25,h:23,m:21,l:17},11:{e:27,h:24,m:22,l:19},12:{e:28,h:26,m:24,l:20},13:{e:29,h:27,m:25,l:21},14:{e:31,h:29,m:27,l:23},15:{e:32,h:30,m:28,l:24},16:{e:34,h:32,m:30,l:25},17:{e:35,h:33,m:31,l:27},18:{e:37,h:35,m:33,l:28},19:{e:38,h:36,m:34,l:29},20:{e:40,h:38,m:36,l:31},21:{e:41,h:39,m:37,l:32},22:{e:43,h:41,m:39,l:33},23:{e:44,h:42,m:40,l:35},24:{e:46,h:44,m:42,l:36} }; // Strike Damage averages (Table 2–10, parentheses values) const DMG={ "-1":{e:4,h:3,m:3,l:2},0:{e:6,h:5,m:4,l:3},1:{e:8,h:6,m:5,l:4},2:{e:11,h:9,m:8,l:6},3:{e:15,h:12,m:10,l:8},4:{e:18,h:14,m:12,l:9},5:{e:20,h:16,m:13,l:11},6:{e:23,h:18,m:15,l:12},7:{e:25,h:20,m:17,l:13},8:{e:28,h:22,m:18,l:15},9:{e:30,h:24,m:20,l:16},10:{e:33,h:26,m:22,l:17},11:{e:35,h:28,m:23,l:19},12:{e:38,h:30,m:25,l:20},13:{e:40,h:32,m:27,l:21},14:{e:43,h:34,m:28,l:23},15:{e:45,h:36,m:30,l:24},16:{e:48,h:37,m:31,l:25},17:{e:50,h:38,m:32,l:26},18:{e:53,h:40,m:33,l:27},19:{e:55,h:42,m:35,l:28},20:{e:58,h:44,m:37,l:29},21:{e:60,h:46,m:38,l:31},22:{e:63,h:48,m:40,l:32},23:{e:65,h:50,m:42,l:33},24:{e:68,h:52,m:44,l:35} }; // NEW: Spell DCs and Spell Attack (GM Core Table 2–11) const SPELL_DC = { "-1":{e:19,h:16,m:13},0:{e:19,h:16,m:13},1:{e:20,h:17,m:14},2:{e:22,h:18,m:15},3:{e:23,h:20,m:17}, 4:{e:25,h:21,m:18},5:{e:26,h:22,m:19},6:{e:27,h:24,m:21},7:{e:29,h:25,m:22},8:{e:30,h:26,m:23}, 9:{e:32,h:28,m:25},10:{e:33,h:29,m:26},11:{e:34,h:30,m:27},12:{e:36,h:32,m:29},13:{e:37,h:33,m:30}, 14:{e:39,h:34,m:31},15:{e:40,h:36,m:33},16:{e:41,h:37,m:34},17:{e:43,h:38,m:35},18:{e:44,h:40,m:37}, 19:{e:46,h:41,m:38},20:{e:47,h:42,m:39},21:{e:48,h:44,m:41},22:{e:50,h:45,m:42},23:{e:51,h:46,m:43}, 24:{e:52,h:48,m:45} }; const SPELL_ATK = { "-1":{e:11,h:8,m:5},0:{e:11,h:8,m:5},1:{e:12,h:9,m:6},2:{e:14,h:10,m:7},3:{e:15,h:12,m:9}, 4:{e:17,h:13,m:10},5:{e:18,h:14,m:11},6:{e:19,h:16,m:13},7:{e:21,h:17,m:14},8:{e:22,h:18,m:15}, 9:{e:24,h:20,m:17},10:{e:25,h:21,m:18},11:{e:26,h:22,m:19},12:{e:28,h:24,m:21},13:{e:29,h:25,m:22}, 14:{e:31,h:26,m:23},15:{e:32,h:28,m:25},16:{e:33,h:29,m:26},17:{e:35,h:30,m:27},18:{e:36,h:32,m:29}, 19:{e:38,h:33,m:30},20:{e:39,h:34,m:31},21:{e:40,h:36,m:33},22:{e:42,h:37,m:34},23:{e:43,h:38,m:35}, 24:{e:44,h:40,m:37} }; // ---------- CLASSIFY ---------- const order = t => ({e:0,h:1,m:2,l:3,t:4})[t] ?? 99; const tierName = l => ({e:"extreme",h:"high",m:"moderate",l:"low",t:"terrible"})[l] || "moderate"; function classifyExact(val, targets){ let best=null; for (const [tier,tgt] of Object.entries(targets)) { const diff=Math.abs(val-tgt); if (!best || diff<best.diff || (diff===best.diff && order(tier)<order(best.tier))) best={tier,tgt,diff}; } return best; } function classifyRange(val, ranges){ for (const tier of ["h","m","l"]) { const [min,max]=ranges[tier]; if (val>=min && val<=max) return {tier,min,max}; } let best=null; for (const tier of ["h","m","l"]) { const [min,max]=ranges[tier]; const d = val<min ? (min-val) : (val-max); if (!best || d<best.d || (d===best.d && order(tier)<order(best.tier))) best={tier,min,max,d}; } return best; } // ---------- STAT ROOT & LEVEL ---------- function scoreStatHintsText(t){let s=0;if(/\bPerception\b/.test(t))s++;if(/\bAC\s+\d+\b/.test(t))s++;if(/\bHP\s+\d+\b/.test(t))s++;if(/\bFort(?:itude)?\s+[+\-−]\d+\b/i.test(t))s++;if(/\bRef(?:lex)?\s+[+\-−]\d+\b/i.test(t))s++;if(/\bWill\s+[+\-−]\d+\b/i.test(t))s++;return s;} function getStatRoot(){ const els=document.querySelectorAll("article, section, div, main"); let best=null; for (const el of els){ const t=readText(el); if (!/\bPerception\b/.test(t)) continue; const sc=scoreStatHintsText(t)*10 - Math.log10((t.length||1)); if (!best || sc>best.sc) best={el,sc}; } return best?.el || document.body; } function getCreatureLevelNear(root){ let el=root; for (let i=0;i<3 && el;i++){ const m = readText(el).match(/\b(Creature|Hazard|Level)\s+(-?\d+)\b/i); if (m) return parseInt(m[2],10); el=el.parentElement; } return null; } // ---------- WALKERS ---------- function findTextNodeAfterLabel(labelEl, numberRegex, stopLabelRe){ const root = labelEl.parentElement; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ALL, null); let past=false; while (walker.nextNode()){ const node = walker.currentNode; if (node === labelEl) { past=true; continue; } if (!past) continue; if (node.nodeType === 1){ if ((node.tagName === "B" || node.tagName === "STRONG") && stopLabelRe && stopLabelRe.test((node.textContent||"").trim())) { return null; } continue; } if (node.nodeType !== 3) continue; const txt = node.nodeValue; const m = numberRegex.exec(txt); if (m) return { node, m }; } return null; } // NEW: walk all text nodes between a bold label and the next bold label, running a callback on each text node function walkTextNodesBetween(labelEl, stopLabelRe, cb){ const root = labelEl.parentElement; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ALL, null); let past=false; while (walker.nextNode()){ const node = walker.currentNode; if (node === labelEl) { past=true; continue; } if (!past) continue; if (node.nodeType === 1){ if ((node.tagName === "B" || node.tagName === "STRONG") && stopLabelRe && stopLabelRe.test((node.textContent||"").trim())) { return; } continue; } if (node.nodeType !== 3) continue; cb(node); } } function replaceMatchInTextNode(node, m, makeSpan){ const txt = node.nodeValue; const start = m.index, end = start + m[0].length; const before = document.createTextNode(txt.slice(0,start)); const badge = makeSpan(m[0]); const after = document.createTextNode(txt.slice(end)); const parent = node.parentNode; parent.replaceChild(after, node); parent.insertBefore(badge, after); parent.insertBefore(before, badge); } // ---------- HIGHLIGHTERS (existing) ---------- function highlightPerception(root, lvl){ const row=PERC[lvl]; if(!row) return; const labels = Array.from(root.querySelectorAll("b,strong")).filter(b => /^Perception\b/i.test((b.textContent||"").trim())); for (const b of labels){ const hit = findTextNodeAfterLabel(b, /[+\-−]?\d+/, /^(AC|Fort|Fortitude|Ref|Reflex|Will|HP|Melee|Ranged|Strikes?|Languages|Skills)\b/i); if (!hit) continue; replaceMatchInTextNode(hit.node, hit.m, (n)=> makeBadge(n, tierName(classifyExact(parseInt(norm(n),10),row).tier), `E ${fmt(row.e)}, H ${fmt(row.h)}, M ${fmt(row.m)}, L ${fmt(row.l)}, T ${fmt(row.t)}`)); } } function highlightAC(root, lvl){ const row=AC[lvl]; if(!row) return; const labels = Array.from(root.querySelectorAll("b,strong")).filter(b => /^AC\b/i.test((b.textContent||"").trim())); for (const b of labels){ const hit = findTextNodeAfterLabel(b, /\d+/, /^(Fort|Fortitude|Ref|Reflex|Will|HP|Melee|Ranged|Strikes?|Perception|Skills)\b/i); if (!hit) continue; replaceMatchInTextNode(hit.node, hit.m, (n)=> makeBadge(n, tierName(classifyExact(parseInt(n,10),row).tier), `E ${row.e}, H ${row.h}, M ${row.m}, L ${row.l}`)); } } function highlightSaves(root, lvl){ const row=SAVES[lvl]; if(!row) return; const want=[/^Fort(?:itude)?\b/i,/^Ref(?:lex)?\b/i,/^Will\b/i]; for (const re of want){ const labels = Array.from(root.querySelectorAll("b,strong")).filter(b => re.test((b.textContent||"").trim())); for (const b of labels){ const hit = findTextNodeAfterLabel(b, /[+\-−]?\d+/, /^(AC|HP|Melee|Ranged|Strikes?|Perception|Skills|Damage)\b/i); if (!hit) continue; replaceMatchInTextNode(hit.node, hit.m, (n)=> makeBadge(n, tierName(classifyExact(parseInt(norm(n),10),row).tier), `E ${fmt(row.e)}, H ${fmt(row.h)}, M ${fmt(row.m)}, L ${fmt(row.l)}, T ${fmt(row.t)}`)); } } } function highlightHP(root, lvl){ const row=HP[lvl]; if(!row) return; const labels = Array.from(root.querySelectorAll("b,strong")).filter(b => /^HP\b/i.test((b.textContent||"").trim())); for (const b of labels){ const hit = findTextNodeAfterLabel(b, /\d+/, /^(AC|Fort|Fortitude|Ref|Reflex|Will|Melee|Ranged|Strikes?|Perception|Skills)\b/i); if (!hit) continue; replaceMatchInTextNode(hit.node, hit.m, (n)=> makeBadge(n, tierName(classifyRange(parseInt(n,10),row).tier), `High ${rng(row.h)}, Moderate ${rng(row.m)}, Low ${rng(row.l)}`)); } } function highlightSkills(root, lvl){ const row=SKILLS[lvl]; if(!row) return; const labels = Array.from(root.querySelectorAll("b,strong")) .filter(b => /^Skills\b/i.test((b.textContent||"").trim())); const stopRe = /^(Perception|Languages|Skills|AC|Fort|Fortitude|Ref|Reflex|Will|HP|Melee|Ranged|Strikes?|Speed|Items|Immunities|Weaknesses|Resistances)\b/i; for (const b of labels){ // Collect text nodes from after "Skills" up to the first <br> or next bold header const nodes=[]; for (let n=b.nextSibling; n; n=n.nextSibling){ if (n.nodeType===1){ const tag=n.tagName; if (tag==="BR" || tag==="HR" || ((tag==="B"||tag==="STRONG") && stopRe.test((n.textContent||"").trim()))) break; const tw=document.createTreeWalker(n, NodeFilter.SHOW_TEXT, null); while (tw.nextNode()) nodes.push(tw.currentNode); } else if (n.nodeType===3){ nodes.push(n); } } const lmid = Math.round((row.l[0]+row.l[1])/2); for (const tn of nodes){ const text = tn.nodeValue; const rx = /([+\-−]\d+)/g; let m, last=0, any=false; const out=[]; while ((m=rx.exec(text))!==null){ // Skip ability scores like "Str -2, Dex +3" const pre = text.slice(Math.max(0, m.index-6), m.index); if (/\b(Str|Dex|Con|Int|Wis|Cha)\s*$/i.test(pre)) continue; out.push(text.slice(last, m.index)); const val = parseInt((m[1]||"").replace(/[–−]/g,"-"),10); const cls = classifyExact(val,{e:row.e,h:row.h,m:row.m,l:lmid}); out.push(makeBadge(m[1], tierName(cls.tier), `E ${fmt(row.e)}, H ${fmt(row.h)}, M ${fmt(row.m)}, L ${rng(row.l)}`)); last = m.index + m[1].length; any=true; } if (any){ out.push(text.slice(last)); const parent=tn.parentNode; for (const piece of out){ parent.insertBefore(piece instanceof Node ? piece : document.createTextNode(piece), tn); } parent.removeChild(tn); } } } } function highlightStrikeAttack(root, lvl){ const row=ATK[lvl]; if(!row) return; const labels = Array.from(root.querySelectorAll("b,strong")).filter(b => /^(Melee|Ranged)\b/i.test((b.textContent||"").trim())); for (const b of labels){ const hit = findTextNodeAfterLabel(b, /[+\-−]\d+/, /^(Damage|Melee|Ranged)\b/i); if (!hit) continue; replaceMatchInTextNode(hit.node, hit.m, (n)=> makeBadge(n, tierName(classifyExact(parseInt(norm(n),10),row).tier), `E ${fmt(row.e)}, H ${fmt(row.h)}, M ${fmt(row.m)}, L ${fmt(row.l)}`)); } } // ---- Strike Damage ---- function avgOfExpr(expr){ const s = norm(expr); let total = 0; const diceRe = /([+\-−]?)\s*(\d+)\s*[dD]\s*(\d+)/g; let m; while ((m=diceRe.exec(s)) !== null){ const sign = (m[1] === "-" || m[1] === "−") ? -1 : 1; const count = parseInt(m[2],10); const die = parseInt(m[3],10); const dieAvg = {4:2.5,6:3.5,8:4.5,10:5.5,12:6.5}[die] || (die/2+0.5); total += sign * count * dieAvg; } const flatRe = /([+\-−])\s*(\d+)(?!\s*[dD])/g; while ((m=flatRe.exec(s)) !== null){ const sign = (m[1] === "-" || m[1] === "−") ? -1 : 1; total += sign * parseInt(m[2],10); } return Math.round(total); } function highlightStrikeDamage(root, lvl){ const targets = DMG[lvl]; if(!targets) return; const labels = Array.from(root.querySelectorAll("b,strong")).filter(b => /^Damage\b/i.test((b.textContent||"").trim())); for (const b of labels){ const hit = findTextNodeAfterLabel(b, /\d+\s*[dD]\s*\d+/, /^(Melee|Ranged|Damage|Spell|DC|Attack)\b/i); if (!hit) continue; const txt = hit.node.nodeValue; let start = hit.m.index; let end = start + hit.m[0].length; while (end < txt.length) { const rest = txt.slice(end); const m2 = /^[ \t]*([+\-−])\s*(?:(\d+[dD]\d+)|(\d+))/.exec(rest); if (!m2) break; const kw = rest.slice(m2[0].length, m2[0].length+12).toLowerCase(); if (/\b(persistent|splash|plus|and|or|;|,)/.test(kw)) { end += m2[0].length; break; } end += m2[0].length; } const expr = txt.slice(start, end); const avg = avgOfExpr(expr); const cls = classifyExact(avg, targets); const title = `avg ${avg} vs E ${targets.e}, H ${targets.h}, M ${targets.m}, L ${targets.l}`; const before = document.createTextNode(txt.slice(0,start)); const badge = makeBadge(expr, tierName(cls.tier), title); const after = document.createTextNode(txt.slice(end)); const parent = hit.node.parentNode; parent.replaceChild(after, hit.node); parent.insertBefore(badge, after); parent.insertBefore(before, badge); } } // ---------- NEW HIGHLIGHTERS: Spellcasting DCs & Ability Save DCs ---------- function highlightSpellcasting(root, lvl){ const dcRow = SPELL_DC[lvl], atkRow = SPELL_ATK[lvl]; if (!dcRow || !atkRow) return; const isSpellHeader = (s) => /\b(Arcane|Divine|Occult|Primal)\b.*\bSpells\b/i.test(s); const labels = Array.from(root.querySelectorAll("b,strong")).filter(b => { const t = (b.textContent||"").trim(); return isSpellHeader(t); }); for (const b of labels){ // Replace DC <num> and attack +<num> between this label and the next bold label walkTextNodesBetween(b, /^(?!x)x/ /*no extra stop*/, (tn)=>{ let text = tn.nodeValue; let pieces = []; let changed = false; // 1) DC <num> const dcRx = /\bDC\s+(\d{1,3})\b/gi; let last = 0, m; let tmpOut = []; while ((m = dcRx.exec(text)) !== null){ tmpOut.push(text.slice(last, m.index)); const badge = makeBadge(m[1], tierName(classifyExact(parseInt(m[1],10), dcRow).tier), `E ${dcRow.e}, H ${dcRow.h}, M ${dcRow.m}`); tmpOut.push(document.createTextNode("DC ")); tmpOut.push(badge); last = m.index + m[0].length; changed = true; } tmpOut.push(text.slice(last)); // Rebuild node if changed if (changed){ const parent = tn.parentNode; for (const part of tmpOut) parent.insertBefore(part instanceof Node ? part : document.createTextNode(part), tn); parent.removeChild(tn); // After DOM surgery, tn is gone; create a fresh tn for next phase on the last text chunk tn = tmpOut[tmpOut.length-1] instanceof Node ? null : null; } }); // Separate pass for attack +N (do it after, across fresh walkers) walkTextNodesBetween(b, /^(?!x)x/, (tn)=>{ const text = tn.nodeValue; const atkRx = /\battack\s+([+\-−]\d{1,3})\b/gi; let m, last=0, any=false; const out=[]; while ((m=atkRx.exec(text))!==null){ out.push(text.slice(last, m.index)); out.push("attack "); const val = parseInt(norm(m[1]),10); const cls = classifyExact(val, atkRow); out.push(makeBadge(m[1], tierName(cls.tier), `E ${fmt(atkRow.e)}, H ${fmt(atkRow.h)}, M ${fmt(atkRow.m)}`)); last = m.index + m[0].length; any=true; } if (any){ out.push(text.slice(last)); const parent=tn.parentNode; for (const p of out) parent.insertBefore(p instanceof Node ? p : document.createTextNode(p), tn); parent.removeChild(tn); } }); } } function highlightAbilitySaveDCs(root, lvl){ const dcRow = SPELL_DC[lvl]; if(!dcRow) return; // Bold labels that are NOT standard stat headers and NOT Recall Knowledge blocks const EXCLUDE = /^(Perception|AC|Fort|Fortitude|Ref|Reflex|Will|HP|Melee|Ranged|Strikes?|Languages|Skills|Speed|Items|Immunities|Weaknesses|Resistances|Recall Knowledge|Unspecific Lore|Specific Lore|Rituals?|Constant|Countermeasures?|Disable|Reset|Offense|Defense|Statistics|Spells?)\b/i; const labels = Array.from(root.querySelectorAll("b,strong")).filter(b => { const t = (b.textContent||"").trim(); return !EXCLUDE.test(t); }); for (const b of labels){ walkTextNodesBetween(b, /^(?!x)x/, (tn)=>{ const text = tn.nodeValue; // Look for "DC <num>" that clearly relates to a save const rx = /\bDC\s+(\d{1,3})\b/gi; let m, last=0, changed=false; const out=[]; while ((m=rx.exec(text))!==null){ const after = text.slice(m.index + m[0].length, m.index + m[0].length + 50); const before = text.slice(Math.max(0, m.index-20), m.index + m[0].length); if (/\b(Fortitude|Reflex|Will)\b/i.test(after) || /\bsave\b/i.test(after) || /\bbasic\b/i.test(after) || /\bsave\b/i.test(before)){ out.push(text.slice(last, m.index)); out.push("DC "); const val = parseInt(m[1],10); const cls = classifyExact(val, dcRow); out.push(makeBadge(m[1], tierName(cls.tier), `E ${dcRow.e}, H ${dcRow.h}, M ${dcRow.m}`)); last = m.index + m[0].length; changed=true; } } if (changed){ out.push(text.slice(last)); const parent=tn.parentNode; for (const p of out) parent.insertBefore(p instanceof Node ? p : document.createTextNode(p), tn); parent.removeChild(tn); } }); } } // ---------- LEGEND ---------- function injectLegend(root){ if (window.matchMedia("(orientation: portrait)").matches) { return } if (document.getElementById("pf2-tier-legend")) return; const box = document.createElement("div"); box.id = "pf2-tier-legend"; box.innerHTML = ` <div style="font-weight:600;margin-bottom:.25rem">Stat Benchmarks</div> ${legendRow("terrible")} ${legendRow("low")} ${legendRow("moderate")} ${legendRow("high")} ${legendRow("extreme")} <div style="margin-top:.35rem;font-size:.8em;opacity:.8">Detected near stat block.</div> `.trim(); Object.assign(box.style, { position:"fixed", top: LEGEND_TOP_PX+"px", right: LEGEND_RIGHT_PX+"px", padding:".5rem .6rem", background:"rgba(0,0,0,0.04)", border:"1px solid rgba(0,0,0,.08)", borderRadius:"8px", zIndex:"9999", maxWidth:"240px", fontFamily:"inherit", fontSize:"12.5px", lineHeight:"1.3" }); root.appendChild(box); } function legendRow(name){ const c=COLORS[name]; const sw=`<span style="display:inline-block;width:.85em;height:.85em;background:${hexToRgba(c,LIGHT_BG_ALPHA)};border-bottom:2px solid ${hexToRgba(c,UNDERLINE_ALPHA)};vertical-align:middle;margin-right:.4em;border-radius:3px"></span>`; return `<div>${sw}${name[0].toUpperCase()+name.slice(1)}</div>`; } // ---------- BOOT ---------- let mo=null, lastSnapshot=""; function snapshot(el){ return readText(el).slice(0,2000); } function process(root){ const lvl = getCreatureLevelNear(root); if (lvl == null) return log("no level near stat block"); highlightPerception(root, lvl); highlightAC(root, lvl); highlightSaves(root, lvl); highlightHP(root, lvl); highlightSkills(root, lvl); highlightStrikeAttack(root, lvl); highlightStrikeDamage(root, lvl); // NEW: highlightSpellcasting(root, lvl); // e.g., "Divine Innate Spells DC 47, attack +37" highlightAbilitySaveDCs(root, lvl); // e.g., "must succeed at a DC 47 Fortitude save" injectLegend(root); lastSnapshot = snapshot(root); } function reprocess(root){ const now = snapshot(root); if (now === lastSnapshot) return; root.querySelectorAll(".pf2-tier").forEach(n => n.replaceWith(document.createTextNode(n.textContent))); document.getElementById("pf2-tier-legend")?.remove(); process(root); } function attachObserver(root){ if (mo) mo.disconnect(); mo = new MutationObserver(()=>{ clearTimeout(attachObserver._t); attachObserver._t=setTimeout(()=>reprocess(root), 250); }); mo.observe(root, { childList:true, subtree:true, characterData:true }); } function waitForStatRoot(){ return new Promise(resolve=>{ const tick=()=>{ const root=getStatRoot(); if (root && scoreStatHintsText(readText(root))>=3) return resolve(root); setTimeout(tick,100); }; tick(); }); } // Hotkey: Alt+Shift+P to force a refresh window.addEventListener("keydown",(e)=>{ if (e.altKey && e.shiftKey && e.code==="KeyP"){ const root=getStatRoot(); root?.querySelectorAll(".pf2-tier").forEach(n=>n.replaceWith(document.createTextNode(n.textContent))); document.getElementById("pf2-tier-legend")?.remove(); lastSnapshot=""; process(root); } }); waitForStatRoot().then(root=>{ process(root); attachObserver(root); }); })();