TornWars Stat Estimator

Overlays TornWars battle-stat estimates on Torn: a profile badge, an Est. column on faction member lists and search results, plus a copy-stats-image button.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         TornWars Stat Estimator
// @namespace    https://tornwars.com/
// @version      0.1.4
// @description  Overlays TornWars battle-stat estimates on Torn: a profile badge, an Est. column on faction member lists and search results, plus a copy-stats-image button.
// @author       YazanZed [3258052]
// @license      MIT
// @homepageURL  https://tornwars.com
// @supportURL   https://tornwars.com
// @match        *://www.torn.com/profiles.php*
// @match        *://torn.com/profiles.php*
// @match        *://www.torn.com/factions.php*
// @match        *://torn.com/factions.php*
// @match        *://www.torn.com/page.php?sid=UserList*
// @match        *://torn.com/page.php?sid=UserList*
// @match        *://www.torn.com/page.php?sid=list*
// @match        *://torn.com/page.php?sid=list*
// @connect      tornwars.com
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-idle
// @noframes
// ==/UserScript==

/*
 * Reads the player id from the profile URL (?XID=), fetches a TornWars stat
 * estimate, and shows it as a badge under the profile card plus a "compare with
 * me" modal. Also adds an estimate column to faction member lists and search.
 *
 * Set your Torn API key from the userscript-manager menu or by clicking the
 * badge.
 */

