Torn Crimes 2.0 Helper

Simple helper for crimes 2.0. Adds links to guides for each crime, quick buy link for materials/enhancers and crime chain counter.

当前为 2025-01-06 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Torn Crimes 2.0 Helper
// @namespace    https://www.torn.com/profiles.php?XID=2973354
// @version      2025-01-06
// @description  Simple helper for crimes 2.0. Adds links to guides for each crime, quick buy link for materials/enhancers and crime chain counter.
// @author       TiltGod5000
// @license      MIT
// @match        https://www.torn.com/loader.php?sid=crimes*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        unsafeWindow
// @run-at       document-idle
// ==/UserScript==

/* jshint esversion: 11 */

(function () {
  'use strict';
  const svgs = {
    refresh: `<?xml version="1.0" ?><svg fill="#000000" width="800px" height="800px" viewBox="-0.5 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m23.314 8.518v-7.832l-2.84 2.84c-2.172-2.176-5.175-3.522-8.493-3.522-6.627 0-12 5.373-12 12s5.373 12 12 12c4.424 0 8.289-2.394 10.37-5.958l.031-.057-2.662-1.536c-1.57 2.695-4.447 4.478-7.739 4.478-4.93 0-8.927-3.997-8.927-8.927s3.997-8.927 8.927-8.927c2.469 0 4.704 1.002 6.32 2.622l-2.82 2.82h7.834z"/></svg>`,
    guide: `<?xml version="1.0" encoding="utf-8"?><svg width="16px" height="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><g><path d="M12.75 1a.75.75 0 000 1.5h.69l-1.97 1.97a.75.75 0 001.06 1.06l1.97-1.97v.69a.75.75 0 001.5 0v-2.5a.75.75 0 00-.75-.75h-2.5z"/><path fill-rule="evenodd" d="M1.25 2C.56 2 0 2.56 0 3.25v8.5C0 12.44.56 13 1.25 13H5c.896 0 1.475.205 1.809.448.317.23.441.51.441.802a.751.751 0 101.5 0c0-.292.124-.572.441-.802.334-.243.913-.448 1.809-.448h3.75c.69 0 1.25-.56 1.25-1.25v-4.5a.75.75 0 00-1.5 0v4.25H11c-.878 0-1.64.158-2.25.467v-6.55c0-.788.376-1.42 1.12-1.722a.75.75 0 00-.561-1.39 3.27 3.27 0 00-1.31.941A3.13 3.13 0 007.773 3C7.106 2.354 6.154 2 5 2H1.25zm6 3.417c0-.553-.187-1.015-.522-1.34C6.394 3.753 5.846 3.5 5 3.5H1.5v8H5c.878 0 1.64.158 2.25.467v-6.55z" clip-rule="evenodd"/></g></svg>`,
  };
  const styles = `
    .enhancer-info > a {
      color: red;
      text-decoration: none;
      padding-left: 1rem;
    }
  
    .apiKeyModal {
      position: fixed;
      top: 50px;
      left: 50px;
      width: 400px;
      background-color: #1a1a1a;
      color: #fff;
      border-radius: 0.6rem;
      border: 1px solid #fff;
      box-shadow: 0 0 10px 0 #000;
      display: flex;
      flex-direction: column;
      padding: 1rem 2rem;
      align-items: center;
      justify-content: center;
      z-index: 99999;
      gap: 1rem;
    }
    .apiKeyModal .modal-buttons {
      display: flex;
      gap: 1rem;
      justify-content: center;
    }
    .apiKeyModal input {
      width: 100%;
      padding: .5rem;
    }
    .apiKeyModal button {
      background-color: buttonface;
    }
    .crime-chain svg {
      width: .75rem;
      height: .75rem;
      fill: #fff;
    }
    .crime-chain.spinning svg {
      animation: rotate 1s linear infinite;
    }
    @keyframes rotate {
      0% {
        transform: rotate(0deg);
      }
      100% {
        transform: rotate(360deg);
      }
    }
    `;

  const styleSheet = document.createElement('style');
  styleSheet.innerText = styles;
  document.head.appendChild(styleSheet);
  const crimeHeader = document.querySelector('.crimes-app');
  const crimeLink = crimeHeader.querySelector('[class*="link_"]');
  const crimeLinkClass = [...crimeLink.classList].find((className) => className.startsWith('link'));
  const crimeChainElement = document.createElement('div');
  let currentEnhancer = null;
  let crimeChainStore = JSON.parse(localStorage.getItem('crimeChain')) || {
    apiKey: null,
    chain: 0,
    lastCritical: null,
    lastTimeStamp: null,
  };

  const crimeDirectory = new Map([
    [
      'searchforcash',
      {
        title: '[Crimes 2.0] Search For Cash: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16343473',
        enhancer: 564,
      },
    ],
    [
      'bootlegging',
      {
        title: '[Crimes 2.0] Bootlegging: An in-depth guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16341811',
        enhancer: 565,
      },
    ],
    [
      'graffiti',
      {
        title: '[Crimes 2.0] Graffiti: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16344567',
        enhancer: 979,
      },
    ],
    [
      'shoplifting',
      {
        title: '[Crimes 2.0] Shoplifting: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16346491',
        enhancer: 566,
      },
    ],
    [
      'cardskimming',
      {
        title: '[Crimes 2.0] Card Skimming: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#p=threads&f=61&t=16350490',
        enhancer: 578,
      },
    ],
    [
      'burglary',
      {
        title: '[Crimes 2.0] Burglary: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#p=threads&f=61&t=16353303',
        enhancer: 1351,
      },
    ],
    [
      'pickpocketing',
      {
        title: '[Crimes 2.0] Pickpocketing: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16358739',
        enhancer: 567,
      },
    ],
    [
      'hustling',
      {
        title: '[Crimes 2.0] Hustling: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16363421',
        enhancer: 1353,
      },
    ],
    [
      'disposal',
      {
        title: '[Crimes 2.0] Disposal: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16367936',
        enhancer: 633,
      },
    ],
    [
      'cracking',
      {
        title: '[Crimes 2.0] Cracking: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16373016',
        enhancer: 1354,
      },
    ],
    [
      'forgery',
      {
        title: '[Crimes 2.0] Forgery: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16388086',
        enhancer: 1346,
      },
    ],
    [
      'scamming',
      {
        title: '[Crimes 2.0] Scamming: An In-Depth Guide',
        url: 'https://www.torn.com/forums.php#/p=threads&f=61&t=16418415',
        enhancer: 571,
      },
    ],
  ]);

  if (typeof window.interceptFetch === 'undefined' && !document.querySelector('#tt-page-status')) {
    // torntools probably not installed
    function interceptFetch(channel) {
      const oldFetch = window.fetch;
      window.fetch = function () {
        return new Promise((resolve, reject) => {
          oldFetch
            .apply(this, arguments)
            .then(async (response) => {
              const page = response.url.substring(
                response.url.indexOf('torn.com/') + 'torn.com/'.length,
                response.url.indexOf('.php')
              );
              let json = {};
              try {
                json = await response.clone().json();
              } catch {}

              const detail = {
                page,
                json,
                fetch: {
                  url: response.url,
                  status: response.status,
                },
              };

              window.dispatchEvent(
                new CustomEvent(channel, {
                  detail,
                })
              );

              resolve(response);
            })
            .catch((error) => {
              reject(error);
            });
        });
      };
    }
    interceptFetch('tt-fetch');
  }

  async function calculateCrimeChain(fromLastCritical = false, manual = false) {
    const fetchLog = async ({ from, to } = {}) => {
      const params = new URLSearchParams({
        selections: 'log',
        cat: '136',
        key: crimeChainStore.apiKey,
        comment: 'Crime Chain Counter',
      });
      if (from) {
        params.set('from', from);
      } else if (to) {
        params.set('to', to);
      }
      return await fetch(`https://api.torn.com/user/?${params.toString()}`)
        .then((res) => res.json())
        .then(({ log }) => Object.entries(log).map(([, logData]) => logData));
    };

    let crimeChain = 0;
    let lastLog = null;
    let logData = await fetchLog({
      from: manual ? undefined : fromLastCritical ? crimeChainStore.lastCritical : crimeChainStore.lastTimeStamp - 1,
    });
    /*
      I want to avoid an infinite loop like while(true) here to avoid crashing anything/running
      into issues with rate limits. Highly unlikely scenario, but using while(true) just feels wrong.
      */
    for (let i = 0; i < 30; i++) {
      if (logData.length === 0 || logData.some(({ title }) => title.startsWith('Crime critical'))) {
        break;
      }
      logData = logData.concat(await fetchLog({ to: logData.at(-1).timestamp - 1 }));
    }
    for (let i = logData.length; i > 0; i--) {
      const log = logData[i - 1];
      const { title } = log;
      if (title.startsWith('Crime critical')) {
        crimeChain = 0;
      } else if (title.startsWith('Crime success')) {
        crimeChain++;
      } else if (title.startsWith('Crime fail')) {
        crimeChain /= 2;
      }
      lastLog = log;
    }
    return { crimeChain, lastLog };
  }

  async function fetchCrimeLog(manual = false) {
    if (!crimeChainStore.apiKey && manual) return apiKeyModal();
    if (!crimeChainStore.apiKey) return;
    if (!manual && crimeChainStore.lastTimeStamp < Date.now() / 1000 - 60 * 5) return;
    crimeChainElement.classList.add('spinning');
    const { crimeChain, lastLog } = await calculateCrimeChain(manual);
    crimeChainStore.chain = crimeChain;
    crimeChainStore.lastTimeStamp = lastLog.timestamp - 1;
    if (lastLog.log === 9154) {
      crimeChainStore.lastCritical = lastLog.timestamp - 1;
    }
    localStorage.setItem('crimeChain', JSON.stringify(crimeChainStore));
    syncCrimeChain();
    crimeChainElement.classList.remove('spinning');
  }

  function apiKeyModal() {
    const modal = document.createElement('div');
    modal.classList.add('apiKeyModal');
    modal.innerHTML = `
        <p>To accurately display your crime chain, please provide a <b>FULL</b> API key</p>
        <input type="text" placeholder="API Key" />
        <div class="modal-buttons">
          <button type="submit">Submit</button>
          <button>Close</button>
        </div>
      `;
    document.body.appendChild(modal);
    [...modal.querySelectorAll('button')].forEach((button) => {
      button.onclick = () => {
        if (button.type === 'submit') {
          const apiKey = modal.querySelector('input').value;
          if (apiKey) {
            crimeChainStore.apiKey = apiKey;
            localStorage.setItem('crimeChain', JSON.stringify(crimeChainStore));
            fetchCrimeLog(true);
          }
        }
        modal.remove();
      };
    });
  }

  function syncCrimeChain() {
    const refreshButton = document.createElement('button');
    refreshButton.innerHTML = svgs.refresh;
    refreshButton.onclick = () => fetchCrimeLog(true);
    crimeChainElement.textContent = `Current Crime Chain: ${crimeChainStore.chain.toFixed(2)}`;
    crimeChainElement.appendChild(refreshButton);
  }

  function addCrimeChain() {
    crimeChainElement.classList.add('crime-chain');
    crimeHeader.insertBefore(crimeChainElement, document.querySelector('.crimes-app > hr'));
    syncCrimeChain();
  }

  function addLinksToRequiredItems(target) {
    [...target.querySelectorAll('div > [class*="silhouette_"] > img')].forEach((img) => {
      const itemId = /items\/(\d+)/.exec(img.src)[1];
      const linkElement = document.createElement('a');
      linkElement.href = `https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${itemId}`;
      linkElement.target = '_blank';
      linkElement.innerHTML = img.outerHTML;
      img.outerHTML = linkElement.outerHTML;
    });
  }

  function addGuideLink() {
    const crimeLocation = window.location.hash.slice(2);
    const guideLink = crimeHeader.querySelector('.guide-link');
    if (crimeLocation === '') {
      guideLink?.remove();
    }
    const crime = crimeDirectory.get(crimeLocation);
    if (crime) {
      const crime = crimeDirectory.get(crimeLocation);

      const urlElement = document.createElement('a');
      urlElement.target = '_blank';
      urlElement.classList.add(crimeLinkClass);
      urlElement.classList.add('guide-link');
      urlElement.setAttribute('data-crime-name', crimeLocation);
      urlElement.innerHTML = `${svgs.guide} ${crime.title}`;
      urlElement.href = crime.url;
      if (!guideLink) {
        const headerElement = crimeHeader.querySelector('.crimes-app-header');
        headerElement.insertBefore(urlElement, headerElement.querySelector('a'));
      } else if (guideLink.getAttribute('data-crime-name') !== crimeLocation) {
        guideLink.outerHTML = urlElement.outerHTML;
      }
    }
  }

  function addEnhancerInfo() {
    const crimeTitle = document.querySelector('.crime-root [class*="title__"]');
    const crimeLocation = window.location.hash.slice(2);
    const crime = crimeDirectory.get(crimeLocation);
    if (crimeTitle && !crimeTitle.classList.contains('enhancer-info') && crime) {
      crimeTitle.classList.add('enhancer-info');
      if (!currentEnhancer?.available) {
        crimeTitle.innerHTML = `${crimeTitle.innerText} <a href="https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${crime.enhancer}" target="_blank">${currentEnhancer.name} NOT AVAILABLE</a>`;
      } else {
        crimeTitle.innerHTML = `${crimeTitle.innerText} ✅`;
      }
    }
  }

  const crimeHeaderObserver = new MutationObserver((mutationList, observer) => {
    for (const mutation of mutationList) {
      if (mutation.target.classList.contains('crimes-app-header')) {
        addGuideLink();
      }
      if (
        mutation.addedNodes[0]?.classList &&
        [...mutation.addedNodes[0]?.classList].some((className) => className.startsWith('crimeSlider__'))
      ) {
        addEnhancerInfo();
      }
      addLinksToRequiredItems(mutation.target);
    }
  });

  crimeHeaderObserver.observe(crimeHeader, {
    childList: true,
    subtree: true,
  });

  addGuideLink();
  addCrimeChain();
  fetchCrimeLog();
  window.addEventListener('tt-fetch', ({ detail }) => {
    const enhancer = detail?.json?.DB?.currentUserStats?.enhancer;
    if (enhancer) {
      currentEnhancer = enhancer;
    }
    const crimeOutcome = detail?.json?.DB?.outcome?.result;
    const ID = detail?.json?.DB?.outcome?.ID;
    if (crimeOutcome) {
      if (crimeOutcome === 'success') {
        crimeChainStore.chain++;
      } else if (crimeOutcome === 'failure') {
        crimeChainStore.chain /= 2;
      } else if (crimeOutcome === 'critical failure' && ID !== null) {
        // needs verification
        crimeChainStore.chain = 0;
      }
      localStorage.setItem('crimeChain', JSON.stringify(crimeChainStore));
      syncCrimeChain();
    }
  });
})();