wall-battlestats

show tornstats spies on faction wall page

// ==UserScript==
// @name        wall-battlestats
// @namespace   seintz.torn.wall-battlestats
// @version     6.9
// @description show tornstats spies on faction wall page
// @author      finally [2060206], seintz [2460991], Shade [3129695], Kindly [1956699]
// @license     GNU GPLv3
// @run-at      document-end
// @match       https://www.torn.com/factions.php*
// @match       https://www.torn.com/war.php?step=rankreport&rankID=*
// @grant       GM_xmlhttpRequest
// @grant       GM_addStyle
// @connect     tornstats.com
// ==/UserScript==

// Grab your Torn Stats API from https://tornstats.com/settings/general
// set it below like "TS_MYKEY" inside the quotation marks below

const apiKey = 'TS_MYKEY';
const colorMode = false;

/*
 * -------------------------------------------------------------------------
 * |    DO NOT MODIFY BELOW     |
 * -------------------------------------------------------------------------
 */

const OKAY = 'OKAY';
const ABROAD = 'ABROAD';
const HOSPITAL = 'HOSPITAL';
const TRAVELING = 'TRAVELING';

// total, str, def, spd, dex
const colors = ['black', '#ad7c5c', '#6ea9a9', '#807b54', '#ae67bb'];
const whore_threshold = 0.35;
const ff_threshold = 2;
const borderColor = 'lightgreen';
const borderThickness = 0;

const hospNodes = [];
const myStatsCacheSeconds = 21600;
const storeFiltFrom = localStorage.getItem('finally.torn.factionFilterFrom');
const storeFiltTo = localStorage.getItem('finally.torn.factionFilterTo');
const storeSort = localStorage.getItem('finally.torn.factionSort');

let bsCache = JSONparse(localStorage['finally.torn.bs']) || {};
let filterFrom = parseInt(storeFiltFrom) || undefined;
let filterTo = parseInt(storeFiltTo) || undefined;
let myBSScore = localStorage.getItem('myBSScore') || 0;
let previousSort = parseInt(storeSort) || 1;

let ff = 0;
let hospLoopCounter = 0;
let hospTime = {};
let loadTSFactionBacklog = [];
let loadTSFactionDone = [];
let loadTSFactionLock = false;
let factionData = {};
let factions = [];

const statusOrder = {
  [OKAY]: 1,
  [HOSPITAL]: 2,
  [TRAVELING]: 3,
  [ABROAD]: 4,
};

const countries = {
  'United Kingdom': 'UK',
  'South Africa': 'SA',
  Switzerland: 'SW',
  Japan: 'JP',
  'Cayman Islands': 'CI',
  Mexico: 'MX',
  Canada: 'CN',
  Argentina: 'AR',
  China: 'CH',
};

function JSONparse(str) {
  try {
    return JSON.parse(str);
  } catch (e) {
    console.log(e);
  }
  return null;
}

function isPDA() {
  return window.flutter_inappwebview !== undefined;
}

function onSpiesLoad(r) {
  const j = JSONparse(r.responseText);
  if (!j || !j.status || !j.faction) {
    loadTSFactionsDone();
    return;
  }

  Object.keys(j.faction.members).forEach((k) => addSpy(k, j.faction.members[k].spy));
  localStorage['finally.torn.bs'] = JSON.stringify(bsCache);
  loadTSFactionsDone();
}

function webReq(URL, callback) {
  // console.log(JSON.parse(data));

  if (isPDA()) {
    PDA_httpGet(URL)
      .then((r) => callback(r))
      .catch(console.log);
  } else {
    GM_xmlhttpRequest({
      method: 'GET',
      url: URL,
      onload: (r) => callback(r),
      onabort: () => loadTSFactionsDone(),
      onerror: () => loadTSFactionsDone(),
      ontimeout: () => loadTSFactionsDone(),
    });
  }
}

function loadTSFactionsDone() {
  loadTSFactionLock = false;
  loadTSFactions();
}

function loadTSFactions(id) {
  if (loadTSFactionLock) {
    if (id && loadTSFactionDone.indexOf(id) === -1 && loadTSFactionBacklog.indexOf(id) === -1)
      loadTSFactionBacklog.push(id);
    return;
  }

  if (!id && loadTSFactionBacklog.length == 0) {
    showStatsAll();
    return;
  }

  loadTSFactionLock = true;
  id = id || loadTSFactionBacklog.shift();
  loadTSFactionDone.push(id);
  const URL = `https://www.tornstats.com/api/v2/${apiKey}/spy/faction/${id}`;
  webReq(URL, onSpiesLoad);
}

