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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
})();