AoN PF2e Creature Stat Tier Highlighter

Color-codes PF2e creature stats on AoN (Monsters/NPCs/Hazards).

目前為 2025-08-08 提交的版本,檢視 最新版本

// ==UserScript==
// @name         AoN PF2e Creature Stat Tier Highlighter
// @namespace    https://2e.aonprd.com/
// @version      1.0.0
// @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;   // move down if your header is taller
  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} };

  // ---------- 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};
    }
    // nearest band
    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 THAT DON'T MANGLE TEXT ----------
  // Walk all nodes under container; start matching AFTER labelEl appears; stop if we hit another bold label (e.g., next section)
  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 /*ELEMENT_NODE*/){
        if ((node.tagName === "B" || node.tagName === "STRONG") && stopLabelRe && stopLabelRe.test((node.textContent||"").trim())) {
          return null;
        }
        continue;
      }
      if (node.nodeType !== 3) continue; // text only
      const txt = node.nodeValue;
      const m = numberRegex.exec(txt);
      if (m) return { node, m };
    }
    return null;
  }

  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 ----------
  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()));
    for (const b of labels){
      // Wrap ALL +N after "Skills"
      const walker = document.createTreeWalker(b.parentElement, NodeFilter.SHOW_TEXT, null);
      let past=false;
      while (walker.nextNode()){
        const tn=walker.currentNode;
        if (b.contains(tn)) { past=true; continue; }
        if (!past) continue;
        const rx = /[+\-−]\d+/g;
        let m; let any=false; const pieces=[]; let last=0; const text=tn.nodeValue;
        while ((m=rx.exec(text))!==null){
          pieces.push(text.slice(last, m.index));
          const val = parseInt(norm(m[0]),10);
          const cls = classifyExact(val,{e:row.e,h:row.h,m:row.m,l:Math.round((row.l[0]+row.l[1])/2)});
          pieces.push(makeBadge(m[0], tierName(cls.tier), `E ${fmt(row.e)}, H ${fmt(row.h)}, M ${fmt(row.m)}, L ${rng(row.l)}`));
          last = m.index + m[0].length; any=true;
        }
        if (any){
          pieces.push(text.slice(last));
          const parent=tn.parentNode;
          for (const p of pieces) parent.insertBefore(p instanceof Node ? p : document.createTextNode(p), 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){
      // First attack bonus after the label; stop if we encounter "Damage" bold label
      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 ----
  // Compute average of a dice+flat expression like "2d6+1d4+4"
  function avgOfExpr(expr){
    const s = norm(expr);
    let total = 0;
    // dice terms
    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;
    }
    // flat terms (avoid +NdM by negative lookahead)
    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;
    // Find bold "Damage" labels tied to each strike
    const labels = Array.from(root.querySelectorAll("b,strong")).filter(b => /^Damage\b/i.test((b.textContent||"").trim()));
    for (const b of labels){
      // Find the first text node after "Damage" with a dice expression
      const hit = findTextNodeAfterLabel(b, /\d+\s*[dD]\s*\d+/, /^(Melee|Ranged|Damage|Spell|DC|Attack)\b/i);
      if (!hit) continue;

      // In that text node, expand the match to include contiguous "+NdM" and "+N" parts right after the first NdM
      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;
        // stop on keywords like " persistent", " splash", " plus ", " and "
        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}`;

      // Replace that slice with a colored badge (keep the exact expression text)
      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);
    }
  }

  // ---------- LEGEND ----------
  function injectLegend(root){
    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);
    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); });

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址