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