(function () {
  "use strict";

  // Config / storage
  const KEY_STORE = "tw_api_key";
  const BASE_STORE = "tw_base_url";
  // API base. Only tornwars.com is @connect-allowed, so any override must stay
  // on that host.
  const DEFAULT_BASE_URL = "https://tornwars.com/api";

  function normalizeBase(u) {
    let base = (u || DEFAULT_BASE_URL).trim().replace(/\/$/, "");
    if (!/^https?:\/\//i.test(base)) base = "https://" + base;
    return base;
  }

  const getApiKey = () => GM_getValue(KEY_STORE, "");
  const getBaseUrl = () => normalizeBase(GM_getValue(BASE_STORE, DEFAULT_BASE_URL));

  // Torn PDA has no GM_registerMenuCommand — skip it there instead of crashing.
  function registerMenu(label, fn) {
    if (typeof GM_registerMenuCommand === "function") GM_registerMenuCommand(label, fn);
  }

  registerMenu("Set Torn API key", () => showKeyDialog());
  registerMenu("Set API base URL", () => {
    const next = prompt("TornWars API base URL:", getBaseUrl());
    if (next !== null) {
      GM_setValue(BASE_STORE, next.trim().replace(/\/$/, ""));
      alert("Saved. Reload the profile page.");
    }
  });

  // GET via GM_xmlhttpRequest → {ok:true,data} | {ok:false,error}.
  function apiGet(path) {
    return new Promise((resolve) => {
      const apiKey = getApiKey();
      if (!apiKey) return resolve({ ok: false, error: "no-key" });

      GM_xmlhttpRequest({
        method: "GET",
        url: `${getBaseUrl()}${path}`,
        headers: { Authorization: `ApiKey ${apiKey}` },
        timeout: 15000,
        onload: (res) => {
          if (res.status === 401) return resolve({ ok: false, error: "unauthorized" });
          if (res.status === 429) return resolve({ ok: false, error: "rate-limited" });
          if (res.status < 200 || res.status >= 300) {
            return resolve({ ok: false, error: `http-${res.status}` });
          }
          let body;
          try {
            body = JSON.parse(res.responseText);
          } catch (e) {
            return resolve({ ok: false, error: "bad-response" });
          }
          if (!body || body.success === false || !body.data) {
            return resolve({ ok: false, error: "bad-response" });
          }
          resolve({ ok: true, data: body.data });
        },
        onerror: () => resolve({ ok: false, error: "network" }),
        ontimeout: () => resolve({ ok: false, error: "network" }),
      });
    });
  }

  const getEstimate = (playerId) => apiGet(`/dashboard/player/${playerId}`);
  const getCompare = () => apiGet("/dashboard/player/me/compare");

  // Styles
  GM_addStyle(`
    #tw-estimate-badge.tw-estimate-badge {
      display: flex; flex-direction: row; align-items: stretch; gap: 12px;
      margin: 8px 0; padding: 10px 14px;
      border: 1px solid rgba(136,136,136,.4); border-radius: 8px;
      background: rgba(20,20,22,.92); color: #e5e7eb;
      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
      box-shadow: 0 2px 10px rgba(0,0,0,.25);
    }
    #tw-estimate-badge .tw-tabs {
      display: flex; flex-direction: column; justify-content: center; gap: 6px;
      margin-left: auto; padding-left: 12px;
      border-left: 1px solid rgba(255,255,255,.1);
    }
    #tw-estimate-badge .tw-tab {
      padding: 4px 10px; border: 1px solid rgba(255,255,255,.15); border-radius: 6px;
      background: transparent; color: #cbd5e1; font-size: 11px; font-weight: 500;
      white-space: nowrap; cursor: pointer;
    }
    #tw-estimate-badge .tw-tab.tw-tab-muted { color: #9ca3af; }
    #tw-estimate-badge .tw-tab:hover { background: rgba(255,255,255,.07); }
    #tw-estimate-badge .tw-main { display: flex; flex-direction: column; justify-content: flex-start; gap: 2px; }
    #tw-estimate-badge.tw-estimate-badge.tw-fixed {
      position: fixed; right: 16px; bottom: 16px; z-index: 2147483647; margin: 0;
    }
    #tw-estimate-badge .tw-label {
      font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: #9ca3af;
    }
    #tw-estimate-badge .tw-value { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }
    #tw-estimate-badge .tw-value.tw-muted { font-size: 13px; font-weight: 400; color: #9ca3af; }
    #tw-estimate-badge .tw-link { color: #60a5fa; text-decoration: underline; }
    #tw-estimate-badge .tw-link:hover { color: #93c5fd; }
    #tw-estimate-badge .tw-conf { font-size: 11px; color: #9ca3af; text-transform: capitalize; }
    #tw-estimate-badge .tw-cached {
      margin-left: 6px; font-size: 10px; font-weight: 600; color: #9ca3af;
      text-transform: uppercase; letter-spacing: .06em; vertical-align: middle;
      border: 1px solid rgba(255,255,255,.18); border-radius: 4px; padding: 1px 5px;
    }
    #tw-estimate-badge .tw-ff-col {
      flex-direction: column; justify-content: flex-start; gap: 2px;
      padding-left: 12px; border-left: 1px solid rgba(255,255,255,.1);
    }
    #tw-estimate-badge .tw-ff-label {
      font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: #9ca3af;
    }
    #tw-estimate-badge .tw-ff-val { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }
    #tw-estimate-badge .tw-updated-col {
      display: flex; flex-direction: column; justify-content: flex-start; gap: 2px;
      padding-left: 12px; border-left: 1px solid rgba(255,255,255,.1);
    }
    #tw-estimate-badge .tw-updated-label {
      font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: #9ca3af;
    }
    #tw-estimate-badge .tw-updated-val {
      font-size: 13px; font-weight: 500; color: #d1d5db;
      font-variant-numeric: tabular-nums; white-space: nowrap;
    }
    #tw-key-modal.tw-modal-overlay, #tw-compare-modal.tw-modal-overlay {
      position: fixed; inset: 0; z-index: 2147483647; display: flex;
      align-items: center; justify-content: center; background: rgba(0,0,0,.5);
      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
    }
    #tw-key-modal .tw-modal {
      width: 340px; max-width: calc(100vw - 32px); padding: 20px; border-radius: 12px;
      background: #1c1c1e; color: #e5e7eb; box-shadow: 0 12px 40px rgba(0,0,0,.5);
    }
    #tw-key-modal .tw-modal-title { font-size: 16px; font-weight: 600; }
    #tw-key-modal .tw-modal-desc { margin-top: 6px; font-size: 12px; line-height: 1.5; color: #9ca3af; }
    #tw-key-modal .tw-modal-input {
      width: 100%; margin-top: 14px; padding: 9px 10px; box-sizing: border-box;
      border: 1px solid #3a3a3d; border-radius: 8px; background: #111; color: #e5e7eb; font-size: 14px;
    }
    #tw-key-modal .tw-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
    .tw-btn { padding: 8px 14px; border: none; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; }
    .tw-btn-ghost { background: transparent; color: #9ca3af; }
    .tw-btn-primary { background: #3b82f6; color: #fff; }
    #tw-compare-modal .tw-cmp {
      width: 380px; max-width: calc(100vw - 32px); max-height: calc(100vh - 48px);
      overflow-y: auto; padding: 20px; border-radius: 12px; background: #1c1c1e;
      color: #e5e7eb; box-shadow: 0 12px 40px rgba(0,0,0,.5);
    }
    #tw-compare-modal .tw-cmp-loading { padding: 24px 8px; text-align: center; color: #9ca3af; font-size: 14px; }
    #tw-compare-modal .tw-cmp-head { font-size: 15px; font-weight: 600; text-align: center; }
    #tw-compare-modal .tw-cmp-legend {
      display: flex; justify-content: center; gap: 16px; margin-top: 8px; font-size: 11px; color: #9ca3af;
    }
    #tw-compare-modal .tw-cmp-legend i {
      display: inline-block; width: 8px; height: 8px; border-radius: 999px;
      margin-right: 5px; vertical-align: middle;
    }
    #tw-compare-modal .tw-cmp-hero {
      display: grid; grid-template-columns: 1fr auto 1fr; align-items: baseline;
      gap: 8px; margin-top: 14px; text-align: center;
    }
    #tw-compare-modal .tw-cmp-you {
      font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; color: #38bdf8;
    }
    #tw-compare-modal .tw-cmp-them { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
    #tw-compare-modal .tw-cmp-mid { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: #9ca3af; }
    #tw-compare-modal .tw-cmp-verdict { margin-top: 6px; text-align: center; font-size: 14px; font-weight: 600; }
    #tw-compare-modal .tw-cmp-ff { margin-top: 8px; text-align: center; font-size: 13px; color: #cbd5e1; }
    #tw-compare-modal .tw-cmp-note { margin-top: 2px; text-align: center; font-size: 11px; color: #9ca3af; }
    #tw-compare-modal .tw-cmp-rows { margin-top: 16px; display: flex; flex-direction: column; gap: 10px; }
    #tw-compare-modal .tw-cmp-nums {
      display: grid; grid-template-columns: 1fr auto 1fr; align-items: baseline; gap: 8px;
      margin-bottom: 4px; font-size: 12px; font-variant-numeric: tabular-nums;
    }
    #tw-compare-modal .tw-cmp-nums > span:first-child { text-align: right; }
    #tw-compare-modal .tw-cmp-nums > span:last-child { text-align: left; }
    #tw-compare-modal .tw-cmp-lbl {
      font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: #9ca3af; white-space: nowrap;
    }
    #tw-compare-modal .tw-strong { color: #f3f4f6; font-weight: 600; }
    #tw-compare-modal .tw-dim { color: #9ca3af; }
    #tw-compare-modal .tw-cmp-track { display: flex; gap: 2px; }
    #tw-compare-modal .tw-cmp-half {
      flex: 1; height: 8px; display: flex; overflow: hidden;
      background: rgba(255,255,255,.08); border-radius: 999px;
    }
    #tw-compare-modal .tw-cmp-half.left { justify-content: flex-end; }
    #tw-compare-modal .tw-cmp-half.right { justify-content: flex-start; }
    #tw-compare-modal .tw-cmp-fill { width: 0; height: 100%; border-radius: 999px; transition: width .7s ease-out; }
    #tw-compare-modal .tw-cmp-close { margin-top: 18px; width: 100%; background: #2a2a2d; color: #e5e7eb; }
  `);


  const BADGE_ID = "tw-estimate-badge";
  const MODAL_ID = "tw-key-modal";
  const COMPARE_ID = "tw-compare-modal";

  let lastData = null;
  let lastPlayerId = null;

  function getPlayerId() {
    const xid = new URLSearchParams(window.location.search).get("XID");
    const id = Number(xid);
    return Number.isInteger(id) && id > 0 ? id : null;
  }

  function fmt(num) {
    if (num == null) return "—";
    const abs = Math.abs(num);
    if (abs >= 1e12) return (num / 1e12).toFixed(2) + "T";
    if (abs >= 1e9) return (num / 1e9).toFixed(2) + "B";
    if (abs >= 1e6) return (num / 1e6).toFixed(2) + "M";
    if (abs >= 1e3) return (num / 1e3).toFixed(1) + "K";
    return String(Math.round(num));
  }

  // Compact form for the narrow table columns: 3 significant figures, so big
  // values like 883.61M fit as 884M.
  function fmtCompact(num) {
    if (num == null) return "—";
    const abs = Math.abs(num);
    let v, s;
    if (abs >= 1e12) { v = num / 1e12; s = "T"; }
    else if (abs >= 1e9) { v = num / 1e9; s = "B"; }
    else if (abs >= 1e6) { v = num / 1e6; s = "M"; }
    else if (abs >= 1e3) { v = num / 1e3; s = "K"; }
    else return String(Math.round(num));
    const d = Math.abs(v) >= 100 ? 0 : Math.abs(v) >= 10 ? 1 : 2;
    return v.toFixed(d) + s;
  }

  function fmtDuration(seconds) {
    if (!seconds) return "—";
    const days = Math.floor(seconds / 86400);
    const hours = Math.floor((seconds % 86400) / 3600);
    return days > 0 ? `${days}d ${hours}h` : `${hours}h`;
  }

  function tierColor(total) {
    if (total >= 250e6) return "#ef4444";
    if (total >= 25e6) return "#f97316";
    if (total >= 2.5e6) return "#f59e0b";
    if (total >= 250e3) return "#3b82f6";
    return "#10b981";
  }

  function fairFight(youTotal, themTotal) {
    if (!youTotal || !themTotal) return null;
    const bssRatio = Math.sqrt(themTotal / youTotal);
    return Math.max(1, Math.min(3, 1 + (8 / 3) * bssRatio));
  }

  function ffColor(ff) {
    if (ff >= 2.5) return "#10b981";
    if (ff >= 1.75) return "#f59e0b";
    return "#9ca3af";
  }

  let myCache = null;
  async function getMyStats() {
    if (myCache) return { ok: true, data: myCache };
    const resp = await getCompare();
    if (resp && resp.ok && resp.data) myCache = resp.data;
    return resp || { ok: false, error: "network" };
  }

  async function maybeShowFF(playerId, themTotal) {
    const me = await getMyStats();
    if (playerId !== lastPlayerId) return;
    if (!me.ok || !me.data || me.data.id === playerId) return;
    const ff = fairFight(me.data.totalBattleStats, themTotal);
    if (ff == null) return;

    const badge = document.getElementById(BADGE_ID);
    const col = badge && badge.querySelector(".tw-ff-col");
    const val = col && col.querySelector(".tw-ff-val");
    if (!col || !val) return;
    val.textContent = `~${ff.toFixed(2)}×`;
    val.style.color = ffColor(ff);
    col.style.display = "flex";
  }

  // Badge mounting
  function waitForMount(cb, tries = 40) {
    const card = document.querySelector("div.user-information");
    if (card) return cb(card);
    if (tries <= 0) return cb(null);
    setTimeout(() => waitForMount(cb, tries - 1), 250);
  }

  function ensureBadge(mountAfter) {
    let badge = document.getElementById(BADGE_ID);
    if (!badge) {
      badge = document.createElement("div");
      badge.id = BADGE_ID;
      badge.className = "tw-estimate-badge";
      badge.addEventListener("click", (e) => {
        const act = e.target && e.target.dataset ? e.target.dataset.act : null;
        if (act === "key") showKeyDialog();
        else if (act === "compare") showCompareModal();
      });
    }
    if (mountAfter && badge.previousElementSibling !== mountAfter) {
      mountAfter.insertAdjacentElement("afterend", badge);
      badge.classList.remove("tw-fixed");
    } else if (!mountAfter && !badge.isConnected) {
      document.body.appendChild(badge);
      badge.classList.add("tw-fixed");
    }
    return badge;
  }

  function setBadge(html, mountAfter) {
    const badge = ensureBadge(mountAfter);
    badge.innerHTML = html;
    return badge;
  }

  function renderLoading(mountAfter) {
    setBadge(
      `<div class="tw-main">` +
        `<span class="tw-label">TornWars</span>` +
        `<span class="tw-value tw-muted">estimating…</span>` +
        `</div>`,
      mountAfter,
    );
  }

  function renderError(error, mountAfter) {
    const messages = {
      "no-key": "Click to set your API key",
      unauthorized:
        'Key not linked — sign in at ' +
        '<a class="tw-link" href="https://tornwars.com" target="_blank" rel="noopener">TornWars</a> first',
      "rate-limited": "Slow down — rate limited",
      network: "Can't reach TornWars",
      "bad-response": "Unexpected response",
    };
    const msg = messages[error] || "Couldn't estimate";
    setBadge(
      `<div class="tw-main">` +
        `<span class="tw-label">TornWars</span>` +
        `<span class="tw-value tw-muted">${msg}</span>` +
        `</div>` +
        `<div class="tw-tabs"><button class="tw-tab" data-act="key">Set key</button></div>`,
      mountAfter,
    );
  }

  function estAge(days) {
    if (days == null || !isFinite(days)) return null;
    const d = Math.round(days);
    if (d <= 0) return "today";
    if (d === 1) return "1d ago";
    if (d < 30) return `${d}d ago`;
    const mo = Math.round(d / 30);
    if (mo < 12) return `${mo}mo ago`;
    return `${Math.round(d / 365)}y ago`;
  }

  function renderEstimate(data, mountAfter, fromCache) {
    lastData = data;
    const est = data.estimate || {};
    const total = est.tbs || 0;
    const color = tierColor(total);
    const title = est.rangeText ? `${est.rangeText}` : "";
    const cachedTag = fromCache
      ? `<span class="tw-cached" title="Showing a saved estimate — refreshing…">cached</span>`
      : "";
    const age = estAge(est.labelAgeDays);
    const updatedHtml = age
      ? `<div class="tw-updated-col">` +
        `<span class="tw-updated-label">Updated</span>` +
        `<span class="tw-updated-val">${age}</span>` +
        `</div>`
      : "";
    setBadge(
      `<div class="tw-main">` +
        `<span class="tw-label">Est. battle stats</span>` +
        `<span class="tw-value" style="color:${color}" title="${title}">~${fmt(total)}</span>` +
        `<span class="tw-conf">${est.confidence || "low"} confidence${cachedTag}</span>` +
        `</div>` +
        `<div class="tw-ff-col" style="display:none">` +
        `<span class="tw-ff-label">Fair fight</span>` +
        `<span class="tw-ff-val"></span>` +
        `</div>` +
        updatedHtml +
        `<div class="tw-tabs">` +
        `<button class="tw-tab" data-act="compare">Compare</button>` +
        `<button class="tw-tab tw-tab-muted" data-act="key">Key</button>` +
        `</div>`,
      mountAfter,
    );
    maybeShowFF(data.profile.id, total);
  }

  // Key dialog
  async function showKeyDialog() {
    if (document.getElementById(MODAL_ID)) return;
    const apiKey = getApiKey();

    const overlay = document.createElement("div");
    overlay.id = MODAL_ID;
    overlay.className = "tw-modal-overlay";
    overlay.innerHTML = `
      <div class="tw-modal">
        <div class="tw-modal-title">TornWars API key</div>
        <div class="tw-modal-desc">
          Used only to authenticate you to your TornWars account. Stored locally in
          your userscript manager.
        </div>
        <input class="tw-modal-input" type="password" placeholder="Your Torn API key" autocomplete="off" />
        <div class="tw-modal-actions">
          <button class="tw-btn tw-btn-ghost" data-act="cancel">Cancel</button>
          <button class="tw-btn tw-btn-primary" data-act="save">Save</button>
        </div>
      </div>`;

    const input = overlay.querySelector(".tw-modal-input");
    if (apiKey) input.value = apiKey;

    const close = () => overlay.remove();
    const save = () => {
      const key = input.value.trim();
      if (!key) return input.focus();
      GM_setValue(KEY_STORE, key);
      close();
      run();
    };

    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) close();
    });
    overlay.querySelector('[data-act="cancel"]').addEventListener("click", close);
    overlay.querySelector('[data-act="save"]').addEventListener("click", save);
    input.addEventListener("keydown", (e) => {
      if (e.key === "Enter") save();
      if (e.key === "Escape") close();
    });

    document.body.appendChild(overlay);
    input.focus();
  }

  // Compare modal
  function countUp(el, target, format) {
    const dur = 800;
    const start = performance.now();
    function tick(now) {
      const t = Math.min((now - start) / dur, 1);
      const v = target * (1 - Math.pow(1 - t, 3));
      el.textContent = format(v);
      if (t < 1) requestAnimationFrame(tick);
    }
    requestAnimationFrame(tick);
  }

  async function showCompareModal() {
    if (!lastData || document.getElementById(COMPARE_ID)) return;

    const overlay = document.createElement("div");
    overlay.id = COMPARE_ID;
    overlay.className = "tw-modal-overlay";
    overlay.innerHTML = `<div class="tw-cmp"><div class="tw-cmp-loading">Reading your stats…</div></div>`;
    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) overlay.remove();
    });
    document.addEventListener("keydown", function esc(e) {
      if (e.key === "Escape") {
        overlay.remove();
        document.removeEventListener("keydown", esc);
      }
    });
    document.body.appendChild(overlay);

    const resp = await getCompare();
    if (!document.body.contains(overlay)) return;

    const card = overlay.querySelector(".tw-cmp");
    if (!resp || !resp.ok) {
      const msg =
        resp && resp.error === "unauthorized"
          ? "This key isn't linked to a TornWars account yet. Sign in once at tornwars.com with this API key, then try again."
          : resp && resp.error === "rate-limited"
            ? "Rate limited — try again shortly."
            : "Couldn't load your stats.";
      card.innerHTML = `<div class="tw-cmp-loading">${msg}</div>`;
      return;
    }

    renderCompare(card, resp.data, lastData);
  }

  function renderCompare(card, me, target) {
    const you = me.totalBattleStats || 0;
    const them = (target.estimate && target.estimate.tbs) || 0;
    const themColor = tierColor(them);

    if (me.id && lastPlayerId && me.id === lastPlayerId) {
      card.innerHTML = `<div class="tw-cmp-loading">This is your own profile.</div>`;
      return;
    }

    myCache = me;

    const ratio = them > 0 ? you / them : Infinity;
    const verdict =
      ratio >= 1.25
        ? { text: "You're likely stronger", color: "#10b981" }
        : ratio <= 0.8
          ? { text: "They're likely stronger", color: "#ef4444" }
          : { text: "Close match", color: "#f59e0b" };

    const ff = fairFight(you, them);
    const ffHtml =
      ff == null
        ? ""
        : `<div class="tw-cmp-ff">Fair fight if you attack: ` +
          `<b style="color:${ffColor(ff)}">~${ff.toFixed(2)}×</b></div>`;

    const metrics = [
      { label: "Xanax taken", you: me.stats.xanaxTaken, them: target.stats.xanaxTaken, f: fmt },
      { label: "Attacks won", you: me.stats.attacksWon, them: target.stats.attacksWon, f: fmt },
      { label: "Respect", you: me.stats.respect, them: target.stats.respect, f: fmt },
      { label: "Elo", you: me.stats.elo, them: target.stats.elo, f: fmt },
      { label: "Networth", you: me.stats.networth, them: target.stats.networth, f: (n) => "$" + fmt(n) },
      { label: "War hits", you: me.stats.rankedWarHits, them: target.stats.rankedWarHits, f: fmt },
      { label: "Crimes", you: me.stats.crimes, them: target.stats.crimes, f: fmt },
      { label: "Time online", you: me.stats.activityTime, them: target.stats.activityTime, f: fmtDuration },
    ];

    const rows = metrics
      .map((m, i) => {
        const y = m.you || 0;
        const t = m.them || 0;
        const max = Math.max(y, t, 1);
        const yPct = (y / max) * 100;
        const tPct = (t / max) * 100;
        const yWin = y >= t;
        return `
        <div class="tw-cmp-row">
          <div class="tw-cmp-nums">
            <span class="${yWin ? "tw-strong" : "tw-dim"}">${m.f(m.you)}</span>
            <span class="tw-cmp-lbl">${m.label}</span>
            <span class="${!yWin ? "tw-strong" : "tw-dim"}">${m.f(m.them)}</span>
          </div>
          <div class="tw-cmp-track">
            <div class="tw-cmp-half left">
              <div class="tw-cmp-fill" data-w="${yPct}" data-d="${i * 40}" style="background:#0ea5e9"></div>
            </div>
            <div class="tw-cmp-half right">
              <div class="tw-cmp-fill" data-w="${tPct}" data-d="${i * 40}" style="background:${themColor}"></div>
            </div>
          </div>
        </div>`;
      })
      .join("");

    card.innerHTML = `
      <div class="tw-cmp-head">You vs ${target.profile.name}</div>
      <div class="tw-cmp-legend">
        <span><i style="background:#0ea5e9"></i>You</span>
        <span><i style="background:${themColor}"></i>${target.profile.name}</span>
      </div>
      <div class="tw-cmp-hero">
        <div class="tw-cmp-you" data-you="${you}">~${fmt(0)}</div>
        <div class="tw-cmp-mid">Battle stats</div>
        <div class="tw-cmp-them" data-them="${them}" style="color:${themColor}">~${fmt(0)}</div>
      </div>
      <div class="tw-cmp-verdict" style="color:${verdict.color}">${verdict.text}</div>
      ${ffHtml}
      <div class="tw-cmp-note">Your real total vs their estimate.</div>
      <div class="tw-cmp-rows">${rows}</div>
      <button class="tw-btn tw-btn-ghost tw-cmp-close" data-act="close">Close</button>`;

    card.querySelector(".tw-cmp-close").addEventListener("click", () => {
      const overlay = document.getElementById(COMPARE_ID);
      if (overlay) overlay.remove();
    });

    countUp(card.querySelector(".tw-cmp-you"), you, (v) => "~" + fmt(v));
    countUp(card.querySelector(".tw-cmp-them"), them, (v) => "~" + fmt(v));

    requestAnimationFrame(() => {
      card.querySelectorAll(".tw-cmp-fill").forEach((el) => {
        el.style.transitionDelay = `${el.dataset.d}ms`;
        el.style.width = `${el.dataset.w}%`;
      });
    });
  }

  // Main
  let currentRequestId = 0;

  function run() {
    const playerId = getPlayerId();
    lastPlayerId = playerId;
    lastData = null;
    if (!playerId) {
      const badge = document.getElementById(BADGE_ID);
      if (badge) badge.remove();
      return;
    }

    const requestId = ++currentRequestId;

    waitForMount((mountAfter) => {
      if (requestId !== currentRequestId) return;

      // Stale-while-revalidate: if we already estimated this player recently
      // (shared cache with the faction/user-list columns, GM storage, 6h TTL),
      // paint it instantly, then refresh quietly in the background.
      const cached = facCacheGet(playerId);
      if (cached) renderEstimate({ estimate: cached, profile: { id: playerId } }, mountAfter, true);
      else renderLoading(mountAfter);

      getEstimate(playerId).then((resp) => {
        if (requestId !== currentRequestId) return;
        if (resp && resp.ok) {
          if (resp.data && resp.data.estimate) facCacheSet(playerId, resp.data.estimate);
          renderEstimate(resp.data, mountAfter);
        } else if (!cached) {
          // Only surface an error if we had nothing cached to show.
          renderError(resp ? resp.error : "network", mountAfter);
        }
      });
    });
  }

  let lastUrl = location.href;
  new MutationObserver(() => {
    if (location.href !== lastUrl) {
      lastUrl = location.href;
      const cmp = document.getElementById(COMPARE_ID);
      if (cmp) cmp.remove();
      run();
    }
  }).observe(document, { subtree: true, childList: true });
  // Initial run() is at the bottom of the file: it uses FAC_TTL (a const defined
  // below), so calling it here would hit a temporal-dead-zone error.

  // Faction member list: "Est." column next to Lvl. Torn re-renders the list on
  // sort/search, so we reinject idempotently on mutations. Requests are throttled
  // (per-user rate limit) and cached (session + GM storage) so re-renders and
  // reloads don't refetch.

  const FAC_HEAD = "tw-fac-head";
  const FAC_CELL = "tw-fac-cell";
  // Active-war view renders a different (floated) member list — its own classes.
  const FACW_HEAD = "tw-facw-head";
  const FACW_CELL = "tw-facw-cell";
  // Enemies / friends / targets list (page.php?sid=list) — shared React table.
  const LIST_HEAD = "tw-list-head";
  const LIST_CELL = "tw-list-cell";
  const FAC_TTL = 6 * 60 * 60 * 1000; // 6h
  const FAC_SPACING = 2050; // ms between request launches — stay under the per-user rate limit
  const FAC_MAX_INFLIGHT = 4; // overlap network round-trips w/o exceeding the launch rate

  GM_addStyle(`
    .${FAC_HEAD}, .${FAC_CELL} {
      flex: 0 0 auto !important;
      width: 74px;
      box-sizing: border-box;
      text-align: center;
      justify-content: center;
      align-items: center;
      font-variant-numeric: tabular-nums;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .${FAC_CELL} { font-size: 12px; font-weight: 600; }
    .${FACW_HEAD}, .${FACW_CELL} {
      float: left; box-sizing: border-box; width: 34px;
      text-align: center; white-space: nowrap;
      overflow: hidden; text-overflow: ellipsis;
      font-size: 10px; font-variant-numeric: tabular-nums;
    }
    .${FACW_CELL} { font-weight: 600; }
    .tw-fac-toolbar { display: flex; justify-content: flex-end; margin: 8px 0; }
    .tw-fac-btn {
      padding: 5px 12px; border: 1px solid rgba(136,136,136,.4); border-radius: 6px;
      background: rgba(20,20,22,.6); color: #e5e7eb;
      font: 12px system-ui, sans-serif; cursor: pointer;
    }
    .tw-fac-btn:hover { background: rgba(255,255,255,.06); }
    /* User list: Est. cell floats where the icon column used to start (the icon
       column is shrunk by the same width in JS). Native layout is otherwise
       untouched, so Name/Level/Icons keep their positions and the header aligns. */
    /* width must match EST_W in JS below. The header columns are floats (class
       "left"), the row columns are inline-block — so match each to avoid the
       est cell jumping ahead of Level. */
    .tw-ul-head, .tw-ul-col {
      box-sizing: border-box; width: 84px;
      text-align: right; padding-right: 12px; white-space: nowrap; line-height: 30px;
      border-right: 1px solid rgba(128, 128, 128, 0.35);
    }
    .tw-ul-head { float: left; }
    .tw-ul-col {
      display: inline-block; vertical-align: middle;
      font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
    }
    /* Enemies / friends / targets list (page.php?sid=list). Our Est. cell clones
       the Level column's classes (for the box + vertical centering); width is set
       inline and reclaimed from the description column so the row total holds. */
    .${LIST_CELL} {
      box-sizing: border-box; text-align: center; white-space: nowrap;
      overflow: hidden; text-overflow: ellipsis;
      font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
    }
    .${LIST_HEAD} { box-sizing: border-box; text-align: center; cursor: pointer; }
  `);

  const facCache = new Map(); // pid -> estimate (session)
  const facInflight = new Set(); // pid queued or fetching
  let facAuthFailed = false; // stop hammering after a 401

  function facCacheGet(pid) {
    if (facCache.has(pid)) return facCache.get(pid);
    const row = GM_getValue("tw_est_" + pid, null);
    if (row && Date.now() - row.t < FAC_TTL) {
      facCache.set(pid, row.v);
      return row.v;
    }
    return null;
  }
  function facCacheSet(pid, est) {
    facCache.set(pid, est);
    GM_setValue("tw_est_" + pid, { t: Date.now(), v: est });
  }

  // Throttled fetch queue.
  const facQueue = [];
  let facDraining = false;
  let facActive = 0; // requests currently in flight
  function facEnqueue(job) {
    facQueue.push(job);
    if (!facDraining) facDrain();
  }
  async function facDrain() {
    facDraining = true;
    while (facQueue.length) {
      // Wait for an in-flight slot, then LAUNCH without awaiting completion so a
      // request's network round-trip overlaps the spacing gap (the old serial
      // `await` added RTT on top of the gap). Throughput is now the launch rate.
      while (facActive >= FAC_MAX_INFLIGHT) await new Promise((r) => setTimeout(r, 40));
      const job = facQueue.shift();
      facActive++;
      Promise.resolve(job()).finally(() => {
        facActive--;
      });
      if (facQueue.length) await new Promise((r) => setTimeout(r, FAC_SPACING));
    }
    facDraining = false;
  }

  // A cell renders into its .value child (user-list rows) or itself (faction
  // table). Write text only on change — replacing textContent is a childList
  // mutation our own observer would see, so unconditional writes would loop.
  function paintEst(cell, est) {
    const target = cell.querySelector(".value") || cell;
    const text = fmtCompact(est.tbs || 0);
    if (target.textContent !== text) target.textContent = text;
    target.style.color = tierColor(est.tbs || 0);
    const title = est.rangeText || "";
    if (cell.getAttribute("title") !== title) cell.setAttribute("title", title);
  }

  function paintText(cell, text, title) {
    const target = cell.querySelector(".value") || cell;
    if (target.textContent !== text) target.textContent = text;
    target.style.color = "";
    const t = title || "";
    if (cell.getAttribute("title") !== t) cell.setAttribute("title", t);
  }

  function estCellsFor(pid) {
    return document.querySelectorAll(`[data-tw-est][data-tw-pid="${pid}"]`);
  }

  // Shared per-cell logic for both the faction table and the user list: mark the
  // cell, then render from cache / prompt for a key / queue a throttled fetch.
  function ensureEst(cell, pid) {
    cell.setAttribute("data-tw-est", "");
    cell.dataset.twPid = String(pid);
    const cached = facCacheGet(pid);
    if (cached) return paintEst(cell, cached);
    if (facAuthFailed || !getApiKey())
      return paintText(cell, "key", "Set your API key from the userscript menu");
    paintText(cell, "…");
    facQueueFetch(pid);
  }

  function facPidFromRow(row) {
    const a = row.querySelector('a[href*="profiles.php?XID="]');
    const m = a && a.getAttribute("href").match(/XID=(\d+)/);
    return m ? Number(m[1]) : null;
  }

  function facQueueFetch(pid) {
    if (facInflight.has(pid)) return;
    facInflight.add(pid);
    facEnqueue(() => facRunFetch(pid));
  }
  async function facRunFetch(pid) {
    const resp = await getEstimate(pid);
    if (resp.ok && resp.data && resp.data.estimate) {
      facInflight.delete(pid);
      facCacheSet(pid, resp.data.estimate);
      estCellsFor(pid).forEach((c) => paintEst(c, resp.data.estimate));
    } else if (resp.error === "unauthorized") {
      facInflight.delete(pid);
      facAuthFailed = true;
      facQueue.length = 0; // drop the rest of the batch
      estCellsFor(pid).forEach((c) =>
        paintText(c, "key", "Key not linked — sign in at tornwars.com with this API key first"),
      );
    } else if (resp.error === "rate-limited") {
      await new Promise((r) => setTimeout(r, 5000)); // back off, keep inflight
      facEnqueue(() => facRunFetch(pid));
    } else {
      facInflight.delete(pid);
      estCellsFor(pid).forEach((c) => paintText(c, "—", "estimate unavailable"));
    }
  }

  // Sorting: reorder rows in place by cached estimate. Rows without an estimate
  // yet (unfetched / no key / error) always sink to the bottom.
  function estOf(row) {
    const pid = facPidFromRow(row);
    const est = pid ? facCacheGet(pid) : null;
    return est && typeof est.tbs === "number" ? est.tbs : null;
  }

  function sortByEst(parent, rowSelector, dir) {
    const rows = [...parent.querySelectorAll(rowSelector)];
    rows.sort((a, b) => {
      const ea = estOf(a);
      const eb = estOf(b);
      if (ea == null && eb == null) return 0;
      if (ea == null) return 1;
      if (eb == null) return -1;
      return dir === "asc" ? ea - eb : eb - ea;
    });
    rows.forEach((r) => parent.appendChild(r));
  }

  function attachEstSort(headerEl, baseLabel, parent, rowSelector) {
    headerEl.style.cursor = "pointer";
    headerEl.addEventListener("click", (e) => {
      // Don't let the click reach Torn's own column-sort handler.
      e.stopPropagation();
      const dir = headerEl.dataset.twSort === "desc" ? "asc" : "desc";
      headerEl.dataset.twSort = dir;
      headerEl.textContent = `${baseLabel} ${dir === "asc" ? "▲" : "▼"}`;
      sortByEst(parent, rowSelector, dir);
    });
  }

  function injectFactionColumn() {
    document.querySelectorAll(".members-list").forEach((list) => {
      const war = isWarList(list);
      // "Copy stats image" toolbar (idempotent per parent). In the war layout the
      // list body sits below a .white-grad header — put the bar above the header
      // so it doesn't shove the rows down.
      if (list.parentElement && !list.parentElement.querySelector(":scope > .tw-fac-toolbar")) {
        const bar = document.createElement("div");
        bar.className = "tw-fac-toolbar";
        const btn = document.createElement("button");
        btn.className = "tw-fac-btn";
        btn.textContent = "Copy stats image";
        btn.addEventListener("click", () => copyFactionImage(list));
        bar.appendChild(btn);
        const head = war && list.parentElement.querySelector(":scope > .white-grad");
        (head || list).insertAdjacentElement("beforebegin", bar);
      }

      // Active war uses a floated <ul><li> layout with no ul.table-header.
      if (war) injectWarList(list);
      else injectClassicList(list);
    });
  }

  // Classic members tab: ul.table-header + ul.table-body > li.table-row (flex).
  function injectClassicList(list) {
    const header = list.querySelector("ul.table-header");
    if (header && !header.querySelector("." + FAC_HEAD)) {
      const lvl = header.querySelector("li.lvl");
      if (lvl) {
        const h = document.createElement("li");
        h.className = "table-cell torn-divider divider-vertical " + FAC_HEAD;
        h.textContent = "Est.";
        h.title = "TornWars estimated total battle stats — click to sort";
        const body = list.querySelector("ul.table-body");
        if (body) attachEstSort(h, "Est.", body, ":scope > li.table-row");
        lvl.insertAdjacentElement("afterend", h);
      }
    }

    list.querySelectorAll("ul.table-body > li.table-row").forEach((row) => {
      const lvl = row.querySelector(".table-cell.lvl");
      if (!lvl) return;
      let cell = row.querySelector("." + FAC_CELL);
      if (!cell) {
        cell = document.createElement("div");
        cell.className = "table-cell torn-divider divider-vertical " + FAC_CELL;
        lvl.insertAdjacentElement("afterend", cell);
      }
      const pid = facPidFromRow(row);
      if (!pid) return paintText(cell, "—");
      ensureEst(cell, pid);
    });
  }

  // Active-war layout: rows are direct <li> children, columns are floated divs,
  // and the header is a sibling .white-grad. Insert an Est. cell after .level.
  function isWarList(list) {
    return (
      list.tagName === "UL" &&
      !list.querySelector("ul.table-header") &&
      !!list.querySelector(":scope > li")
    );
  }

  // War columns float and already fill the row, so reclaim the Est. width from
  // the member column (measured once, then shrunk) instead of widening the row.
  const FACW_W = 34;
  function shrinkWarMember(row) {
    const member = row.querySelector(":scope > .member");
    if (member && !member.dataset.twShrunk) {
      member.dataset.twShrunk = "1";
      member.style.boxSizing = "border-box";
      member.style.width = Math.max(60, member.offsetWidth - FACW_W) + "px";
    }
  }

  function injectWarList(list) {
    const cont = list.parentElement;
    const head = cont && cont.querySelector(":scope > .white-grad");
    if (head && !head.querySelector("." + FACW_HEAD)) {
      const lvl = head.querySelector(":scope > .level");
      if (lvl) {
        shrinkWarMember(head);
        const h = document.createElement("div");
        h.className = FACW_HEAD;
        h.textContent = "Est.";
        h.title = "TornWars estimated total battle stats — click to sort";
        if (lvl.offsetHeight) h.style.lineHeight = lvl.offsetHeight + "px";
        attachEstSort(h, "Est.", list, ":scope > li");
        lvl.insertAdjacentElement("afterend", h);
      }
    }

    list.querySelectorAll(":scope > li").forEach((row) => {
      const lvl = row.querySelector(":scope > .level");
      if (!lvl) return;
      shrinkWarMember(row);
      let cell = row.querySelector(":scope > ." + FACW_CELL);
      if (!cell) {
        cell = document.createElement("div");
        cell.className = FACW_CELL;
        // Match the native .level cell's height so the value centers vertically.
        if (lvl.offsetHeight) cell.style.lineHeight = lvl.offsetHeight + "px";
        lvl.insertAdjacentElement("afterend", cell);
      }
      const pid = facPidFromRow(row);
      if (!pid) return paintText(cell, "—");
      ensureEst(cell, pid);
    });
  }

  // User list (page.php?sid=UserList): "Est." column between Level and Icons.
  // The name column is left untouched — the est column reclaims its width from
  // the icons column (measured once, then shrunk by EST_W), applied identically
  // in the header and every row so the columns stay aligned.
  const EST_W = 84;

  function shrinkIcons(icons) {
    if (icons && !icons.dataset.twShrunk) {
      icons.dataset.twShrunk = "1";
      icons.style.boxSizing = "border-box";
      icons.style.width = Math.max(80, icons.offsetWidth - EST_W) + "px";
    }
  }

  function injectUserList() {
    const wrap = document.querySelector(".userlist-wrapper");
    if (!wrap) return;

    // Header "Est." cell — carve it out of the header's icon column.
    const title = wrap.querySelector(".users-list-title");
    if (title && !title.querySelector(".tw-ul-head")) {
      const icons = title.querySelector(".user-icons");
      if (icons) {
        shrinkIcons(icons);
        const h = document.createElement("div");
        h.className = "tw-ul-head";
        h.textContent = "Est.";
        icons.insertAdjacentElement("beforebegin", h);
      }
    }

    wrap.querySelectorAll("ul.user-info-list-wrap > li").forEach((row) => {
      const link = row.querySelector('a[href*="profiles.php?XID="]');
      const m = link && link.getAttribute("href").match(/XID=(\d+)/);
      const icons = row.querySelector(".level-icons-wrap .user-icons");
      if (!m || !icons) return;

      shrinkIcons(icons);
      let cell = row.querySelector(".tw-ul-col");
      if (!cell) {
        cell = document.createElement("span");
        cell.className = "tw-ul-col";
        icons.insertAdjacentElement("beforebegin", cell);
      }
      ensureEst(cell, Number(m[1]));
    });
  }

  // Enemies / friends / targets list (page.php?sid=list) — a shared React table
  // (.tableWrapper) with a flex header (.tableHead) and <li> rows. Insert an Est.
  // column after Level in both. Hashed module classes keep a stable readable
  // prefix before "___", so match on that.
  // Fixed Est. width, reclaimed from the (wide) description column so the row
  // total stays constant and the action buttons don't get pushed out.
  const EST_LIST_W = 60;
  function shrinkListDesc(row) {
    const desc = row.querySelector('[class*="description___"]');
    if (desc && !desc.dataset.twShrunk) {
      desc.dataset.twShrunk = "1";
      const w = desc.getBoundingClientRect().width;
      desc.style.boxSizing = "border-box";
      desc.style.width = Math.max(80, w - EST_LIST_W) + "px";
      desc.style.flexBasis = desc.style.width;
    }
  }

  function injectListTable() {
    document.querySelectorAll(".tableWrapper").forEach((wrap) => {
      const head = wrap.querySelector('[class*="tableHead"]');
      const ul = wrap.querySelector(":scope > ul");
      if (!head || !ul) return;

      if (!head.querySelector("." + LIST_HEAD)) {
        const lvl = head.querySelector('[class*="headingWrapper"][class*="level___"]');
        if (lvl) {
          shrinkListDesc(head);
          const h = document.createElement("div");
          // Clone the Level heading's classes for the box, fix our own width.
          h.className = lvl.className + " " + LIST_HEAD;
          h.style.width = EST_LIST_W + "px";
          h.style.flexBasis = EST_LIST_W + "px";
          h.textContent = "Est.";
          h.title = "TornWars estimated total battle stats — click to sort";
          attachEstSort(h, "Est.", ul, ":scope > li");
          lvl.insertAdjacentElement("afterend", h);
        }
      }

      ul.querySelectorAll(":scope > li").forEach((row) => {
        const group = row.querySelector('[class*="contentGroup"]') || row;
        const lvl = group.querySelector('[class*="level___"]');
        if (!lvl) return;
        shrinkListDesc(group);
        let cell = group.querySelector("." + LIST_CELL);
        if (!cell) {
          cell = document.createElement("div");
          // Clone the Level cell's classes for the box, fix our own width.
          cell.className = lvl.className + " " + LIST_CELL;
          cell.style.width = EST_LIST_W + "px";
          cell.style.flexBasis = EST_LIST_W + "px";
          lvl.insertAdjacentElement("afterend", cell);
        }
        const pid = facPidFromRow(row);
        if (!pid) return paintText(cell, "—");
        ensureEst(cell, pid);
      });
    });
  }

  // "Copy stats image": draw name / lvl / est to a canvas, copy to clipboard.
  // Scoped to one list — during a war there are two (enemy + your faction).
  function collectFactionRows(list) {
    list = list || document.querySelector(".members-list");
    if (!list) return [];
    const war = isWarList(list);
    const rows = war
      ? list.querySelectorAll(":scope > li")
      : list.querySelectorAll("ul.table-body > li.table-row");
    const out = [];
    rows.forEach((row) => {
      const link = row.querySelector('a[aria-label^="View profile of"]');
      const name = link
        ? link.getAttribute("aria-label").replace(/^View profile of\s*/, "")
        : "—";
      const lvlCell = war
        ? row.querySelector(":scope > .level")
        : row.querySelector(".table-cell.lvl");
      const level = lvlCell ? lvlCell.textContent.trim() : "";
      const pid = facPidFromRow(row);
      out.push({ name, level, est: pid ? facCacheGet(pid) : null });
    });
    return out;
  }

  // Name of the faction being viewed. The header text node holds the name; a
  // trailing <span class="f-title-respect"> holds the respect count, so read
  // only the direct text nodes. Not the top-nav chip — that's the viewer's own
  // faction.
  function factionName(list) {
    const head = document.querySelector(
      ".faction-info-wrap .title-black, .faction-profile .title-black",
    );
    if (head) {
      let name = "";
      head.childNodes.forEach((n) => {
        if (n.nodeType === Node.TEXT_NODE) name += n.textContent;
      });
      if (name.trim()) return name.trim();
    }
    // Active war: two side-by-side lists; map this list to its side's name.
    if (list) {
      const tab = list.closest(".tab-menu-cont");
      const enemy = tab && tab.classList.contains("enemy-faction");
      const nameDiv = document.querySelector(
        enemy ? ".faction-names .name.enemy" : ".faction-names .name.your",
      );
      if (nameDiv) {
        // The name div holds a text child and a score child; skip the score.
        const textDiv = [...nameDiv.children].find((c) => !/score/i.test(c.className));
        if (textDiv) return textDiv.textContent.trim();
      }
    }
    return "";
  }

  function fitText(ctx, text, maxW) {
    if (ctx.measureText(text).width <= maxW) return text;
    let t = text;
    while (t.length && ctx.measureText(t + "…").width > maxW) t = t.slice(0, -1);
    return t + "…";
  }

  function renderFactionCanvas(rows, list) {
    const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
    const pad = 16;
    const colRank = 34, colName = 190, colLvl = 46, colEst = 96;
    const W = pad * 2 + colRank + colName + colLvl + colEst;
    const titleH = 46, headH = 24, rowH = 22;
    const H = pad * 2 + titleH + headH + rows.length * rowH;

    const canvas = document.createElement("canvas");
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    const ctx = canvas.getContext("2d");
    ctx.scale(dpr, dpr);
    ctx.textBaseline = "middle";

    ctx.fillStyle = "#1c1c1e";
    ctx.fillRect(0, 0, W, H);

    // Title — faction name if we can read it, else a generic heading.
    const name = factionName(list);
    const date = new Date().toISOString().slice(0, 10);
    ctx.textAlign = "left";
    ctx.fillStyle = "#f3f4f6";
    ctx.font = "600 15px system-ui, sans-serif";
    ctx.fillText(fitText(ctx, name || "Faction stat estimates", W - pad * 2), pad, pad + 10);
    ctx.fillStyle = "#9ca3af";
    ctx.font = "11px system-ui, sans-serif";
    const sub = (name ? "Stat estimates · " : "") + `${rows.length} members · ${date} · TornWars`;
    ctx.fillText(fitText(ctx, sub, W - pad * 2), pad, pad + 30);

    const xRank = pad;
    const xName = xRank + colRank;
    const xLvl = xName + colName;
    const estRight = xLvl + colLvl + colEst;

    // Header.
    const hy = pad + titleH + headH / 2;
    ctx.font = "10px system-ui, sans-serif";
    ctx.fillStyle = "#9ca3af";
    ctx.textAlign = "left";
    ctx.fillText("#", xRank, hy);
    ctx.fillText("NAME", xName, hy);
    ctx.textAlign = "center";
    ctx.fillText("LVL", xLvl + colLvl / 2, hy);
    ctx.textAlign = "right";
    ctx.fillText("EST. STATS", estRight, hy);
    ctx.strokeStyle = "rgba(255,255,255,0.12)";
    ctx.beginPath();
    ctx.moveTo(pad, pad + titleH + headH);
    ctx.lineTo(W - pad, pad + titleH + headH);
    ctx.stroke();

    // Rows.
    rows.forEach((r, i) => {
      const top = pad + titleH + headH + i * rowH;
      const mid = top + rowH / 2;
      if (i % 2 === 1) {
        ctx.fillStyle = "rgba(255,255,255,0.03)";
        ctx.fillRect(pad, top, W - pad * 2, rowH);
      }
      ctx.textAlign = "left";
      ctx.fillStyle = "#6b7280";
      ctx.font = "11px system-ui, sans-serif";
      ctx.fillText(String(i + 1), xRank, mid);

      ctx.fillStyle = "#e5e7eb";
      ctx.font = "12px system-ui, sans-serif";
      ctx.fillText(fitText(ctx, r.name || "—", colName - 8), xName, mid);

      ctx.fillStyle = "#d1d5db";
      ctx.textAlign = "center";
      ctx.fillText(r.level || "—", xLvl + colLvl / 2, mid);

      ctx.textAlign = "right";
      if (r.est) {
        ctx.fillStyle = tierColor(r.est.tbs || 0);
        ctx.font = "600 12px system-ui, sans-serif";
        ctx.fillText("~" + fmt(r.est.tbs || 0), estRight, mid);
      } else {
        ctx.fillStyle = "#6b7280";
        ctx.font = "12px system-ui, sans-serif";
        ctx.fillText("—", estRight, mid);
      }
    });

    return canvas;
  }

  function facToast(msg) {
    const t = document.createElement("div");
    t.textContent = msg;
    t.style.cssText =
      "position:fixed;bottom:20px;left:50%;transform:translateX(-50%);" +
      "z-index:2147483647;background:#1c1c1e;color:#e5e7eb;padding:10px 16px;" +
      "border-radius:8px;font:13px system-ui,sans-serif;" +
      "box-shadow:0 6px 24px rgba(0,0,0,.4);opacity:0;transition:opacity .2s;";
    document.body.appendChild(t);
    requestAnimationFrame(() => (t.style.opacity = "1"));
    setTimeout(() => {
      t.style.opacity = "0";
      setTimeout(() => t.remove(), 300);
    }, 2400);
  }

  async function copyFactionImage(list) {
    const rows = collectFactionRows(list);
    if (!rows.length) return facToast("No member rows found on this page.");
    const canvas = renderFactionCanvas(rows, list);
    const missing = rows.filter((r) => !r.est).length;

    const blob = await new Promise((res) => canvas.toBlob(res, "image/png"));
    try {
      await navigator.clipboard.write([
        new ClipboardItem({ "image/png": blob }),
      ]);
      facToast(
        missing
          ? `Copied — ${missing} still estimating (shown as —)`
          : "Copied faction stats image to clipboard",
      );
    } catch (e) {
      // Clipboard image write blocked (e.g. not focused) — download instead.
      const a = document.createElement("a");
      a.href = canvas.toDataURL("image/png");
      a.download = "faction-stats.png";
      a.click();
      facToast("Clipboard blocked — downloaded the image instead");
    }
  }

  if (/factions\.php/.test(location.pathname)) {
    registerMenu("Copy faction stats image", () => copyFactionImage());
  }

  function injectEstColumns() {
    injectFactionColumn();
    injectUserList();
    injectListTable();
  }

  let facTimer = null;
  new MutationObserver(() => {
    clearTimeout(facTimer);
    facTimer = setTimeout(injectEstColumns, 400);
  }).observe(document.body, { childList: true, subtree: true });
  injectEstColumns();

  // Initial profile-badge render. Kept here (after FAC_TTL etc. are defined) so
  // the synchronous facCacheGet path inside run() doesn't hit a TDZ error.
  run();
})();