// ==UserScript==
// @name Torn Crimes 2.0 Helper Fixed
// @namespace https://www.torn.com
// @version 0.0.1
// @description Simple helper for crimes 2.0. Adds links to guides for each crime, quick buy link for materials/enhancers and crime chain counter. Fixed the old version duplicating elements.
// @author TheProgrammer
// @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';
// Add a flag to check if the script has already run
if (window.crimesTwoPointOhScriptHasRun) {
return; // Exit if the script has already run
}
window.crimesTwoPointOhScriptHasRun = true;
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();
}
});
})();