function runFactionData(r) {
  const j = JSONparse(r.responseText);

  const { members } = j;
  console.log(j);

  for (const [memId, { status }] of Object.entries(members)) {
    const { state, description: desc } = status;

    if (state === 'Traveling' || state === 'Abroad') {
      let country = '';

      if (desc.includes('Returning')) country = '<-' + (countries[desc.split('from ')[1]] || desc.split('from ')[1]);
      else if (desc.includes('Traveling to'))
        country = '->' + (countries[desc.split('to ')[1]] || desc.split('to ')[1]);
      else country = countries[desc.split('In ')[1]] || desc.split('In ')[1];

      factionData[memId] = country;
    } else {
      delete factionData[memId];
    }
  }

  updateStateTravellers();
}

async function getFactionData() {
  for (const factionId of factions) {
    console.log(factionId);
    const URL = `https://api.torn.com/faction/${factionId}?selections=&key=${apiKey}&comment=wallbs`;
    webReq(URL, runFactionData);
  }
}

function updateStateTravellers() {
  const update = (selector) => {
    document.querySelectorAll(`.${selector}`).forEach((el) => {
      const parent = el.parentElement;
      const link = parent.querySelector("a[href*='XID=']");
      const userId = link?.href.split('XID=')[1];
      if (userId && factionData[userId]) {
        el.textContent = factionData[userId];
      }
    });
  };

  update('traveling');
  update('abroad');
}

function onLoadCacheMyBS(r) {
  const j = JSONparse(r.responseText);
  if (!j || !j.status || !j.data) return;
  const currentStats = j.data[j.data.length - 1];
  const myScore =
    Math.sqrt(currentStats.strength) +
    Math.sqrt(currentStats.defense) +
    Math.sqrt(currentStats.speed) +
    Math.sqrt(currentStats.dexterity);
  localStorage.setItem('myBSScore', myScore);
}

function cacheMyBattleStats() {
  const myLink = document?.querySelector('.settings-menu li:first-child a')?.getAttribute('href');
  if (!myLink) return;
  const myUserID = myLink.replace(/^\D+/g, '');
  const currentTimestamp = Math.floor(Date.now() / 1000);
  const lastUpdate = localStorage.getItem('lastMyBSUpdateTime');
  const cachedUntil = +lastUpdate + +myStatsCacheSeconds;

  if (myBSScore && lastUpdate && cachedUntil > currentTimestamp) return;

  localStorage.setItem('lastMyBSUpdateTime', currentTimestamp);
  const URL = `https://www.tornstats.com/api/v2/${apiKey}/spy/user/${myUserID}`;
  webReq(URL, onLoadCacheMyBS);
}

function loadFactions() {
  let factionIds = Array.from(document.querySelectorAll("[href^='/factions.php?step=profile&ID=']"))
    .map((a) => a.href.replace(/.*?ID=(\d+)$/, '$1'))
    .filter((v, i, a) => a.indexOf(v) === i);
  factions = factionIds;
  factionIds.forEach((id) => loadTSFactions(id));
}

function getStatus(node) {
  var c = node.className;
  if (c.includes('okay')) {
    return OKAY;
  } else if (c.includes('hospital')) {
    return HOSPITAL;
  } else if (c.includes('traveling')) {
    return TRAVELING;
  } else if (c.includes('abroad')) {
    return ABROAD;
  } else {
    return ABROAD;
  }
}

