Adds an RST column to your faction members list: rolling 7-day respect per member, taken straight from Torn's own chain reports.
// ==UserScript==
// @name Torn Weekly Chain Respect
// @namespace https://www.torn.com/profiles.php?XID=4347781
// @version 2.0
// @description Adds an RST column to your faction members list: rolling 7-day respect per member, taken straight from Torn's own chain reports.
// @author Microddot [4347781]
// @license MIT
// @match https://www.torn.com/factions.php*
// @connect api.torn.com
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @run-at document-end
// @supportURL https://www.torn.com/profiles.php?XID=4347781
// @compatible chrome Tampermonkey or Violentmonkey
// @compatible firefox Tampermonkey or Violentmonkey
// @compatible edge Tampermonkey
// @compatible safari Tampermonkey
// ==/UserScript==
// Enjoying this script? Send a Xanax to Microddot [4347781]
// https://www.torn.com/profiles.php?XID=4347781
//
// Display-only: reads the Torn API and the page, injects one column. No game actions.
(function () {
'use strict';
var NS = 'frc_'; // storage + css namespace
var API = 'https://api.torn.com/v2/';
var COMMENT = 'FactionRespectColumn';
var TAB_ID = Math.random().toString(36).slice(2);
var PAGE_LIMIT = 100; // /faction/attacks caps limit at 100
var THROTTLE_MS = 800; // ~75 req/min, well under the 100/min cap
// Torn's FactionAttackResult enum (v2 openapi)
var RESULTS = ['None', 'Attacked', 'Mugged', 'Hospitalized', 'Arrested', 'Looted', 'Lost',
'Stalemate', 'Assist', 'Escape', 'Timeout', 'Special', 'Bounty', 'Interrupted'];
var FAILED = { Lost: 1, Stalemate: 1, Escape: 1, Timeout: 1, Interrupted: 1, None: 1 };
// modifier bitmask
var M_WAR = 1, M_RETAL = 2, M_OVERSEAS = 4, M_GROUP = 8, M_WARLORD = 16;
/* ------------------------------ GM wrappers ------------------------------ */
function gmGet(key, fallback) {
var raw;
try {
if (typeof GM_getValue === 'function') {
raw = GM_getValue(NS + key, undefined);
if (raw !== undefined && raw !== null) {
return typeof raw === 'string' ? JSON.parse(raw) : raw;
}
return fallback;
}
} catch (e) { /* fall through to localStorage */ }
try {
raw = localStorage.getItem(NS + key);
return raw === null ? fallback : JSON.parse(raw);
} catch (e) { return fallback; }
}
function gmSet(key, value) {
var s;
try { s = JSON.stringify(value); } catch (e) { return false; }
try {
if (typeof GM_setValue === 'function') { GM_setValue(NS + key, s); return true; }
} catch (e) { /* fall through */ }
try { localStorage.setItem(NS + key, s); return true; } catch (e) { return false; }
}
function gmAddStyle(css) {
try { if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; } } catch (e) {}
var st = document.createElement('style');
st.textContent = css;
(document.head || document.documentElement).appendChild(st);
}
/* --------------------------- Torn PDA support --------------------------- */
var PDA_API_KEY = "###PDA-APIKEY###";
var HAS_PDA_KEY = PDA_API_KEY.indexOf('###PDA') !== 0 && /^[A-Za-z0-9]{16}$/.test(PDA_API_KEY);
function httpGet(url, onOk, onErr) {
if (typeof window.PDA_httpGet === 'function') {
try {
Promise.resolve(window.PDA_httpGet(url)).then(function (r) {
onOk((r && (r.responseText || r.response)) || '');
}, function (e) { onErr(scrub(String(e))); });
return;
} catch (e) { /* fall through */ }
}
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest({
method: 'GET', url: url, timeout: 25000,
onload: function (r) {
if (r.status === 429) { onErr('Rate limited (HTTP 429)'); return; }
onOk(r.responseText || '');
},
onerror: function () { onErr('Network error'); },
ontimeout: function () { onErr('Request timed out'); }
});
return;
}
var ctl = new AbortController();
var t = setTimeout(function () { ctl.abort(); }, 25000);
fetch(url, { signal: ctl.signal })
.then(function (r) { return r.text(); })
.then(function (txt) { clearTimeout(t); onOk(txt); })
.catch(function (e) { clearTimeout(t); onErr(scrub(String((e && e.message) || e))); });
}
function scrub(s) { return String(s).replace(/key=[A-Za-z0-9]+/g, 'key=***'); }
/* ------------------------------- settings ------------------------------- */
var DEFAULTS = {
apiKey: '',
source: 'chainreport', // 'chainreport' (Torn's own chain numbers) | 'attacklog'
windowDays: 7,
includeBonusRespect: true, // chain milestone bonuses (10/25/50...), chainreport only
types: { regular: true, chain: true, war: true, retaliation: true, overseas: true },
countFailed: true, // attacklog only
refreshMinutes: 10,
maxPages: 150, // attacklog only
showTooltip: true
};
function deepMerge(base, over) {
var out = {}, k;
for (k in base) {
if (!Object.prototype.hasOwnProperty.call(base, k)) continue;
var b = base[k];
var o = over && Object.prototype.hasOwnProperty.call(over, k) ? over[k] : undefined;
if (b && typeof b === 'object' && !Array.isArray(b)) out[k] = deepMerge(b, o && typeof o === 'object' ? o : {});
else out[k] = (o === undefined || o === null) ? b : o;
}
return out;
}
var settings = deepMerge(DEFAULTS, gmGet('settings', {}));
if (HAS_PDA_KEY && !settings.apiKey) settings.apiKey = PDA_API_KEY;
function saveSettings() { gmSet('settings', settings); }
/* -------------------------------- cache -------------------------------- */
// chain-report mode: reports[chainId] = { start, end, chain, respect,
// rows: [[memberId, attacks, respect, best]], bonus: [[memberId, respect]] }
// attack-log mode: rows = [[attackId, endedUnix, attackerId, respectGain,
// chainNumber, resultIdx, modBits]]
function emptyCache() {
return { coverFrom: 0, lastEnded: 0, rows: [], reports: {}, fetchedAt: 0, truncated: false, factionId: 0 };
}
function normaliseCache(c) {
var out = emptyCache(), k;
if (c && typeof c === 'object') {
for (k in out) if (c[k] !== undefined && c[k] !== null) out[k] = c[k];
}
if (!Array.isArray(out.rows)) out.rows = [];
if (!out.reports || typeof out.reports !== 'object') out.reports = {};
return out;
}
var cache = normaliseCache(gmGet('cache', null));
function saveCache() {
if (gmSet('cache', cache)) return;
// storage full — halve the attack history and try once more
cache.rows = cache.rows.slice(Math.floor(cache.rows.length / 2));
if (cache.rows.length) cache.coverFrom = cache.rows[0][1];
gmSet('cache', cache);
}
/* ------------------------------- helpers ------------------------------- */
function nowSec() {
try {
if (typeof window.getCurrentTimestamp === 'function') {
var t = window.getCurrentTimestamp();
if (t > 1e9) return Math.floor(t > 1e11 ? t / 1000 : t);
}
} catch (e) {}
return Math.floor(Date.now() / 1000);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
function ago(ts) {
if (!ts) return 'never';
var d = nowSec() - Math.floor(ts / 1000);
if (d < 60) return d + 's ago';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
return Math.floor(d / 86400) + 'd ago';
}
// The column is 46px wide, so keep it short — the tooltip carries the exact value.
function fmtRespect(v) {
if (!v) return '0';
if (v < 10) return v.toFixed(2);
if (v < 100) return v.toFixed(1);
if (v < 10000) return String(Math.round(v));
if (v < 100000) return (v / 1000).toFixed(1) + 'k';
return Math.round(v / 1000) + 'k';
}
// Torn's chain milestones are 10, 25, 50, 100, 250, 500 ... (a 1-2.5-5 series),
// NOT 10/20/40, and the flat bonus respect they award (10, 20, 40, 80 ...) never
// appears in /faction/attacks at all — only in a chain report's `bonuses` array.
// So bonus respect is a chain-report-only figure; see recomputeChains().
function apiErrText(err) {
var code = err && err.code;
var map = {
1: 'API key missing', 2: 'Incorrect API key', 5: 'Rate limited — backing off',
6: 'Incorrect ID', 8: 'IP block',
7: 'Torn will not serve faction attacks for this key — usually the faction rank has no API access permission',
9: 'API temporarily disabled', 10: 'Key owner in federal jail',
11: 'Key changed too recently', 13: 'Key disabled (owner inactive)',
14: 'Daily read limit reached', 15: 'Temporary API error',
16: 'Key access level too low — this needs a Limited (or higher) key',
17: 'Torn backend error', 18: 'API key is paused', 24: 'API closed temporarily'
};
return (map[code] || (err && err.error) || 'Unknown API error') + ' (code ' + code + ')';
}
function isFatalKeyError(code) { return code === 1 || code === 2 || code === 13 || code === 16 || code === 18; }
/* ---------------------------- aggregation ----------------------------- */
// Two sources, and they measure genuinely different things:
//
// chainreport — Torn's own per-member figures from the report of every chain
// that reached 10 (shorter chains are never recorded). This is the number
// Torn itself shows for a chain, and the flat milestone bonuses (10/25/50…)
// are only ever available here.
// attacklog — every attack in /faction/attacks summed by respect_gain. Much
// larger, because most respect comes from short chains that never reach 10
// and so never appear in any chain report.
//
// Measured on a real week: chainreport 443.65 vs attacklog 3204.05.
var agg = { byId: {}, total: 0, counted: 0, max: 0, chains: 0 };
function entry(byId, id) {
return byId[id] || (byId[id] = { sum: 0, n: 0, best: 0, chains: 0, bonus: 0 });
}
function finishAgg(byId, counted, total, chains) {
var max = 0;
for (var id in byId) if (byId[id].sum > max) max = byId[id].sum;
agg = { byId: byId, total: total, counted: counted, max: max, chains: chains };
}
/* ---- attack-log aggregation ---- */
function passes(row) {
var chain = row[4] || 0;
var mods = row[6] || 0;
var result = RESULTS[row[5]] || 'None';
if (!settings.countFailed && FAILED[result]) return false;
var t = settings.types;
if (chain > 0 ? t.chain : t.regular) return true;
if (t.war && (mods & M_WAR)) return true;
if (t.retaliation && (mods & M_RETAL)) return true;
if (t.overseas && (mods & M_OVERSEAS)) return true;
return false;
}
function recomputeAttacks() {
var cutoff = nowSec() - settings.windowDays * 86400;
var byId = {}, counted = 0, total = 0, i, r, e;
for (i = 0; i < cache.rows.length; i++) {
r = cache.rows[i];
if (!r || r[1] < cutoff || !passes(r)) continue;
e = entry(byId, r[2]);
e.sum += r[3];
e.n++;
if (r[3] > e.best) e.best = r[3];
counted++;
total += r[3];
}
finishAgg(byId, counted, total, 0);
}
/* ---- chain-report aggregation ---- */
function recomputeChains() {
var cutoff = nowSec() - settings.windowDays * 86400;
var byId = {}, counted = 0, total = 0, chains = 0, id, rep, i, row, e;
for (id in cache.reports) {
rep = cache.reports[id];
if (!rep || (rep.end || rep.start || 0) < cutoff) continue;
chains++;
for (i = 0; i < rep.rows.length; i++) {
row = rep.rows[i]; // [memberId, attacks, respect, best]
e = entry(byId, row[0]);
e.sum += row[2];
e.n += row[1];
e.chains++;
if (row[3] > e.best) e.best = row[3];
counted += row[1];
total += row[2];
}
if (settings.includeBonusRespect) {
for (i = 0; i < rep.bonus.length; i++) {
row = rep.bonus[i]; // [memberId, respect]
e = entry(byId, row[0]);
e.sum += row[1];
e.bonus += row[1];
total += row[1];
}
}
}
finishAgg(byId, counted, total, chains);
}
function recompute() {
if (settings.source === 'attacklog') recomputeAttacks();
else recomputeChains();
}
/* ------------------------------- fetching ------------------------------ */
var fetching = false;
var lastError = '';
var progress = '';
var backoffUntil = 0; // set after an API error so the 60s tick can't hammer the key
function lockOk() {
var now = Date.now(), l = null;
try { l = JSON.parse(localStorage.getItem(NS + 'lock') || 'null'); } catch (e) {}
if (l && l.id !== TAB_ID && now - l.t < 45000) return false;
try { localStorage.setItem(NS + 'lock', JSON.stringify({ id: TAB_ID, t: now })); } catch (e) {}
return true;
}
function lockTouch() {
try { localStorage.setItem(NS + 'lock', JSON.stringify({ id: TAB_ID, t: Date.now() })); } catch (e) {}
}
function lockRelease() {
try { if ((JSON.parse(localStorage.getItem(NS + 'lock') || 'null') || {}).id === TAB_ID) localStorage.removeItem(NS + 'lock'); } catch (e) {}
}
function apiUrl(path, extra) {
return API + path + (path.indexOf('?') === -1 ? '?' : '&') + (extra ? extra + '&' : '') +
'comment=' + COMMENT + '&key=' + encodeURIComponent(settings.apiKey);
}
// one GET, JSON-parsed, Torn's error envelope turned into a message
function apiGet(url, cb) {
lockTouch();
httpGet(url, function (txt) {
var data;
try { data = JSON.parse(txt); } catch (e) { cb('Bad API response'); return; }
if (data && data.error) {
if (isFatalKeyError(data.error.code) && data.error.code !== 16) { settings.apiKey = ''; saveSettings(); }
cb(apiErrText(data.error));
return;
}
cb(null, data);
}, function (err) { cb(err); });
}
function refresh(force, done) {
done = done || function () {};
if (fetching) { done('Already refreshing'); return; }
if (!settings.apiKey) { lastError = 'No API key set'; render(); done(lastError); return; }
if (!force && Date.now() < backoffUntil) { done(lastError || 'Backing off'); return; }
// another tab may have refreshed since we loaded
var stored = gmGet('cache', null);
if (stored && stored.fetchedAt > (cache.fetchedAt || 0)) { cache = normaliseCache(stored); recompute(); }
var staleAfter = Math.max(1, settings.refreshMinutes) * 60000;
if (!force && cache.fetchedAt && Date.now() - cache.fetchedAt < staleAfter) { done(null); return; }
if (!lockOk()) { done('Another tab is refreshing'); return; }
fetching = true;
lastError = '';
render();
var run = settings.source === 'attacklog' ? runAttacks : runChains;
run(function (err) {
fetching = false;
progress = '';
lockRelease();
if (err) {
lastError = err;
backoffUntil = Date.now() + (/Rate limited|too many/i.test(err) ? 300000 : 120000);
render();
done(err);
return;
}
backoffUntil = 0;
cache.fetchedAt = Date.now();
saveCache();
recompute();
render();
done(null);
});
}
/* ---- chain-report fetch: 1 call for the chain list + 1 per new chain ---- */
// Chain reports never change once a chain has ended, so they are cached by
// chain id and a refresh normally costs a single request.
function runChains(done) {
var cutoff = nowSec() - settings.windowDays * 86400;
progress = 'chains…';
render();
apiGet(apiUrl('faction/chains', 'limit=100&sort=DESC&from=' + cutoff), function (err, data) {
if (err) { done(err); return; }
var list = (data && data.chains) || [];
var live = nowSec();
var want = [], i, c;
for (i = 0; i < list.length; i++) {
c = list[i];
if (!c || !c.id) continue;
if ((c.end || c.start || 0) < cutoff) continue;
want.push(c);
}
cache.truncated = list.length >= 100;
// drop reports that have aged out of the window
for (var id in cache.reports) {
var rep = cache.reports[id];
if (!rep || (rep.end || rep.start || 0) < cutoff) delete cache.reports[id];
}
var queue = [];
for (i = 0; i < want.length; i++) {
c = want[i];
var have = cache.reports[c.id];
var stillRunning = live - (c.end || 0) < 600; // may still be growing
if (!have || have.chain !== c.chain || stillRunning) queue.push(c);
}
if (!queue.length) { done(null); return; }
var n = 0;
function next() {
if (n >= queue.length) { done(null); return; }
var ch = queue[n];
progress = 'chain ' + (n + 1) + '/' + queue.length;
render();
apiGet(apiUrl('faction/' + ch.id + '/chainreport'), function (e2, d2) {
if (e2) { done(e2); return; }
var r = (d2 && (d2.chainreport || d2)) || {};
var rows = [], bonus = [], j, a, b;
for (j = 0; j < (r.attackers || []).length; j++) {
a = r.attackers[j];
if (!a || !a.id) continue;
var resp = a.respect || {};
var atk = a.attacks || {};
rows.push([a.id, +atk.total || 0, +resp.total || 0, +resp.best || 0]);
}
for (j = 0; j < (r.bonuses || []).length; j++) {
b = r.bonuses[j];
if (!b || !b.attacker_id) continue;
bonus.push([b.attacker_id, +b.respect || 0]);
}
if (r.faction_id) cache.factionId = r.faction_id;
cache.reports[ch.id] = {
start: r.start || ch.start || 0,
end: r.end || ch.end || 0,
chain: ch.chain || (r.details && r.details.chain) || 0,
respect: ch.respect || (r.details && r.details.respect) || 0,
rows: rows, bonus: bonus
};
n++;
setTimeout(next, THROTTLE_MS);
});
}
next();
});
}
/* ---- attack-log fetch (the alternative source) ---- */
function runAttacks(done) {
var desiredFrom = nowSec() - settings.windowDays * 86400;
var full = !cache.coverFrom || cache.coverFrom > desiredFrom + 120 || !cache.rows.length;
var from = full ? desiredFrom : Math.max(desiredFrom, (cache.lastEnded || desiredFrom) - 120);
var rows = full ? [] : cache.rows.slice();
var seen = {}, i;
for (i = 0; i < rows.length; i++) seen[rows[i][0]] = 1;
var pages = 0, added = 0, lastEnded = full ? 0 : (cache.lastEnded || 0), truncated = false;
progress = full ? 'building…' : 'updating…';
render();
function stop(err) {
if (err) { done(err); return; }
rows.sort(function (a, b) { return a[1] - b[1]; });
// Prune against the SAME cutoff this cycle fetched with, not a freshly
// computed one — re-deriving it here threw away the oldest row we had just
// fetched whenever the cycle took a second or more.
var kept = [];
for (i = 0; i < rows.length; i++) if (rows[i][1] >= desiredFrom) kept.push(rows[i]);
cache.rows = kept;
cache.coverFrom = desiredFrom;
cache.lastEnded = lastEnded || cache.lastEnded || desiredFrom;
cache.truncated = truncated;
done(null);
}
// Pagination is by hand on purpose. Torn's _metadata.links.next drops BOTH
// the api key and the filters, so following it returns error 2 "Incorrect
// key" on page 2 and the cycle fails blaming your key.
function page(fromTs) {
apiGet(apiUrl('faction/attacks', 'filters=outgoing&sort=ASC&limit=' + PAGE_LIMIT + '&from=' + fromTs), function (err, data) {
if (err) { stop(err); return; }
var list = (data && data.attacks) || [];
var pageMax = fromTs;
for (var j = 0; j < list.length; j++) {
var a = list[j];
var endedAny = (a && (a.ended || a.started)) || 0;
if (endedAny > pageMax) pageMax = endedAny;
if (!a || !a.attacker || !a.attacker.id) continue; // stealthed attacker
if (a.attacker.faction && a.attacker.faction.id) cache.factionId = a.attacker.faction.id;
if (endedAny > lastEnded) lastEnded = endedAny;
if (endedAny < desiredFrom || seen[a.id]) continue;
seen[a.id] = 1;
var m = a.modifiers || {};
var bits = 0;
if (a.is_ranked_war || a.is_territory_war || (+m.war > 1)) bits |= M_WAR;
if (+m.retaliation > 1) bits |= M_RETAL;
if (+m.overseas > 1) bits |= M_OVERSEAS;
if (+m.group > 1) bits |= M_GROUP;
if (+m.warlord > 1) bits |= M_WARLORD;
var ridx = RESULTS.indexOf(a.result);
rows.push([a.id, endedAny, a.attacker.id, Math.round((+a.respect_gain || 0) * 100) / 100,
a.chain || 0, ridx < 0 ? 0 : ridx, bits]);
added++;
}
pages++;
progress = 'page ' + pages + ' · ' + added + ' hits';
render();
if (list.length < PAGE_LIMIT) { stop(null); return; } // short page = last page
if (pages >= settings.maxPages) { truncated = true; stop(null); return; }
setTimeout(function () { page(pageMax > fromTs ? pageMax : fromTs + 1); }, THROTTLE_MS);
});
}
page(from);
}
/* --------------------------------- CSS --------------------------------- */
var stylesDone = false;
function injectStyles() {
if (stylesDone) return;
stylesDone = true;
var css = [
':root{--frc-fg:#333;--frc-dim:#888;--frc-accent:#1a7a2e;--frc-bg:#f7f7f7;--frc-border:#ccc;--frc-bar:rgba(46,160,67,.18);}',
'body.dark-mode,.dark-mode{--frc-fg:#d5d5d5;--frc-dim:#8b8b8b;--frc-accent:#4cc366;--frc-bg:#2b2b2b;--frc-border:#444;--frc-bar:rgba(76,195,102,.18);}',
// The row is a fixed-width flex line whose cells already have flex-shrink:1,
// so a fixed-width, non-shrinking cell of ours makes every other column give
// up a few px proportionally (measured: name 260>243, icons 231>217,
// position 112>105 — nothing wraps, nothing overflows).
'.frc-cell{flex:0 0 46px!important;width:46px;min-width:46px;box-sizing:border-box;padding:0 5px 0 3px;',
' display:flex!important;align-items:center;justify-content:flex-end;overflow:hidden;',
' font-size:11px;line-height:1.2;position:relative;white-space:nowrap;}',
'.frc-cell .frc-bar{position:absolute;left:2px;right:2px;bottom:2px;height:2px;background:var(--frc-bar);border-radius:1px;}',
'.frc-cell .frc-bar i{display:block;height:100%;background:var(--frc-accent);border-radius:1px;opacity:.75;}',
'.frc-val{font-weight:700;color:var(--frc-accent);}',
'.frc-zero{color:var(--frc-dim);font-weight:400;}',
'.frc-head{cursor:pointer;justify-content:flex-end;gap:2px;font-weight:700;font-size:10.5px;}',
'.frc-head .frc-gear{opacity:.6;font-weight:400;font-size:11px;}',
'.frc-head .frc-gear:hover{opacity:1;}',
'.frc-short{display:none;}',
'.frc-spin{display:inline-block;animation:frc-rot 1s linear infinite;}',
'@keyframes frc-rot{to{transform:rotate(360deg);}}',
/* ---- settings sheet. Torn styles bare h3/h4/p/label hard (that is what
blew the headings up), so every element here is a div/span carrying its
own !important typography, and the sheet goes full-screen on phones. ---- */
'.frc-wrap{position:fixed;top:0;right:0;bottom:0;left:0;z-index:2147483000;display:flex;',
' align-items:center;justify-content:center;padding:14px;background:rgba(0,0,0,.62);}',
'.frc-modal{box-sizing:border-box;width:100%;max-width:420px;max-height:100%;overflow-y:auto;',
' -webkit-overflow-scrolling:touch;background:var(--frc-bg);color:var(--frc-fg);',
' border:1px solid var(--frc-border);border-radius:10px;padding:14px;',
' font-size:13px!important;line-height:1.45!important;text-align:left;',
' box-shadow:0 10px 40px rgba(0,0,0,.45);}',
'.frc-modal *{box-sizing:border-box;}',
'.frc-top{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0 0 10px;}',
'.frc-title{font-size:15px!important;line-height:1.25!important;font-weight:700!important;',
' letter-spacing:0;margin:0!important;}',
'.frc-x{flex:0 0 auto;width:34px;height:34px;display:flex;align-items:center;justify-content:center;',
' cursor:pointer;color:var(--frc-dim);font-size:18px!important;border-radius:6px;user-select:none;}',
'.frc-x:hover{color:var(--frc-fg);background:rgba(127,127,127,.14);}',
'.frc-status{font-size:12px!important;line-height:1.45!important;color:var(--frc-dim);',
' background:rgba(127,127,127,.1);border-radius:7px;padding:8px 10px;margin:0 0 2px;word-break:break-word;}',
'.frc-h{font-size:11px!important;line-height:1.3!important;font-weight:700!important;',
' color:var(--frc-dim);text-transform:uppercase;',
' letter-spacing:.06em;margin:14px 0 6px!important;padding:12px 0 0!important;',
' border-top:1px solid var(--frc-border);}',
'.frc-h.frc-h0{border-top:0;padding-top:2px!important;margin-top:10px!important;}',
'.frc-h span{text-transform:none;font-weight:400!important;letter-spacing:0;}',
'.frc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));gap:0 12px;}',
'.frc-chk{display:flex!important;align-items:center;gap:9px;min-height:34px;margin:0!important;',
' cursor:pointer;font-size:13px!important;line-height:1.3!important;color:var(--frc-fg);}',
'.frc-chk input{flex:0 0 auto;width:17px;height:17px;margin:0;accent-color:var(--frc-accent);}',
'.frc-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:8px 0;line-height:1.3!important;}',
'.frc-row .frc-lab{flex:1 1 auto;font-size:13px!important;line-height:1.3!important;min-width:110px;}',
'.frc-modal input[type=text],.frc-modal input[type=number]{background:transparent!important;',
' color:var(--frc-fg)!important;border:1px solid var(--frc-border)!important;border-radius:7px!important;',
' padding:8px 9px!important;font-size:16px!important;min-height:38px;width:100%;max-width:100%;}',
'.frc-modal input:focus{outline:none;border-color:var(--frc-accent)!important;}',
'.frc-num{flex:0 0 92px;width:92px!important;text-align:right;}',
'.frc-key{flex:1 1 140px;min-width:0;-webkit-text-security:disc;letter-spacing:.12em;}',
'#' + NS + 'showkey{flex:0 0 auto;min-width:66px;}',
'.frc-btn{display:inline-flex;align-items:center;justify-content:center;min-height:36px;',
' padding:7px 13px!important;font-size:12px!important;font-weight:600;background:transparent;',
' color:var(--frc-fg);border:1px solid var(--frc-border);border-radius:7px;cursor:pointer;margin:0;}',
'.frc-btn:hover{border-color:var(--frc-accent);color:var(--frc-accent);}',
'.frc-btn:active{transform:translateY(1px);}',
'.frc-btn.frc-on{border-color:var(--frc-accent);color:var(--frc-accent);box-shadow:inset 0 0 0 1px var(--frc-accent);}',
'.frc-btns{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 0;}',
'.frc-btns .frc-btn{flex:1 1 42%;}',
'.frc-note{font-size:11.5px!important;line-height:1.45!important;color:var(--frc-dim);margin:6px 0 0;}',
'.frc-err{color:#e0645f!important;}',
'.frc-foot{margin-top:16px;padding-top:10px;border-top:1px solid var(--frc-border);font-size:12px!important;}',
'.frc-foot a{color:var(--frc-accent)!important;text-decoration:none;}',
'.frc-locked{overflow:hidden!important;}',
'@media (max-width:784px){',
' .frc-cell{flex:0 0 40px!important;width:40px;min-width:40px;font-size:10px;padding:0 3px 0 1px;}',
' .frc-head{font-size:9.5px;}.frc-long{display:none;}.frc-short{display:inline;}',
'}',
'@media (max-width:600px){',
' .frc-wrap{padding:0;align-items:stretch;}',
' .frc-modal{max-width:none;height:100%;max-height:100%;border:0;border-radius:0;',
' padding:14px 14px calc(28px + env(safe-area-inset-bottom,0px));}',
' .frc-grid{grid-template-columns:1fr;}',
' .frc-chk{min-height:40px;}',
' .frc-num{flex:0 0 100px;width:100px!important;}',
' .frc-key{flex:1 1 140px;width:auto!important;}',
' .frc-btns .frc-btn{flex:1 1 42%;}',
'}'
].join('');
gmAddStyle(css);
}
/* --------------------------- table discovery --------------------------- */
// Torn's faction members block has changed shape more than once, so find the
// pieces structurally: rows are whatever contains a profile link, the body is
// their common parent, the header is the sibling that labels the columns.
function findTable() {
var links = document.querySelectorAll('a[href*="XID="]');
var groups = [], i, j;
for (i = 0; i < links.length; i++) {
var row = links[i];
while (row && row !== document.body) {
var tag = row.tagName;
var cls = typeof row.className === 'string' ? row.className : '';
if (tag === 'LI' || tag === 'TR' || /table-row/.test(cls)) break;
row = row.parentElement;
}
if (!row || row === document.body || row.tagName === 'BODY') continue;
if (!row.parentElement) continue;
var found = null;
for (j = 0; j < groups.length; j++) if (groups[j].parent === row.parentElement) { found = groups[j]; break; }
if (!found) { found = { parent: row.parentElement, rows: [] }; groups.push(found); }
if (found.rows.indexOf(row) === -1) found.rows.push(row);
}
if (!groups.length) return null;
groups.sort(function (a, b) { return b.rows.length - a.rows.length; });
var body = groups[0].parent;
var rows = groups[0].rows;
if (rows.length < 2) return null;
// header: nearest previous sibling (or aunt) that looks like a titles row
var header = null;
var probe = body.previousElementSibling;
var hops = 0;
while (probe && hops++ < 3) {
if (looksLikeHeader(probe)) { header = probe; break; }
probe = probe.previousElementSibling;
}
if (!header && body.parentElement) {
var cand = body.parentElement.querySelectorAll('[class*="table-header"], li.titles, [class*="titles"]');
for (i = 0; i < cand.length; i++) if (looksLikeHeader(cand[i])) { header = cand[i]; break; }
}
return { body: body, rows: rows, header: header };
}
function looksLikeHeader(el) {
if (!el || el.nodeType !== 1) return false;
var cls = typeof el.className === 'string' ? el.className : '';
var txt = (el.textContent || '').replace(/\s+/g, ' ').trim();
if (/table-header|titles/i.test(cls)) return true;
return /Position/i.test(txt) && /Status/i.test(txt) && txt.length < 200;
}
// Where our cell goes: immediately right of the Icons column.
// Torn's markup (both header row and member rows):
// .table-cell.member.icons.membersCol___x <- name cell, also carries "icons"
// .table-cell.lvl.lvlCol___x
// .table-cell.member-icons[.icons] <- the real Icons column
// .table-cell.position...
// so match "member-icons" first and only then fall back.
function clsOf(el) { return el && typeof el.className === 'string' ? el.className : ''; }
function insertIndexFor(row) {
var kids = row.children, i, c;
for (i = 0; i < kids.length; i++) if (/member-icons/i.test(clsOf(kids[i]))) return i + 1;
for (i = 0; i < kids.length; i++) if (/^icons$|^Icons$/.test((kids[i].textContent || '').trim())) return i + 1;
for (i = 0; i < kids.length; i++) {
c = clsOf(kids[i]);
if (/\bicons\b/i.test(c) && !/\bmember\b/i.test(c)) return i + 1;
}
for (i = 0; i < kids.length; i++) if (/\bposition\b/i.test(clsOf(kids[i]))) return i;
for (i = 0; i < kids.length; i++) if (/\bdays\b|\bstatus\b/i.test(clsOf(kids[i]))) return i;
return kids.length;
}
// Copy only the generic layout/divider classes from a neighbouring cell so
// Torn's own styling (cell box, vertical divider) still applies to ours.
function templateClassFrom(row, idx) {
var ref = row.children[idx] || row.children[row.children.length - 1];
return clsOf(ref).split(/\s+/).filter(function (c) {
return /^(table-cell|torn-divider|divider-vertical|left|right|center)$/.test(c);
}).join(' ');
}
function userIdOf(row) {
var a = row.querySelector('a[href*="XID="]');
if (!a) return 0;
var m = /XID=(\d+)/.exec(a.getAttribute('href') || '');
return m ? parseInt(m[1], 10) : 0;
}
/* -------------------------------- render -------------------------------- */
var injecting = false;
function ownFactionPage() {
var q = new URLSearchParams(location.search);
var id = q.get('ID');
if (!id) return true; // step=your / no explicit faction
if (!cache.factionId) return true; // unknown yet — allow, first fetch settles it
return parseInt(id, 10) === cache.factionId;
}
// The cell is 46px (40px on phones), so the header has to be tiny: RST, with
// the meaning in the tooltip. The gear is desktop-only — on narrow screens a
// tap anywhere in the header opens the panel instead.
function headLabel() {
if (fetching) return '<span class="frc-spin">⟳</span>';
return 'RST';
}
function narrow() { return window.innerWidth < 784; }
function render() {
if (injecting) return;
injecting = true;
try { renderInner(); } catch (e) { /* never break the page */ }
injecting = false;
}
function renderInner() {
if (!ownFactionPage()) return;
var t = findTable();
if (!t) return;
// header cell
if (t.header) {
if (!t.header.querySelector('[data-frc="head"]')) {
var hIdx = insertIndexFor(t.header);
var head = document.createElement('div');
head.setAttribute('data-frc', 'head');
head.className = 'frc-cell frc-head ' + templateClassFrom(t.header, hIdx - 1);
head.title = 'RST = ' + (settings.source === 'attacklog'
? 'respect from every attack'
: 'respect from Torn chain reports (chains of 10+)') +
' in the last ' + settings.windowDays + ' days. ' +
(narrow() ? 'Tap for settings.' : 'Click to refresh, ⚙ for settings.');
head.innerHTML = '<span class="frc-headlabel">' + headLabel() + '</span>' +
'<span class="frc-gear frc-long" title="Settings">⚙</span>';
head.addEventListener('click', function (ev) {
ev.stopPropagation();
ev.preventDefault();
if (narrow()) { openPanel(); return; }
if (ev.target && ev.target.classList && ev.target.classList.contains('frc-gear')) { openPanel(); return; }
refresh(true);
});
t.header.insertBefore(head, t.header.children[hIdx] || null);
} else {
var lbl = t.header.querySelector('.frc-headlabel');
if (lbl) lbl.innerHTML = headLabel();
}
}
// member cells
for (var r = 0; r < t.rows.length; r++) {
var row = t.rows[r];
var uid = userIdOf(row);
if (!uid) continue;
var cell = row.querySelector('[data-frc="cell"]');
if (!cell) {
var idx = insertIndexFor(row);
cell = document.createElement('div');
cell.setAttribute('data-frc', 'cell');
cell.className = 'frc-cell ' + templateClassFrom(row, idx - 1);
row.insertBefore(cell, row.children[idx] || null);
}
paint(cell, uid);
}
}
function paint(cell, uid) {
var e = agg.byId[uid];
var sum = e ? e.sum : 0;
var txt = settings.apiKey ? fmtRespect(sum) : '—';
var bar = agg.max > 0 && sum > 0 ? Math.max(3, Math.round(100 * sum / agg.max)) : 0;
var html = '<span class="frc-val' + (sum ? '' : ' frc-zero') + '">' + txt + '</span>';
if (bar) html += '<span class="frc-bar"><i style="width:' + bar + '%"></i></span>';
if (cell.getAttribute('data-frc-v') !== txt + '|' + bar) {
cell.innerHTML = html;
cell.setAttribute('data-frc-v', txt + '|' + bar);
}
if (settings.showTooltip) cell.title = tooltipFor(e);
}
function tooltipFor(e) {
if (!settings.apiKey) return 'Set an API key — tap the column header (⚙ on desktop)';
var chainMode = settings.source !== 'attacklog';
if (!e) {
return chainMode
? 'No hits in a recorded chain (10+) in the last ' + settings.windowDays + ' days'
: 'No counted attacks in the last ' + settings.windowDays + ' days';
}
var bits = [];
if (chainMode) bits.push(e.chains + ' chain' + (e.chains === 1 ? '' : 's'));
bits.push(e.n + ' attack' + (e.n === 1 ? '' : 's'));
bits.push(e.sum.toFixed(2) + ' respect');
if (e.n) bits.push('avg ' + ((e.sum - e.bonus) / e.n).toFixed(2));
if (e.best) bits.push('best hit ' + e.best.toFixed(2));
if (e.bonus) bits.push('incl. ' + e.bonus.toFixed(0) + ' milestone bonus');
return bits.join(' · ');
}
/* -------------------------------- panel -------------------------------- */
function keyFingerprint(k) {
if (!k) return 'none';
return k.length + ' chars ending “' + k.slice(-4) + '”';
}
// /key/info only needs Public access, so it can tell an invalid key apart from
// a key whose access level is too low — which is the difference between
// "code 2" and "code 16" and the usual reason this column stays empty.
function testKey(k, cb) {
var url = API + 'key/info?comment=' + COMMENT + '&key=' + encodeURIComponent(k);
httpGet(url, function (txt) {
var d;
try { d = JSON.parse(txt); } catch (e) { cb('Bad response from the API.', true); return; }
if (d && d.error) {
var extra = d.error.code === 2
? ' Torn does not recognise this key. Check for a typo (use Show and compare against Settings → API Key), make sure it has not been deleted, and that you copied the key itself rather than its name.'
: '';
cb(apiErrText(d.error) + extra, true);
return;
}
var info = (d && d.info) || {};
var acc = info.access || {};
var user = info.user || {};
var fsel = (info.selections && info.selections.faction) || [];
var canAttacks = fsel.length === 0 || fsel.indexOf('attacks') !== -1;
var bits = ['Key is valid — user ' + (user.id || '?') +
', faction ' + (user.faction_id || 'none') +
', access ' + (acc.type || '?') + ' (level ' + (acc.level == null ? '?' : acc.level) + ')'];
var bad = false;
if (acc.level != null && acc.level < 2) { bits.push('⚠ This is a Public key — faction attacks need Limited or higher.'); bad = true; }
if (acc.faction === false) { bits.push('⚠ The key cannot read faction data. Your faction rank needs the API access permission.'); bad = true; }
if (!canAttacks) { bits.push('⚠ This custom key does not include the faction “attacks” selection.'); bad = true; }
if (!user.faction_id) { bits.push('⚠ The key owner is not in a faction.'); bad = true; }
cb(bits.join(' '), bad);
}, function (err) { cb(err, true); });
}
function openPanel() {
if (document.getElementById(NS + 'modal')) return;
var wrap = document.createElement('div');
wrap.className = 'frc-wrap';
wrap.id = NS + 'wrap';
var m = document.createElement('div');
m.className = 'frc-modal';
m.id = NS + 'modal';
function chk(id, label) {
return '<label class="frc-chk"><input type="checkbox" id="' + NS + id + '"><span>' + label + '</span></label>';
}
function num(id, label, min, max) {
return '<div class="frc-row"><span class="frc-lab">' + label + '</span>' +
'<input type="number" class="frc-num" id="' + NS + id + '" min="' + min + '" max="' + max + '" step="1"></div>';
}
m.innerHTML =
'<div class="frc-top"><div class="frc-title">Weekly Chain Respect</div>' +
'<div class="frc-x" id="' + NS + 'close" title="Close">✕</div></div>' +
'<div class="frc-status" id="' + NS + 'status"></div>' +
'<div class="frc-h frc-h0">API key <span>— Limited access or higher</span></div>' +
'<div class="frc-row">' +
'<input type="text" class="frc-key" id="' + NS + 'key" autocomplete="off" spellcheck="false" ' +
'autocapitalize="off" inputmode="latin" data-lpignore="true" data-1p-ignore="true" ' +
'placeholder="16-character key">' +
'<button class="frc-btn" id="' + NS + 'showkey" type="button">Show</button></div>' +
'<div class="frc-btns"><button class="frc-btn" id="' + NS + 'savekey" type="button">Save key</button>' +
'<button class="frc-btn" id="' + NS + 'testkey" type="button">Test key</button></div>' +
'<div class="frc-note">Torn → Settings → API Key. Only faction attack data is read, and the key never leaves this browser.</div>' +
'<div class="frc-h">Where the respect comes from</div>' +
'<div class="frc-btns">' +
'<button class="frc-btn" id="' + NS + 'src-chain" type="button">Chain reports</button>' +
'<button class="frc-btn" id="' + NS + 'src-log" type="button">Attack log</button></div>' +
'<div class="frc-note" id="' + NS + 'srcnote"></div>' +
'<div class="frc-h">Window</div>' +
num('days', 'Rolling days', 1, 30) +
num('mins', 'Refresh every (min)', 1, 240) +
'<div id="' + NS + 'blk-chain">' +
'<div class="frc-h">Chain report options</div>' +
'<div class="frc-grid">' + chk('bonusresp', 'Add milestone bonuses (10/25/50…)') + '</div>' +
'</div>' +
'<div id="' + NS + 'blk-log">' +
'<div class="frc-h">Attack log options</div>' +
'<div class="frc-grid">' +
chk('t-regular', 'Regular (not in a chain)') +
chk('t-chain', 'Chain') +
chk('t-war', 'War') +
chk('t-retaliation', 'Retaliation') +
chk('t-overseas', 'Overseas') +
chk('failed', 'Lost / stalemate / escape') +
'</div>' +
'<div class="frc-btns"><button class="frc-btn" id="' + NS + 'p-all" type="button">All attacks</button>' +
'<button class="frc-btn" id="' + NS + 'p-chain" type="button">Chain hits only</button></div>' +
num('pages', 'Max API pages', 1, 500) +
'</div>' +
'<div class="frc-h">Other</div>' +
'<div class="frc-grid">' + chk('tip', 'Hover tooltips') + '</div>' +
'<div class="frc-btns"><button class="frc-btn" id="' + NS + 'refresh" type="button">Refresh now</button>' +
'<button class="frc-btn" id="' + NS + 'rebuild" type="button">Rebuild cache</button></div>' +
'<div class="frc-foot">' +
'<a href="https://www.torn.com/profiles.php?XID=4347781" target="_blank" rel="noopener">' +
'💊 Send a Xanax to Microddot [4347781] if you enjoy this script</a></div>';
wrap.appendChild(m);
document.body.appendChild(wrap);
document.documentElement.classList.add('frc-locked');
document.body.classList.add('frc-locked');
var $ = function (id) { return document.getElementById(NS + id); };
function close() {
var w = $('wrap');
if (w) w.remove();
document.documentElement.classList.remove('frc-locked');
document.body.classList.remove('frc-locked');
document.removeEventListener('keydown', onKey);
}
function onKey(ev) { if (ev.key === 'Escape') close(); }
document.addEventListener('keydown', onKey);
wrap.addEventListener('click', function (ev) { if (ev.target === wrap) close(); });
$('close').addEventListener('click', close);
$('key').value = settings.apiKey || '';
$('days').value = settings.windowDays;
$('mins').value = settings.refreshMinutes;
$('pages').value = settings.maxPages;
$('bonusresp').checked = !!settings.includeBonusRespect;
$('failed').checked = !!settings.countFailed;
$('tip').checked = !!settings.showTooltip;
['regular', 'chain', 'war', 'retaliation', 'overseas'].forEach(function (k) {
$('t-' + k).checked = !!settings.types[k];
});
function paintSource() {
var chainMode = settings.source !== 'attacklog';
$('src-chain').classList.toggle('frc-on', chainMode);
$('src-log').classList.toggle('frc-on', !chainMode);
$('blk-chain').style.display = chainMode ? '' : 'none';
$('blk-log').style.display = chainMode ? 'none' : '';
$('srcnote').textContent = chainMode
? 'Torn’s own per-member figures from the report of every chain that reached 10. Shorter chains are never recorded by Torn, so they are not counted. Costs about one API call per refresh.'
: 'Every attack in your faction log, summed by respect gained — includes the many short chains Torn never reports, so totals run much higher. Costs one call per 100 attacks.';
}
paintSource();
function status(msg, cls) {
var s = $('status');
if (!s) return;
var base;
if (settings.source === 'attacklog') {
base = 'Attack log: ' + cache.rows.length + ' attacks cached · counted ' + agg.counted;
} else {
base = 'Chain reports: ' + agg.chains + ' chains · ' + agg.counted + ' attacks';
}
base += ' · total ' + agg.total.toFixed(2) + ' respect · updated ' + ago(cache.fetchedAt) +
' · key: ' + keyFingerprint(settings.apiKey);
if (cache.truncated) base += ' · ⚠ hit the fetch cap, older data may be missing';
s.innerHTML = '<div>' + escapeHtml(base) + '</div>' +
(msg ? '<div class="' + (cls || 'frc-dim') + '">' + escapeHtml(msg) + '</div>' : '') +
(lastError && !msg ? '<div class="frc-err">' + escapeHtml(lastError) + '</div>' : '');
}
status(progress || '');
function afterChange(refetch, note) {
saveSettings();
recompute();
render();
if (refetch) {
status('Refreshing…');
backoffUntil = 0;
refresh(true, function (e) { status(e || (note || 'Updated.'), e ? 'frc-err' : 'frc-dim'); });
} else {
status(note || 'Updated.');
}
}
$('src-chain').addEventListener('click', function () {
if (settings.source === 'chainreport') return;
settings.source = 'chainreport';
paintSource();
afterChange(true, 'Switched to Torn chain reports.');
});
$('src-log').addEventListener('click', function () {
if (settings.source === 'attacklog') return;
settings.source = 'attacklog';
paintSource();
afterChange(true, 'Switched to the attack log.');
});
// keep only key characters, so a stray space or newline from a copy/paste
// can never reach the API as part of the key
function cleanKey() {
var raw = $('key').value || '';
var v = raw.replace(/[^A-Za-z0-9]/g, '');
if (v !== raw) $('key').value = v;
return v;
}
$('key').addEventListener('input', cleanKey);
$('key').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); $('savekey').click(); } });
var shown = false;
$('showkey').addEventListener('click', function () {
shown = !shown;
$('key').style.webkitTextSecurity = shown ? 'none' : 'disc';
$('showkey').textContent = shown ? 'Hide' : 'Show';
});
$('testkey').addEventListener('click', function () {
var v = cleanKey();
if (!/^[A-Za-z0-9]{16}$/.test(v)) {
status('A Torn key is 16 letters/numbers — this one is ' + v.length + '.', 'frc-err');
return;
}
status('Testing key…');
testKey(v, function (msg, bad) { status(msg, bad ? 'frc-err' : 'frc-dim'); });
});
$('savekey').addEventListener('click', function () {
var v = cleanKey();
if (v && !/^[A-Za-z0-9]{16}$/.test(v)) {
status('A Torn key is 16 letters/numbers — this one is ' + v.length + '. Nothing saved.', 'frc-err');
return;
}
settings.apiKey = v;
saveSettings();
if (!v) { status('Key cleared.'); render(); return; }
status('Checking key…');
cache = emptyCache();
saveCache();
recompute();
backoffUntil = 0;
refresh(true, function (err) {
if (!err) { status('Key works — column filled in.'); return; }
// an attacks failure is ambiguous, so say exactly what Torn thinks of the key
testKey(v, function (msg) { status(err + ' — ' + msg, 'frc-err'); });
});
});
function bindNum(id, key, min, max, needsRefetch) {
$(id).addEventListener('change', function () {
var v = parseInt($(id).value, 10);
if (isNaN(v) || v < min || v > max) { $(id).value = settings[key]; return; }
settings[key] = v;
afterChange(needsRefetch);
});
}
bindNum('days', 'windowDays', 1, 30, true);
bindNum('mins', 'refreshMinutes', 1, 240, false);
bindNum('pages', 'maxPages', 1, 500, false);
function bindChk(id, apply) {
$(id).addEventListener('change', function () { apply($(id).checked); afterChange(false); });
}
bindChk('bonusresp', function (v) { settings.includeBonusRespect = v; });
bindChk('failed', function (v) { settings.countFailed = v; });
bindChk('tip', function (v) { settings.showTooltip = v; });
['regular', 'chain', 'war', 'retaliation', 'overseas'].forEach(function (k) {
bindChk('t-' + k, function (v) { settings.types[k] = v; });
});
function applyPreset(types, failed) {
settings.types = types;
settings.countFailed = failed;
['regular', 'chain', 'war', 'retaliation', 'overseas'].forEach(function (k) { $('t-' + k).checked = !!types[k]; });
$('failed').checked = failed;
afterChange(false, 'Preset applied.');
}
$('p-all').addEventListener('click', function () {
applyPreset({ regular: true, chain: true, war: true, retaliation: true, overseas: true }, true);
});
$('p-chain').addEventListener('click', function () {
applyPreset({ regular: false, chain: true, war: false, retaliation: false, overseas: false }, true);
});
$('refresh').addEventListener('click', function () {
status('Refreshing…');
backoffUntil = 0;
refresh(true, function (err) { status(err || 'Done.', err ? 'frc-err' : 'frc-dim'); });
});
$('rebuild').addEventListener('click', function () {
var fid = cache.factionId;
cache = emptyCache();
cache.factionId = fid;
saveCache();
recompute();
render();
status('Rebuilding…');
backoffUntil = 0;
refresh(true, function (err) { status(err || 'Rebuilt.', err ? 'frc-err' : 'frc-dim'); });
});
}
/* -------------------------------- boot -------------------------------- */
var debounce = null;
function schedule() {
clearTimeout(debounce);
debounce = setTimeout(function () { render(); }, 300);
}
function start() {
injectStyles();
recompute();
render();
if (settings.apiKey) {
setTimeout(function () { refresh(false); }, 1200);
}
var mo = new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var t = muts[i].target;
if (t && t.closest && (t.closest('.frc-modal') || t.getAttribute && t.getAttribute('data-frc'))) continue;
schedule();
return;
}
});
mo.observe(document.body, { childList: true, subtree: true });
window.addEventListener('beforeunload', function () { mo.disconnect(); lockRelease(); });
window.addEventListener('hashchange', function () { setTimeout(render, 0); });
if (window.navigation && window.navigation.addEventListener) {
window.navigation.addEventListener('currententrychange', function () { setTimeout(render, 0); });
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
var stored = gmGet('cache', null);
if (stored && stored.fetchedAt > (cache.fetchedAt || 0)) { cache = stored; recompute(); render(); }
if (settings.apiKey) refresh(false);
}
});
setInterval(function () {
if (document.visibilityState === 'visible' && settings.apiKey) refresh(false);
}, 60000);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
})();