OC2.0 Helper

Adds a weight box under each role in Organized Crimes, adds an indicator for whether you meet the Faction's CPR requirements to join that role, and adds the current success chance of the OC. Credit to JohnNash for creating the original script to which this feature was added, and Seberus work I have built upon.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         OC2.0 Helper
// @namespace    https://torn.com/
// @version      1.02
// @description  Adds a weight box under each role in Organized Crimes, adds an indicator for whether you meet the Faction's CPR requirements to join that role, and adds the current success chance of the OC. Credit to JohnNash for creating the original script to which this feature was added, and Seberus work I have built upon.
// @match        https://www.torn.com/factions.php*
// @match        https://www.torn.com/organizedcrimes.php*
// @run-at       document-idle
// @grant        GM.xmlHttpRequest
// @connect      tornprobability.com
// @license      GNU GPLv3
// ==/UserScript==
(function () {
  "use strict";

  const API_URL = "https://tornprobability.com:3000/api/GetRoleWeights";
  const ROLE_NAMES_API_URL = "https://tornprobability.com:3000/api/GetRoleNames";
  const SUCCESS_API_URL = "https://tornprobability.com:3000/api/CalculateSuccess";
  const STYLE_ID = "oc-weights-style";
  let weightData = {};
  let roleNameData = {};
  let successData = {};
  let pendingSuccessRequests = new Set();
  let scanTimer = null;

  const q = (s, r = document) => r.querySelector(s);
  const qa = (s, r = document) => Array.from(r.querySelectorAll(s));

  function normalize(str) {
    return (str || "").toLowerCase().replace(/[^a-z0-9]/g, "");
  }

  function gmJsonRequest({ method, url, data }) {
    return new Promise((resolve, reject) => {
      GM.xmlHttpRequest({
        method,
        url,
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
        },
        data: data == null ? undefined : JSON.stringify(data),
        onload: (response) => {
          if (response.status < 200 || response.status >= 300) {
            reject(
              new Error(
                `Request failed with status ${response.status}: ${response.responseText}`
              )
            );
            return;
          }

          try {
            resolve(JSON.parse(response.responseText));
          } catch (err) {
            reject(err);
          }
        },
        onerror: reject,
      });
    });
  }

  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;

    const css = `
      .oc-weight-box {
        margin-top: 6px;
        padding: 6px;
        text-align: center;
        border: 1px solid rgba(255,255,255,0.15);
        border-radius: 6px;
        background: rgba(255,255,255,0.03);
      }

      .oc-weight-box .label {
        display: block;
        font-size: 11px;
        text-transform: uppercase;
        letter-spacing: .05em;
        opacity: .8;
        padding-bottom: 3px;
        margin-bottom: 4px;
        border-bottom: 1px solid rgba(255,255,255,0.2);
      }

      .oc-weight-box .value {
        display: block;
        font-size: 16px;
        font-weight: 700;
        margin-top: 2px;
      }

      [class*="joinButtonContainer"] {
        display: flex;
        align-items: center;
        gap: 6px;
      }

      .oc-cpr-indicator {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-width: 18px;
        height: 18px;
        font-weight: 700;
        font-size: 14px;
        line-height: 1;
      }

      .oc-cpr-indicator.pass {
        color: #65d46e;
      }

      .oc-cpr-indicator.fail {
        color: #e05a5a;
      }

      .oc-success-box {
        box-sizing: border-box;
        width: fit-content;
        max-width: 100%;
        margin: 8px auto 0;
        padding: 6px 10px;
        border: 1px solid rgba(255,255,255,0.15);
        border-radius: 6px;
        background: rgba(255,255,255,0.04);
        font-size: 12px;
        font-weight: 700;
        text-align: center;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        white-space: nowrap;
      }

      .oc-success-box .label {
        opacity: .75;
        margin-right: 4px;
        text-transform: uppercase;
        font-size: 11px;
      }

      .oc-success-box .value {
        color: #65d46e;
        font-size: 14px;
      }
    `;

    const st = document.createElement("style");
    st.id = STYLE_ID;
    st.textContent = css;
    document.head.appendChild(st);
  }

  function getOCName(ocRoot) {
    const el = q('[class*="panelTitle"]', ocRoot);
    return el ? el.textContent.trim() : null;
  }

  function getOCLevel(ocRoot) {
    const el = q('[class*="levelValue"]', ocRoot);
    if (!el) return null;

    const value = parseInt(el.textContent.trim(), 10);
    return Number.isNaN(value) ? null : value;
  }

  function parseCPR(role) {
    const el = q('[class*="successChance"]', role);
    if (!el) return null;

    const raw = el.textContent.trim();
    const cprPart = raw.split("/")[0].trim();
    const value = parseInt(cprPart, 10);

    return Number.isNaN(value) ? null : value;
  }

  function getRoleName(role) {
    const header = q('[class*="slotHeader"]', role);
    const titleEl = q('[class*="title"]', header || role);
    return titleEl ? titleEl.textContent.trim() : "";
  }

  function getRoleSuccessMap(roles) {
    const successByRole = {};

    for (const role of roles) {
      const roleName = getRoleName(role);
      const cpr = parseCPR(role);

      if (!roleName || cpr == null) continue;

      successByRole[normalize(roleName)] = cpr;
    }

    return successByRole;
  }

  function getRoleSuccessParameters(ocNameRaw, roles) {
    const apiRoleNames = roleNameData[normalize(ocNameRaw)];
    const successByRole = getRoleSuccessMap(roles);

    if (!Array.isArray(apiRoleNames)) {
      return roles
        .map((role) => parseCPR(role))
        .filter((successChance) => successChance != null);
    }

    return apiRoleNames
      .map((roleName) => successByRole[normalize(roleName)])
      .filter((successChance) => successChance != null);
  }

  function isOpenRole(role) {
    return String(role.className).includes("waitingJoin");
  }

  function getRequiredCPR(level, weight) {
    // Levels 1-7
    if (level >= 1 && level <= 7) {
      if (weight < 10) return 57;
      if (weight < 23) return 65;
      if (weight < 31) return 71;
      return 72;
    }

    // Levels 8-10
    if (level >= 8 && level <= 10) {
      if (weight < 10) return 59;
      if (weight < 16) return 61;
      if (weight < 23) return 62;
      if (weight < 29) return 64;
      if (weight < 36) return 65;
      return 66;
    }

    return null;
  }

  function normalizeApiData(data) {
    const normalized = {};

    for (const [ocName, roles] of Object.entries(data || {})) {
      normalized[normalize(ocName)] = Object.entries(roles || {})
        .sort(([a], [b]) => {
          const aIndex = parseInt(a.replace(/\D/g, ""), 10);
          const bIndex = parseInt(b.replace(/\D/g, ""), 10);
          return aIndex - bIndex;
        })
        .map(([, roleName]) => roleName);
    }

    return normalized;
  }

  function meetsCPRRequirement(level, weight, cpr) {
    const required = getRequiredCPR(level, weight);
    if (required == null) return false;
    return cpr >= required;
  }

  function updateDisplayedCPR(role, level, weight, cpr) {
    const cprEl = q('[class*="successChance"]', role);
    if (!cprEl) return;

    const requiredCPR = getRequiredCPR(level, weight);
    if (requiredCPR == null) return;

    const desiredText = `${cpr} / ${requiredCPR}`;

    if (cprEl.textContent.trim() !== desiredText) {
      cprEl.textContent = desiredText;
    }
  }

  function updateIndicator(role, level, weight, cpr) {
    const container = q('[class*="joinButtonContainer"]', role);
    let indicator = q(".oc-cpr-indicator", role);

    if (!isOpenRole(role) || cpr == null || !container) {
      if (indicator) indicator.remove();
      return;
    }

    const requiredCPR = getRequiredCPR(level, weight);
    if (requiredCPR == null) {
      if (indicator) indicator.remove();
      return;
    }

    const ok = meetsCPRRequirement(level, weight, cpr);

    if (!indicator) {
      indicator = document.createElement("span");
      indicator.className = "oc-cpr-indicator";
      container.appendChild(indicator);
    }

    indicator.className = `oc-cpr-indicator ${ok ? "pass" : "fail"}`;
    indicator.textContent = ok ? "✔" : "✖";
    indicator.title = ok
      ? `CPR ${cpr} meets requirement for level ${level}, weight ${weight.toFixed(1)}% (needs ${requiredCPR}+)`
      : `CPR ${cpr} does not meet requirement for level ${level}, weight ${weight.toFixed(1)}% (needs ${requiredCPR}+)`;
  }

  function updateWeightBox(role, weight) {
    let box = q(".oc-weight-box", role);

    if (!box) {
      box = document.createElement("div");
      box.className = "oc-weight-box";
      box.innerHTML = `
        <span class="label">Weight</span>
        <span class="value"></span>
      `;
      role.appendChild(box);
    }

    const valueEl = q(".value", box);
    const newWeightText = `${weight.toFixed(1)}%`;

    if (valueEl && valueEl.textContent !== newWeightText) {
      valueEl.textContent = newWeightText;
    }
  }

  function updateSuccessBox(ocRoot, successChance) {
    const panel = q('[class*="panel"]', ocRoot) || ocRoot;
    let box = q(".oc-success-box", ocRoot);

    if (!box) {
      box = document.createElement("div");
      box.className = "oc-success-box";
      box.innerHTML = `
        <span class="label">Success</span>
        <span class="value"></span>
      `;
      panel.appendChild(box);
    }

    const valueEl = q(".value", box);
    const numericValue = Number(successChance);
    const text = Number.isNaN(numericValue)
      ? String(successChance)
      : `${(numericValue * 100).toFixed(2)}%`;

    if (valueEl && valueEl.textContent !== text) {
      valueEl.textContent = text;
    }
  }

  function extractCalculatedSuccess(data) {
    if (data && typeof data === "object" && typeof data.successChance === "number") {
      return data.successChance;
    }

    return null;
  }

  async function calculateAndDisplaySuccess(ocRoot, ocNameRaw, parameters) {
    const cacheKey = JSON.stringify({
      scenario: ocNameRaw,
      parameters,
    });

    if (successData[cacheKey] != null) {
      updateSuccessBox(ocRoot, successData[cacheKey]);
      return;
    }

    if (pendingSuccessRequests.has(cacheKey)) return;
    pendingSuccessRequests.add(cacheKey);

    try {
      const data = await gmJsonRequest({
        method: "POST",
        url: SUCCESS_API_URL,
        data: {
          scenario: ocNameRaw,
          parameters,
        },
      });

      const successChance = extractCalculatedSuccess(data);

      if (successChance == null) {
        console.warn(
          "[OC Requirement Checker] CalculateSuccess response did not contain a recognizable success chance:",
          data
        );
        return;
      }

      successData[cacheKey] = successChance;
      updateSuccessBox(ocRoot, successChance);
    } catch (err) {
      console.error("[OC Requirement Checker] Failed to calculate success chance:", err);
    } finally {
      pendingSuccessRequests.delete(cacheKey);
    }
  }

  function processRole(role, ocWeights, ocLevel) {
    const header = q('[class*="slotHeader"]', role);
    const roleNameRaw = (q('[class*="title"]', header || role)?.textContent || "").trim();
    if (!roleNameRaw) return;

    const roleKey = normalize(roleNameRaw);
    const weight = ocWeights[roleKey];
    if (weight == null) return;

    updateWeightBox(role, weight);

    const cpr = parseCPR(role);
    if (cpr == null) return;

    if (isOpenRole(role)) {
      updateDisplayedCPR(role, ocLevel, weight, cpr);
    }

    updateIndicator(role, ocLevel, weight, cpr);
  }

  function processOC(ocRoot) {
    const ocNameRaw = getOCName(ocRoot);
    if (!ocNameRaw) return;

    const ocKey = normalize(ocNameRaw);
    const ocWeights = weightData[ocKey];
    if (!ocWeights) return;

    const ocLevel = getOCLevel(ocRoot);
    if (ocLevel == null) return;

    const roles = qa('[class*="wrapper"]', ocRoot).filter((el) =>
      Array.from(el.children).some((child) => String(child.className).includes("slotHeader")) &&
      q('[class*="successChance"]', el)
    );

    roles.forEach((role) => processRole(role, ocWeights, ocLevel));

    const parameters = getRoleSuccessParameters(ocNameRaw, roles);
    if (parameters.length) {
      calculateAndDisplaySuccess(ocRoot, ocNameRaw, parameters);
    }
  }

  function scanPage() {
    if (!Object.keys(weightData).length) return;
    injectStyles();

    const ocs = qa("[data-oc-id]");
    ocs.forEach(processOC);
  }

  function scheduleScan() {
    clearTimeout(scanTimer);
    scanTimer = setTimeout(scanPage, 150);
  }

  const obs = new MutationObserver((mutations) => {
    for (const m of mutations) {
      if (m.addedNodes.length || m.removedNodes.length) {
        scheduleScan();
        return;
      }
    }
  });

  function startObserver() {
    obs.observe(document.body, { childList: true, subtree: true });
  }

  async function loadApiData() {
    try {
      const [weights, roleNames] = await Promise.all([
        gmJsonRequest({
          method: "GET",
          url: API_URL,
        }),
        gmJsonRequest({
          method: "GET",
          url: ROLE_NAMES_API_URL,
        }),
      ]);

      weightData = {};

      for (const [ocName, roles] of Object.entries(weights || {})) {
        const ocKey = normalize(ocName);
        weightData[ocKey] = {};

        for (const [roleName, value] of Object.entries(roles || {})) {
          weightData[ocKey][normalize(roleName)] = value;
        }
      }

      roleNameData = normalizeApiData(roleNames);

      scanPage();
      startObserver();
    } catch (err) {
      console.error("[OC Requirement Checker] Failed to load API data:", err);
    }
  }

  loadApiData();
})();