function sortStatus(node, sort) {
  if (!node) node = document.querySelector('.f-war-list .members-list');
  if (!node) return;

  let sortIcon = node.parentNode.querySelector(".status > [class*='sortIcon']");

  if (sort) node.finallySort = sort;
  else if (node.finallySort == undefined) node.finallySort = 2;
  else if (++node.finallySort > 2) node.finallySort = sortIcon ? 1 : 0;

  if (sortIcon) {
    if (node.finallySort > 0) {
      let active = node.parentNode.querySelector("[class*='activeIcon']:not([class*='finally-status-activeIcon'])");
      if (active) {
        let activeClass = active?.className?.match(/(?:\s|^)(activeIcon(?:[^\s|$]+))(?:\s|$)/)?.[1];
        if (activeClass) active.classList.remove(activeClass);
      }

      sortIcon.classList.add('finally-status-activeIcon');
      if (node.finallySort == 1) {
        sortIcon.classList.remove('finally-status-desc');
        sortIcon.classList.add('finally-status-asc');
      } else {
        sortIcon.classList.remove('finally-status-asc');
        sortIcon.classList.add('finally-status-desc');
      }
    } else {
      sortIcon.classList.remove('finally-status-activeIcon');
    }
  }

  // default sort order is
  // ok
  // travelling
  // hospital
  // abroad

  let nodes = Array.from(node.querySelectorAll('.your:not(.row-animation-new), .enemy:not(.row-animation-new)'));
  for (let i = 0; i < nodes.length; i++) if (nodes[i].finallyPos == undefined) nodes[i].finallyPos = i;

  nodes = nodes.sort((a, b) => {
    let idA = a.querySelector('a[href*="XID"]').href.replace(/.*?XID=(\d+)/i, '$1');
    let statusA = getStatus(a.querySelector('.status'));

    let posA = a.finallyPos;
    let idB = b.querySelector('a[href*="XID"]').href.replace(/.*?XID=(\d+)/i, '$1');
    let statusB = getStatus(b.querySelector('.status'));
    let posB = b.finallyPos;

    let type = node.finallySort;
    switch (node.finallySort) {
      case 1:
        if (statusA !== HOSPITAL || statusB !== HOSPITAL) return statusOrder[statusA] - statusOrder[statusB];
        return hospTime[idA] - new Date().getTime() / 1000 - (hospTime[idB] - new Date().getTime() / 1000);
      case 2:
        if (statusA !== HOSPITAL || statusB !== HOSPITAL) return statusOrder[statusB] - statusOrder[statusA];
        return hospTime[idB] - new Date().getTime() / 1000 - (hospTime[idA] - new Date().getTime() / 1000);
      default:
        return posA > posB ? 1 : -1;
    }
  });

  for (let i = 0; i < nodes.length; i++) nodes[i].parentNode.appendChild(nodes[i]);

  if (!sort) {
    document.querySelectorAll('.members-list').forEach((e) => {
      if (node != e) sortStatus(e, node.finallySort);
    });
  }
}

function sortStats(node, sort) {
  if (!node) node = document.querySelector('.f-war-list .members-list');
  if (!node) return;

  let sortIcon = node.parentNode.querySelector(".bs > [class*='sortIcon']");

  if (sort) node.finallySort = sort;
  else if (node.finallySort == undefined) node.finallySort = 2;
  else if (++node.finallySort > 2) node.finallySort = sortIcon ? 1 : 0;

  if (sortIcon) {
    if (node.finallySort > 0) {
      let active = node.parentNode.querySelector("[class*='activeIcon']:not([class*='finally-bs-activeIcon'])");
      if (active) {
        let activeClass = active.className.match(/(?:\s|^)(activeIcon(?:[^\s|$]+))(?:\s|$)/)[1];
        active.classList.remove(activeClass);
      }

      sortIcon.classList.add('finally-bs-activeIcon');
      if (node.finallySort == 1) {
        sortIcon.classList.remove('finally-bs-desc');
        sortIcon.classList.add('finally-bs-asc');
      } else {
        sortIcon.classList.remove('finally-bs-asc');
        sortIcon.classList.add('finally-bs-desc');
      }
    } else {
      sortIcon.classList.remove('finally-bs-activeIcon');
    }
  }

  let nodes = Array.from(
    node.querySelectorAll('.table-body > .table-row, .your:not(.row-animation-new), .enemy:not(.row-animation-new)'),
  );
  for (let i = 0; i < nodes.length; i++) if (nodes[i].finallyPos == undefined) nodes[i].finallyPos = i;

  nodes = nodes.sort((a, b) => {
    let posA = a.finallyPos;
    let idA = a.querySelector('a[href*="XID"]').href.replace(/.*?XID=(\d+)/i, '$1');
    let totalA = (bsCache[idA] && typeof bsCache[idA].total == 'number' && bsCache[idA].total) || posA;
    let posB = b.finallyPos;
    let idB = b.querySelector('a[href*="XID"]').href.replace(/.*?XID=(\d+)/i, '$1');
    let totalB = (bsCache[idB] && typeof bsCache[idB].total == 'number' && bsCache[idB].total) || posB;

    let type = node.finallySort;
    switch (node.finallySort) {
      case 1:
        if (totalA <= 100 && totalB <= 100) return totalB > totalA ? 1 : -1;
        return totalA > totalB ? 1 : -1;
      case 2:
        return totalB > totalA ? 1 : -1;
      default:
        return posA > posB ? 1 : -1;
    }
  });

  for (let i = 0; i < nodes.length; i++) nodes[i].parentNode.appendChild(nodes[i]);

  if (!sort) {
    document.querySelectorAll('.members-list').forEach((e) => {
      if (node != e) sortStats(e, node.finallySort);
    });
  }
}

