Torn OC Role Restrictions

Highlight role restrictions and best roles in OC 2.0 (modified copy of "Torn OC Role Evaluator"). Well paired with https://greasyfork.org/en/scripts/526834-oc-success-chance-2-0.

// ==UserScript==
// @name         Torn OC Role Restrictions
// @namespace    https://xentac.github.io
// @version      0.3
// @description  Highlight role restrictions and best roles in OC 2.0 (modified copy of "Torn OC Role Evaluator"). Well paired with https://greasyfork.org/en/scripts/526834-oc-success-chance-2-0.
// @author       underko[3362751], xentac[3354782]
// @match        https://www.torn.com/factions.php*
// @grant        GM_xmlhttpRequest
// @connect      raw.githubusercontent.com
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  let ocRoleInfluence = {
    "Pet Project": [
      { role: "Kidnapper", influence: 41.14, lower: 70 },
      { role: "Muscle", influence: 26.83, lower: 70 },
      { role: "Picklock", influence: 32.03, lower: 70 },
    ],
    "Mob Mentality": [
      { role: "Looter #1", influence: 34.83, lower: 70 },
      { role: "Looter #2", influence: 25.97, lower: 70 },
      { role: "Looter #3", influence: 19.87, lower: 60 },
      { role: "Looter #4", influence: 19.33, lower: 67 },
    ],
    "Cash Me if You Can": [
      { role: "Thief #1", influence: 46.67, lower: 70 },
      { role: "Thief #2", influence: 21.87, lower: 65 },
      { role: "Lookout", influence: 31.46, lower: 70 },
    ],
    "Best of the Lot": [
      { role: "Picklock", influence: 23.65, lower: 70 },
      { role: "Car Thief", influence: 21.06, lower: 70 },
      { role: "Muscle", influence: 36.43, lower: 75 },
      { role: "Imitator", influence: 18.85, lower: 60 },
    ],
    "Market Forces": [
      { role: "Enforcer", influence: 27.56, lower: 70 },
      { role: "Negotiator", influence: 25.59, lower: 70 },
      { role: "Lookout", influence: 19.05, lower: 68 },
      { role: "Arsonist", influence: 4.12, lower: 40 },
      { role: "Muscle", influence: 23.68, lower: 70 },
    ],
    "Smoke and Wing Mirrors": [
      { role: "Car Thief", influence: 48.2, lower: 74 },
      { role: "Imitator", influence: 26.3, lower: 70 },
      { role: "Hustler #1", influence: 7.7, lower: 60 },
      { role: "Hustler #2", influence: 17.81, lower: 65 },
    ],
    "Gaslight the Way": [
      { role: "Imitator #1", influence: 7.54, lower: 70 },
      { role: "Imitator #2", influence: 34.85, lower: 72 },
      { role: "Imitator #3", influence: 40.25, lower: 72 },
      { role: "Looter #1", influence: 7.54, lower: 60 },
      { role: "Looter #2", influence: 0.0, lower: 40 },
      { role: "Looter #3", influence: 9.83, lower: 65 },
    ],
    "Stage Fright": [
      { role: "Enforcer", influence: 16.89, lower: 70 },
      { role: "Muscle #1", influence: 21.92, lower: 72 },
      { role: "Muscle #2", influence: 2.09, lower: 50 },
      { role: "Muscle #3", influence: 9.49, lower: 70 },
      { role: "Lookout", influence: 7.68, lower: 60 },
      { role: "Sniper", influence: 41.92, lower: 75 },
    ],
    "Snow Blind": [
      { role: "Hustler", influence: 51.4, lower: 74 },
      { role: "Imitator", influence: 30.44, lower: 70 },
      { role: "Muscle #1", influence: 9.08, lower: 70 },
      { role: "Muscle #2", influence: 9.08, lower: 50 },
    ],
    "Leave No Trace": [
      { role: "Techie", influence: 24.4, lower: 60 },
      { role: "Negotiator", influence: 29.07, lower: 70 },
      { role: "Imitator", influence: 46.54, lower: 73 },
    ],
    "No Reserve": [
      { role: "Car Thief", influence: 30.86, lower: 67 },
      { role: "Techie", influence: 37.88, lower: 75 },
      { role: "Engineer", influence: 31.27, lower: 67 },
    ],
    "Counter Offer": [
      { role: "Robber", influence: 33.29, lower: 62 },
      { role: "Looter", influence: 4.69, lower: 42 },
      { role: "Hacker", influence: 16.72, lower: 60 },
      { role: "Picklock", influence: 17.1, lower: 60 },
      { role: "Engineer", influence: 28.21, lower: 62 },
    ],
    "Honey Trap": [
      { role: "Enforcer", influence: 20.21, lower: 60 },
      { role: "Muscle #1", influence: 34.32, lower: 70 },
      { role: "Muscle #2", influence: 45.47, lower: 75 },
    ],
    "Bidding War": [
      { role: "Robber #1", influence: 6.82, lower: 60 },
      { role: "Driver", influence: 21.93, lower: 70 },
      { role: "Robber #2", influence: 19.63, lower: 75 },
      { role: "Robber #3", influence: 25.65, lower: 70 },
      { role: "Bomber #1", influence: 10.96, lower: 70 },
      { role: "Bomber #2", influence: 15.0, lower: 63 },
    ],
    "Blast from the Past": [
      { role: "Picklock #1", influence: 9.81, lower: 70 },
      { role: "Hacker", influence: 6.18, lower: 65 },
      { role: "Engineer", influence: 25.29, lower: 75 },
      { role: "Bomber", influence: 20.4, lower: 70 },
      { role: "Muscle", influence: 36.75, lower: 75 },
      { role: "Picklock #2", influence: 1.56, lower: 40 },
    ],
    "Break the Bank": [
      { role: "Robber", influence: 10.84, lower: 63 },
      { role: "Muscle #1", influence: 10.27, lower: 63 },
      { role: "Muscle #2", influence: 7.78, lower: 60 },
      { role: "Thief #1", influence: 3.55, lower: 60 },
      { role: "Muscle #3", influence: 33.54, lower: 72 },
      { role: "Thief #2", influence: 34.03, lower: 72 },
    ],
    "Stacking the Deck": [
      { role: "Cat Burglar", influence: 31.99, lower: 75 },
      { role: "Driver", influence: 3.86, lower: 68 },
      { role: "Hacker", influence: 25.64, lower: 63 },
      { role: "Imitator", influence: 38.52, lower: 70 },
    ],
    "Clinical Precision": [
      { role: "Imitator", influence: 41.51, lower: 75 },
      { role: "Cat Burglar", influence: 22.21, lower: 70 },
      { role: "Assassin", influence: 14.56, lower: 60 },
      { role: "Cleaner", influence: 21.71, lower: 70 },
    ],
    "Ace in the Hole": [
      { role: "Imitator", influence: 13.73, lower: 65 },
      { role: "Muscle #1", influence: 18.55, lower: 65 },
      { role: "Muscle #2", influence: 18.88, lower: 72 },
      { role: "Hacker", influence: 37.49, lower: 75 },
      { role: "Driver", influence: 11.35, lower: 60 },
    ],
  };

  let crimeData = {};
  let previousTab = "none";

  function classifyOcRoleInfluence(ocName, roleName) {
    const ocInfo = ocRoleInfluence[ocName];
    const roleData = ocInfo?.find((r) => r.role === roleName);
    const influence = roleData ? roleData.influence : 0;
    const lower = roleData ? roleData.lower : 70;
    let upper = lower + 10;

    const roleLowers = ocInfo
      .map((role) => {
        return role.lower;
      })
      .sort();

    // If our role is a low influence role, set the upper bound to the next highest lower bound
    if (roleLowers[0] == lower) {
      upper = roleLowers[1];
    }

    return { influence, lower, upper };
  }

  function getFactionId() {
    let factionId = "";
    try {
      document
        .querySelector(".forum-thread")
        .href.split("#")[1]
        .split("&")
        .forEach((elem) => {
          if (elem[0] == "a") {
            factionId = elem.split("=")[1];
          }
        });
    } catch (e) {
      console.log("[OCRoleRestrictions] Couldn't extract faction id:", e);
    }

    return factionId;
  }

  function updateFactionRoleRestrictions(factionId, cb) {
    try {
      GM_xmlhttpRequest({
        method: "GET",
        url: `https://raw.githubusercontent.com/xentac/oc_role_restrictions/refs/heads/main/${factionId}.json`,
        headers: {
          "Content-Type": "application/json",
        },
        onload: async function (response) {
          console.log(response);
          if (response.status != 200) {
            console.error(
              "[OCRoleRestrictions] Bad response fetching faction restrictions:",
              response.status,
            );
            return cb();
          }

          try {
            const result = JSON.parse(response.responseText);
            ocRoleInfluence = result;
          } catch (error) {
            console.error(
              "[OCRoleRestrictions] Failed to parse faction restrictions:",
              error.message,
            );
          }
          return cb();
        },
      });
    } catch (error) {
      console.error(
        "[OCRoleRestrictions] Failed fetching faction restrictions:",
        error.message,
      );
    }
  }

  function processCrime(wrapper) {
    const ocId = wrapper.getAttribute("data-oc-id");
    if (!ocId || crimeData[ocId]) return;

    const titleEl = wrapper.querySelector("p.panelTitle___aoGuV");
    if (!titleEl) return;

    const crimeTitle = titleEl.textContent.trim();
    const roles = [];

    const roleEls = wrapper.querySelectorAll(".title___UqFNy");
    roleEls.forEach((roleEl) => {
      const roleName = roleEl.textContent.trim();
      const successEl = roleEl.nextElementSibling;
      const chance = successEl
        ? parseInt(successEl.textContent.trim(), 10)
        : null;
      const evaluation =
        chance !== null
          ? classifyOcRoleInfluence(crimeTitle, roleName)
          : { influence: null, lower: 70, upper: 80 };
      roles.push({ role: roleName, chance, evaluation });

      if (successEl && evaluation.influence !== null) {
        successEl.textContent = `${chance}/${evaluation.lower}`;
      }

      const slotHeader = roleEl.closest("button.slotHeader___K2BS_");
      if (slotHeader) {
        if (chance >= evaluation.upper) {
          //slotHeader.style.backgroundColor = "#ca6f1e";
        } else if (chance >= evaluation.lower) {
          slotHeader.style.backgroundColor = "#239b56";
        } else {
          slotHeader.style.backgroundColor = "#a93226";
        }
      }
    });

    crimeData[ocId] = { id: ocId, title: crimeTitle, roles };
  }

  function setupMutationObserver(root) {
    const observer = new MutationObserver(() => {
      const tabTitle = document
        .querySelector("button.active___ImR61 span.tabName___DdwH3")
        ?.textContent.trim();

      if (tabTitle !== "Recruiting" && tabTitle !== "Planning") return;

      if (previousTab !== tabTitle) {
        crimeData = {};
        previousTab = tabTitle;
      }

      const allCrimes = document.querySelectorAll(".wrapper___U2Ap7");
      allCrimes.forEach((crimeNode) => {
        processCrime(crimeNode);
      });
    });

    observer.observe(root, { childList: true, subtree: true });
  }

  const factionId = getFactionId();
  const cb = () => {
    waitForKeyElements("#faction-crimes-root", (root) => {
      setupMutationObserver(root);
    });
  };
  if (factionId) {
    updateFactionRoleRestrictions(factionId, cb);
  } else {
    console.log(
      "[OCRoleRestrictions] Couldn't find faction id, going with defaults.",
    );
    cb();
  }

  // Inserting dependency because Torn PDA can't handle @require
  // ==UserScript==
  // @version 1.3.0
  // @name waitForKeyElements.js (CoeJoder fork)
  // @description A utility function for userscripts that detects and handles AJAXed content.
  // @namespace https://github.com/CoeJoder/waitForKeyElements.js
  // @author CoeJoder
  // @homepage https://github.com/CoeJoder/waitForKeyElements.js
  // @source https://raw.githubusercontent.com/CoeJoder/waitForKeyElements.js/master/waitForKeyElements.js
  //
  // ==/UserScript==

  /**
   * A utility function for userscripts that detects and handles AJAXed content.
   *
   * @example
   * waitForKeyElements("div.comments", (element) => {
   *   element.innerHTML = "This text inserted by waitForKeyElements().";
   * });
   *
   * waitForKeyElements(() => {
   *   const iframe = document.querySelector('iframe');
   *   if (iframe) {
   *     const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
   *     return iframeDoc.querySelectorAll("div.comments");
   *   }
   *   return null;
   * }, callbackFunc);
   *
   * @param {(string|function)} selectorOrFunction - The selector string or function.
   * @param {function}          callback           - The callback function; takes a single DOM element as parameter.
   *                                                 If returns true, element will be processed again on subsequent iterations.
   * @param {boolean}           [waitOnce=true]    - Whether to stop after the first elements are found.
   * @param {number}            [interval=300]     - The time (ms) to wait between iterations.
   * @param {number}            [maxIntervals=-1]  - The max number of intervals to run (negative number for unlimited).
   */
  function waitForKeyElements(
    selectorOrFunction,
    callback,
    waitOnce,
    interval,
    maxIntervals,
  ) {
    if (typeof waitOnce === "undefined") {
      waitOnce = true;
    }
    if (typeof interval === "undefined") {
      interval = 300;
    }
    if (typeof maxIntervals === "undefined") {
      maxIntervals = -1;
    }
    if (typeof waitForKeyElements.namespace === "undefined") {
      waitForKeyElements.namespace = Date.now().toString();
    }
    var targetNodes =
      typeof selectorOrFunction === "function"
        ? selectorOrFunction()
        : document.querySelectorAll(selectorOrFunction);

    var targetsFound = targetNodes && targetNodes.length > 0;
    if (targetsFound) {
      targetNodes.forEach(function (targetNode) {
        var attrAlreadyFound = `data-userscript-${waitForKeyElements.namespace}-alreadyFound`;
        var alreadyFound = targetNode.getAttribute(attrAlreadyFound) || false;
        if (!alreadyFound) {
          var cancelFound = callback(targetNode);
          if (cancelFound) {
            targetsFound = false;
          } else {
            targetNode.setAttribute(attrAlreadyFound, true);
          }
        }
      });
    }

    if (maxIntervals !== 0 && !(targetsFound && waitOnce)) {
      maxIntervals -= 1;
      setTimeout(function () {
        waitForKeyElements(
          selectorOrFunction,
          callback,
          waitOnce,
          interval,
          maxIntervals,
        );
      }, interval);
    }
  }
})();