AoN PF2e Creature Stat Tier Highlighter

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); });

})();