function addSpy(id, spy) {
  if (!spy) return;
  bsCache[id] = spy;
}

function updateStats(id, node, parentNode) {
  if (!node) return;

  let stats = Array(5).fill('N/A');
  let time = '';
  let ff = '-';
  let changeColor = false;
  let highestIndex = 1;

  const data = bsCache[id];
  if (data) {
    const { total, strength, defense, speed, dexterity, timestamp } = data;

    // Filtering logic
    const lowFilter = filterFrom && total <= filterFrom;
    const highFilter = filterTo && total >= filterTo;
    const shouldHide = lowFilter || highFilter;
    if (shouldHide) parentNode.style.display = 'none';
    else parentNode.style.display = '';

    stats = [total, strength, defense, speed, dexterity];

    highestIndex = stats.slice(1).reduce((maxIdx, val, idx, arr) => (+val > +arr[maxIdx] ? idx : maxIdx), 0) + 1;

    const totalNum = +stats[0];
    const highestStat = +stats[highestIndex];
    changeColor = highestStat >= whore_threshold * totalNum;

    // FF calculation
    const enemyScore = [1, 2, 3, 4].map((i) => Math.sqrt(+stats[i] || 0)).reduce((sum, val) => sum + val, 0);

    const myScore = +localStorage.getItem('myBSScore');
    if (myScore > 0 && enemyScore > 0) {
      ff = Math.min(3, Math.round((1 + (8 / 3) * (enemyScore / myScore)) * 100) / 100);
    }

    // Time formatting
    const secondsAgo = Date.now() / 1000 - timestamp;
    if (secondsAgo < 0) {
      delete bsCache[id];
      localStorage['finally.torn.bs'] = JSON.stringify(bsCache);
      return;
    }

    const timeLabels = [
      [365 * 24 * 60 * 60, 'years ago'],
      [30 * 24 * 60 * 60, 'months ago'],
      [24 * 60 * 60, 'days ago'],
      [60 * 60, 'hours ago'],
      [60, 'minutes ago'],
    ];
    for (const [sec, label] of timeLabels) {
      if (secondsAgo > sec) {
        time = `${Math.floor(secondsAgo / sec)} ${label}`;
        break;
      }
    }
    if (!time) time = `${Math.floor(secondsAgo)} seconds ago`;
  }

  // Format stats with suffixes
  const units = ['K', 'M', 'B', 'T', 'Q'];
  stats = stats.map((val, i) => {
    let num = +val;
    if (isNaN(num) || num === 0) return 'N/A';
    for (const unit of units) {
      num /= 1000;
      if (num < 1000) {
        const fixed = i === 0 ? (num >= 100 ? 0 : 1) : 2;
        return `${num.toFixed(fixed)}${unit}`;
      }
    }
    return val;
  });

  // Apply color formatting
  const statsWithColors = stats.map((val, i) => {
    if (!colorMode) return `<span>${val}</span>`;
    if (changeColor && i === 0) return `<span style="color: ${colors[highestIndex]};">${val}</span>`;
    if (changeColor && i === highestIndex) return `<span style="color: ${colors[i]};font-weight:bold">${val}</span>`;
    return `<span>${val}</span>`;
  });

  // Set node style and content
  node.style.border = ff >= ff_threshold ? `${borderThickness}px solid ${borderColor}` : '';
  node.innerHTML = statsWithColors[0];
  node.title = `
      <div class="finally-bs-stat">
        <b>FF</b> <span class="finally-bs-stat">${ff}</span><br/>
        <b>STR</b> ${statsWithColors[1]}<br/>
        <b>DEF</b> ${statsWithColors[2]}<br/>
        <b>SPD</b> ${statsWithColors[3]}<br/>
        <b>DEX</b> ${statsWithColors[4]}<br/>
        ${time}
      </div>`;
}

