Torn Weekly Chain Respect

Adds an RST column to your faction members list: rolling 7-day respect per member, taken straight from Torn's own chain reports.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();
})();