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.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

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