function updateHospTimers() {
  for (let i = 0, n = hospNodes.length; i < n; i++) {
    const hospNode = hospNodes[i];
    const id = hospNode[0];
    const node = hospNode[1];
    if (!node) continue;
    if (!hospTime[id]) continue;

    let totalSeconds = hospTime[id] - new Date().getTime() / 1000;
    if (!totalSeconds || totalSeconds <= 0) continue;
    else if (totalSeconds >= 10 * 60 && hospLoopCounter % 10 != 0) continue;
    else if (totalSeconds < 10 * 60 && totalSeconds >= 5 * 60 && hospLoopCounter % 5 != 0) continue;

    let hours = Math.floor(totalSeconds / 3600);
    totalSeconds %= 3600;
    let minutes = Math.floor(totalSeconds / 60);
    let seconds = Math.floor(totalSeconds % 60);

    node.textContent = `${hours.toString().padLeft(2, '0')}:${minutes
      .toString()
      .padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}`;
  }
  if (hospNodes.length > 0) hospLoopCounter++;
  setTimeout(updateHospTimers, 1000);
}

function updateStatus(id, node) {
  if (!node) return;
  if (hospNodes.find((h) => h[0] == id)) return;
  hospNodes.push([id, node]);
}

function onClickCopyStatus(event) {
  const name = event.target.parentNode.querySelector('.honor-text:not([class*=" "])')?.textContent;
  const status = event.target.textContent;
  const bs = event.target.parentNode.querySelector('.bs').textContent;
  const id = event.target.parentNode.querySelector('a[href*="XID"]').href.replace(/.*?XID=(\d+)/i, '$1');

  const text = `${name} ${bs} ${status} https://torn.com/loader.php?sid=attack&user2ID=${id}`;

  navigator.clipboard.writeText(text);
}

function showStats(node) {
  if (!node) return;

  let id = node.querySelector('a[href*="XID"]').href.replace(/.*?XID=(\d+)/i, '$1');
  let bsNode = node.querySelector('.bs') || document.createElement('div');
  let statusNode = node.querySelector('.status');

  statusNode.addEventListener('click', (event) => {
    onClickCopyStatus(event);
  });

  updateStats(id, bsNode, node);
  updateStatus(id, statusNode);

  if (bsNode.classList.contains('bs')) {
    return;
  }

  bsNode.className = 'table-cell bs level lvl left iconShow finally-bs-col';
  let iconsNode = node.querySelector('.user-icons, .member-icons, .points');
  iconsNode.parentNode.insertBefore(bsNode, iconsNode);
  let isMobile = false;
  bsNode.addEventListener('touchstart', () => (isMobile = true));
  bsNode.addEventListener('click', () => {
    if (isMobile) return;
    window.open(`loader.php?sid=attack&user2ID=${id}`, '_newtab');
  });
  bsNode.addEventListener('dblclick', () => {
    window.open(`loader.php?sid=attack&user2ID=${id}`, '_newtab');
  });
}

function showStatsAll(node) {
  if (!node) node = Array.from(document.querySelectorAll('.f-war-list .members-list, .members-list'));
  if (!node) return;

  if (!(node instanceof Array)) {
    node = [node];
  }

  node.forEach((n) =>
    n
      .querySelectorAll('.your:not(.row-animation-new), .enemy:not(.row-animation-new), .table-body > .table-row')
      .forEach((e) => showStats(e)),
  );
}

function watchWall(observeNode) {
  if (!observeNode) return;

  loadFactions();

  let parentNode = observeNode.parentNode.parentNode.parentNode;
  let factionNames = parentNode.querySelector('.faction-names');
  if (factionNames && !factionNames.querySelector('.finally-bs-swap')) {
    let swapNode = document.createElement('div');
    swapNode.className = 'finally-bs-swap';
    swapNode.innerHTML = '&lt;&gt;';
    factionNames.appendChild(swapNode);
    swapNode.addEventListener('click', () => {
      parentNode.querySelectorAll('.name.left, .name.right, .tab-menu-cont.right, .tab-menu-cont.left').forEach((e) => {
        if (e.classList.contains('left')) {
          e.classList.remove('left');
          e.classList.add('right');
        } else {
          e.classList.remove('right');
          e.classList.add('left');
        }
      });
    });

    let filterNode = document.createElement('div');
    filterNode.className = 'finally-bs-filter input-money-group no-max-value';
    let filterFromInput = document.createElement('input');
    filterFromInput.className = 'input-money';
    filterFromInput.placeholder = 'Filter BS from';
    filterFromInput.value = localStorage.getItem('finally.torn.factionFilterFrom') || '';
    let filterToInput = document.createElement('input');
    filterToInput.className = 'input-money';
    filterToInput.placeholder = 'Filter BS to';
    filterToInput.value = localStorage.getItem('finally.torn.factionFilterTo') || '';
    filterNode.appendChild(filterFromInput);
    filterNode.appendChild(filterToInput);
    factionNames.appendChild(filterNode);

    function filterFromTo() {
      function formatInput(input) {
        let value = input.value.toLowerCase();
        let valueNum = value.replace(/[^\d]/g, '');

        let multiplier = 1;
        if (value.indexOf('k') !== -1) multiplier = 1000;
        else if (value.indexOf('m') !== -1) multiplier = 1000000;
        else if (value.indexOf('b') !== -1) multiplier = 1000000000;
        else if (value.indexOf('t') !== -1) multiplier = 1000000000000;

        valueNum *= multiplier;
        input.value = valueNum > 0 ? valueNum.toLocaleString('en-US') : '';

        return valueNum;
      }

      filterFrom = formatInput(filterFromInput);
      filterTo = formatInput(filterToInput);
      localStorage.setItem('finally.torn.factionFilterFrom', filterFrom || '');
      localStorage.setItem('finally.torn.factionFilterTo', filterTo || '');

      showStatsAll();
    }
    filterFromTo();

    filterFromInput.addEventListener('keyup', filterFromTo);
    filterToInput.addEventListener('keyup', filterFromTo);
  }

  let titleNode = observeNode.parentNode.querySelector('.title, .c-pointer');
  let lvNode = titleNode.querySelector('.level');
  lvNode.childNodes[0].nodeValue = 'Lv';

  // sort by status
  let oldStatusNode = titleNode.querySelector('.status');
  if (oldStatusNode) {
    // cloning node to remove existing click event handler
    let statusNode = oldStatusNode.cloneNode(true);
    // finding default order class and removing it
    let orderClass = statusNode.childNodes[1].className.match(/(?:\s|^)((?:asc|desc)(?:[^\s|$]+))(?:\s|$)/)[1];
    statusNode.childNodes[1].classList.remove(orderClass);
    // replacing old node with cloned node and adding click handler
    oldStatusNode.replaceWith(statusNode);
    statusNode.addEventListener('click', () => {
      sortStatus(observeNode);
    });
  }

  if (!titleNode.querySelector('.bs')) {
    let bsNode = lvNode.cloneNode(true);
    bsNode.classList.add('bs');
    bsNode.childNodes[0].nodeValue = 'BS';
    titleNode.insertBefore(bsNode, titleNode.querySelector('.user-icons, .points'));
    if (bsNode.childNodes.length > 1) {
      let orderClass = bsNode.childNodes[1].className.match(/(?:\s|^)((?:asc|desc)(?:[^\s|$]+))(?:\s|$)/)[1];
      bsNode.childNodes[1].classList.remove(orderClass);
      for (let i = 0; i < titleNode.children.length; i++) {
        titleNode.children[i].addEventListener('click', (e) => {
          setTimeout(() => {
            let sort = i + 1;
            let sortIcon = e.target.querySelector("[class*='sortIcon']");
            let desc = sortIcon ? sortIcon.className.indexOf('desc') === -1 : false;
            sort = desc ? sort : -sort;
            localStorage.setItem('finally.torn.factionSort', sort);

            if (!e.target.classList.contains('status'))
              document
                .querySelectorAll("[class*='finally-status-activeIcon']")
                .forEach((e) => e.classList.remove('finally-status-activeIcon'));

            if (!e.target.classList.contains('bs'))
              document
                .querySelectorAll("[class*='finally-bs-activeIcon']")
                .forEach((e) => e.classList.remove('finally-bs-activeIcon'));
            //if (Math.abs(sort) != 3) document.querySelectorAll("[class*='finally-bs-activeIcon']").forEach((e) => e.classList.remove("finally-bs-activeIcon"));
          }, 100);
        });
      }
      bsNode.addEventListener('click', () => {
        sortStats(observeNode);
      });

      let title = titleNode.children[Math.abs(previousSort) - 1];
      let sortIcon = title.querySelector("[class*='sortIcon']");
      let desc = sortIcon ? sortIcon.className.indexOf('desc') !== -1 : false;
      let active = sortIcon ? sortIcon.className.indexOf('activeIcon') !== -1 : false;

      let x = 0;
      if (title.classList.contains('bs') && observeNode.querySelector('.enemy'))
        x = 0; //funny edge case, dont ask :)
      //if (Math.abs(previousSort) == 3 && observeNode.querySelector(".enemy")) x = 0; //funny edge case, dont ask :)
      else if (!active && previousSort < 0) x = 1;
      else if (!active) x = 2;
      else if (previousSort < 0 && !desc) x = 1;
      else if (previousSort > 0 && desc) x = 1;

      for (; x > 0; x--) {
        title.click();
      }
    }
  }

  showStatsAll(observeNode);

  let prevSortCheck = '';
  const mo = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      for (const node of mutation.addedNodes) {
        if (node.classList && (node.classList.contains('your') || node.classList.contains('enemy'))) {
          showStats(node);
        }
      }
    });

    let sort = Array.from(observeNode.querySelectorAll('a[href*="XID"]'))
      .map((a) => a.href)
      .join(',');
    if (prevSortCheck != sort && observeNode.parentNode.querySelector('.finally-bs-activeIcon')) {
      mo.disconnect();
      sortStats(observeNode, observeNode.finallySort);
      prevSortCheck = Array.from(observeNode.querySelectorAll('a[href*="XID"]'))
        .map((a) => a.href)
        .join(',');
      mo.takeRecords();
      mo.observe(observeNode, { childList: true, subtree: true });
    }
  });

  mo.observe(observeNode, { childList: true, subtree: true });
}

function watchWalls(observeNode) {
  if (!observeNode) return;

  observeNode.querySelectorAll('.members-list').forEach((e) => watchWall(e));

  new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      for (const node of mutation.addedNodes) {
        node.querySelector && node.querySelectorAll('.members-list').forEach((w) => watchWall(w));
      }
    });
  }).observe(observeNode, { childList: true, subtree: true });
}

function memberList(observeNode) {
  if (!observeNode) return;

  loadFactions();

  let titleNode = observeNode.querySelector('.table-header');

  if (!titleNode || titleNode.querySelector('.bs')) return;

  let bsNode = document.createElement('li');
  bsNode.className = 'table-cell bs torn-divider divider-vertical';
  bsNode.innerHTML = 'BS';
  titleNode.insertBefore(bsNode, titleNode.querySelector('.member-icons'));
  for (let i = 0; i < titleNode.children.length; i++) {
    titleNode.children[i].addEventListener('click', (e) => {
      let sort = i + 1;
      sort = e.target.querySelector("[class*='asc']") ? -sort : sort;
      localStorage.setItem('finally.torn.factionSort', sort);
    });
  }
  bsNode.addEventListener('click', () => {
    sortStats(observeNode);
  });

  if (previousSort >= 0) {
    titleNode.children[previousSort - 1].click();
    titleNode.children[previousSort - 1].click();
  } else if (previousSort < 0) titleNode.children[-previousSort - 1].click();

  observeNode.querySelectorAll('.table-body > .table-row').forEach((e) => showStats(e));
}

cacheMyBattleStats();
updateHospTimers();
memberList(document.querySelector('.members-list'));
watchWalls(document.querySelector('.f-war-list'));

new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    for (const node of mutation.addedNodes) {
      memberList(node.querySelector && node.querySelector('.members-list'));
      watchWalls(node.querySelector && node.querySelector('.f-war-list'));
    }
  });
}).observe(document.body, { childList: true, subtree: true });

const targetWindow = isPDA() ? window : unsafeWindow;
const oldFetch = targetWindow.fetch;
targetWindow.fetch = async (...args) => {
  const url = args[0]?.url || args[0];
  const notWarPage = !url.includes('step=getwarusers') && !url.includes('step=getProcessBarRefreshData');
  if (notWarPage) return oldFetch(...args);

  const response = await oldFetch(...args);
  const clone = response.clone();

  clone.json().then((json) => {
    let members = null;
    if (json.warDesc) members = json.warDesc.members;
    else if (json.userStatuses) members = json.userStatuses;
    else return;

    Object.keys(members).forEach((id) => {
      const status = members[id].status || members[id];
      id = members[id].userID || id;
      if (status.text === 'Hospital') hospTime[id] = status.updateAt;
      else delete hospTime[id];
    });

    showStatsAll();
  });

  return response;
};

const targetWindowSoc = isPDA() ? window : unsafeWindow;
const oldWebSocket = targetWindowSoc.WebSocket;
targetWindowSoc.WebSocket = function (...args) {
  const socket = new oldWebSocket(...args);
  socket.addEventListener('message', (event) => {
    const json = JSONparse(event.data);
    const statusUpdate = json?.result?.data?.data?.message?.namespaces?.users?.actions?.updateStatus;

    if (!statusUpdate?.status) return;
    const id = statusUpdate.userId;
    const status = statusUpdate.status;

    if (status.text === 'Hospital') hospTime[id] = status.updateAt;
    else delete hospTime[id];
    showStatsAll();
  });
  return socket;
};

// setTimeout(getFactionData, 2000);

const addStyle = (style) => {
  if (isPDA()) {
    const elem = document.createElement('style');
    elem.innerText = style;
    document.head.appendChild(elem);
    return;
  }
  GM_addStyle(style);
};

addStyle(`
    @media screen and (max-width: 1000px) {
        .members-cont .bs {
            display: none;
        }
    }

    .members-cont .level {
        width: 27px !important;
    }

    .members-cont .id {
        padding-left: 5px !important;
        width: 28px !important;
    }

    .members-cont .points {
        width: 42px !important;
    }

    .finally-bs-stat {
        font-family: monospace;
    }

    .finally-bs-stat > span {
        display: inline-block;
        width: 55px;
        text-align: right;
    }

    .faction-names {
        position: relative;
    }

    .finally-status-filter {
        position: absolute !important;
        top: 25px !important;
        left: 0;
        right: 0;
        margin-left: auto;
        margin-right: auto;
        width: 120px;
        cursor: pointer;
    }
    .finally-status-filter > input {
        display: block !important;
        width: 100px;
    }

    .finally-status-swap {
        position: absolute;
        top: 0px;
        left: 0;
        right: 0;
        margin-left: auto;
        margin-right: auto;
        width: 100px;
        cursor: pointer;
    }

    .finally-status-activeIcon {
        display: block !important;
    }

    .finally-status-asc {
        border-bottom: 6px solid var(--sort-arrow-color);
        border-left: 6px solid transparent;
        border-right: 6px solid transparent;
        border-top: 0 solid transparent;
        height: 0;
        top: -8px;
        width: 0;
    }

    .finally-status-desc {
        border-bottom: 0 solid transparent;
        border-left: 6px solid transparent;
        border-right: 6px solid transparent;
        border-top: 6px solid var(--sort-arrow-border-color);
        height: 0;
        top: -1px;
        width: 0;
    }

    .finally-status-col {
        text-overflow: clip !important;
    }


    .finally-bs-filter {
        position: absolute !important;
        top: 25px !important;
        left: 0;
        right: 0;
        margin-left: auto;
        margin-right: auto;
        width: 120px;
        cursor: pointer;
    }
    .finally-bs-filter > input {
        display: block !important;
        width: 100px;
    }

    .finally-bs-swap {
        position: absolute;
        top: 0px;
        left: 0;
        right: 0;
        margin-left: auto;
        margin-right: auto;
        width: 100px;
        cursor: pointer;
    }

    .finally-bs-activeIcon {
        display: block !important;
    }

    .finally-bs-asc {
        border-bottom: 6px solid var(--sort-arrow-color);
        border-left: 6px solid transparent;
        border-right: 6px solid transparent;
        border-top: 0 solid transparent;
        height: 0;
        top: -8px;
        width: 0;
    }

    .finally-bs-desc {
        border-bottom: 0 solid transparent;
        border-left: 6px solid transparent;
        border-right: 6px solid transparent;
        border-top: 6px solid var(--sort-arrow-border-color);
        height: 0;
        top: -1px;
        width: 0;
    }

    .finally-bs-col {
        text-overflow: clip !important;
    }

    .raid-members-list .level:not(.bs) {
        width: 16px !important;
    }

    .raid-members-list .level:not(.status) {
        width: 16px !important;
    }

    div.desc-wrap:not([class*='warDesc']) .finally-bs-swap {
    display: none;
    }

    div.desc-wrap:not([class*='warDesc']) .finally-status-swap {
    display: none;
    }

    div.desc-wrap:not([class*='warDesc']) .faction-names {
    padding-top: 100px !important;
    }

    .re_spy_title, .re_spy_col {
    display: none !important;
    }
`);