Torn Log Hustling

Logs detailed info about all hustling attempts.

// ==UserScript==
// @name         Torn Log Hustling
// @namespace    https://github.com/SOLiNARY
// @version      0.3.1
// @description  Logs detailed info about all hustling attempts.
// @author       Ramin Quluzade, Silmaril [2665762]
// @license      MIT License
// @match        https://www.torn.com/loader.php?sid=crimes*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_setClipboard
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use strict';

    const viewPortWidthPx = window.innerWidth;
    const isMobileView = viewPortWidthPx <= 784;
    const isTampermonkeyEnabled = typeof unsafeWindow !== 'undefined';
    const levelsImagePxGap = 35;
    const levelsArray = [[1], generateNumberRange(2, 24), generateNumberRange(25, 49), generateNumberRange(50, 74), generateNumberRange(75, 99)];
    const actions = ['Gather', 'Demo', 'Hype', 'Lose', 'Win'];
    let attached = false;
    let userId = 'N/A';

    if (!isTampermonkeyEnabled) {
        console.error('[TornLogHustling] Please, install Tampermonkey first!');
        alert('[TornLogHustling] Please, install Tampermonkey first!');
        return;
    }

    const styles = `
    div.crimeSlider___uha7d > div.previousCrime___gxp9r, div.currentCrime___KNKYQ, div.nextCrime___uKnJr {
        transition-duration: 0ms !important;
    }`;
    GM_addStyle(styles);

    try {
        if (isMobileView) {
            const inputString = document.querySelector('img.mini-avatar-image').src;
            const regex = /(\d+)\.jpg$/;
            const match = inputString.match(regex);
            if (match) {
                userId = match[1];
            }
        } else {
            const userLink = document.querySelector('#sidebarroot div.user-information___VBSOk p.menu-info-row___YG31c > a').href;
            userId = userLink.split("=")[1];
        }
    } catch (e) {
        userId = 'N/A';
    }

    addExportButton(document.querySelector('div.crimes-app div.topSection___U7sVi div.titleContainer___QrlWP'));

    const targetNode = document.querySelector("div.crimes-app");
    const observerConfig = { childList: true, subtree: true };
    const observer = new MutationObserver(async (mutationsList, observer) => {
        const crimesDiv = document.querySelectorAll("div.crimes-app");
        const currentCrimeDiv = document.querySelectorAll("div.currentCrime___KNKYQ");
        for (const mutation of mutationsList) {
            let mutationTarget = mutation.target;
            if (!attached && mutation.type === 'childList' && mutationTarget.className == 'crime-root hustling-root') {
                if (document.querySelector('div.currentCrime___KNKYQ div.title___MqBua') == null) {
                    continue;
                }
                attached = true;
                crimesDiv.forEach((div) => {
                    div.addEventListener("click", function (event) {
                        if (event.target.matches("div.topSection___U7sVi div.linksContainer___LiOTN")) {
                            handleSwitchPage();
                        }
                    });
                });
                currentCrimeDiv.forEach((div) => {
                    div.addEventListener("click", function (event) {
                        if (event.target.matches("div.topSection___HchKK div.crimeBanner___LiKtj div.crimeSliderArrowButtons___N_y4N button.arrowButton___gYTVW")) {
                            handleSwitchPage();
                        }
                    });
                });
                const hustlingRoot = document.querySelector('div.crime-root.hustling-root');
                const gatherBtn = document.querySelectorAll('.crimeOptionGroup___gQ6rI')[0].querySelector('button.commitButton___uX7k_');
                $(mutationTarget).on("click", "button.commitButton___uX7k_:not(.silmaril-log-hustling)", async function(event){
                    const now = document.querySelector('span.server-date-time').textContent.split(' ');
                    const date = now[3];
                    const time = now[1];
                    const skillLevelBefore = getCurrentSkillLevelValue();
                    const audienceBefore = getAudience();

                    const button = findNearestButtonParent(event.target);
                    const action = button.ariaLabel;
                    const row = button.parentNode.parentNode;
                    const shillBalanceBefore = getCurrentShillBalance();
                    const pickpocketBalanceBefore = getCurrentPickpocketBalance();
                    let game = '';
                    let target = '';
                    let techLevel = '';
                    let techSkillBefore = '';
                    try {
                        switch (action) {
                            case 'Gather, 4 nerve':
                                break;
                            case 'Demo, 2 nerve':
                            case 'Hype, 2 nerve':
                            case 'Lose, 2 nerve':
                            case 'Win, 2 nerve':
                                game = isMobileView ? row.querySelector('div.titleAndBet___JgIty > span').textContent : row.querySelector('div.crimeOptionSection___hslpu.title___u67Sn').textContent;
                                techLevel = row.querySelector('div.techniqueBar___JaXl6').ariaLabel;
                                techSkillBefore = getCurrentTechSkill(row);
                                break;
                            case 'Recruit, 4 nerve':
                            case 'Collect, 2 nerve':
                                target = isMobileView ? row.querySelector('div.titleAndBet___JgIty > span').textContent : row.querySelector('div.crimeOptionSection___hslpu.titleSection___Se0RD').textContent;
                                break;
                            default:
                                break;
                        }
                    } catch (e) {
                        console.error('[TornLogHustling] Error while parsing action', action, e);
                    }

                    while (row.nextElementSibling.querySelector('div.outcomeReward___E34U7:not(.silmaril-log-hustling-parsed)') == null) {
                        await sleep(20);
                    }

                    const outcomeRewardDiv = row.nextElementSibling.querySelector('div.outcomeReward___E34U7');
                    const result = outcomeRewardDiv.querySelector('p.title___IrNe6').textContent;
                    const gained = [];
                    if (outcomeRewardDiv.querySelector('div.rewards___oRCyz span.reward___dCLvW') != null) {
                        const money = outcomeRewardDiv.querySelector('div.rewards___oRCyz span.reward___dCLvW').textContent;
                        gained.push(money);
                    }
                    try {
                        gained.push(outcomeRewardDiv.querySelector('div.rewards___oRCyz div.reward___Tvvmx').textContent);
                    } catch (e) {
                        console.log('[TornLogHustling] No reward string parsed');
                    }
                    try {
                        gained.push(outcomeRewardDiv.querySelector('div.rewards___oRCyz div.reward___OmEOo').textContent);
                    } catch (e) {
                        console.log('[TornLogHustling] No crit fail injury string parsed');
                    }
                    try {
                        gained.push(outcomeRewardDiv.querySelector('div.rewards___oRCyz div.reward___snxxf').textContent);
                    } catch (e) {
                        console.log('[TornLogHustling] No crit fail jail string parsed');
                    }

                    outcomeRewardDiv
                        .querySelectorAll('div.rewards___oRCyz div.reward___P7K87 img')
                        .forEach(item => {
                        try {
                            gained.push(item.alt);
                        } catch (e) {
                            console.error('[TornLogHustling] Error while parsing non-ammo rewards:', item, e);
                        }
                    });
                    outcomeRewardDiv
                        .querySelectorAll('div.rewards___oRCyz div.reward___oQH1h')
                        .forEach(item => {
                        try {
                            let ammoQuantity = item.querySelector('div.itemCell___aZaUE.countCell___XzhYQ span.count___hBmtm').textContent;
                            let ammoCodeX = (Math.abs(parseInt(item.querySelector('div.ammoImage___tebhe').style.backgroundPositionX))/70).toFixed(0);
                            let ammoCodeY = (Math.abs(parseInt(item.querySelector('div.ammoImage___tebhe').style.backgroundPositionY))/70).toFixed(0);
                            gained.push(`${ammoQuantity} ammo of type ${ammoCodeX}-${ammoCodeY}`);
                        } catch (e) {
                            console.error('[TornLogHustling] Error while parsing ammo rewards:', item, e);
                        }
                    });
                    outcomeRewardDiv.classList.add('silmaril-log-hustling-parsed');
                    const skillLevelAfter = getCurrentSkillLevelValue();

                    let techSkillAfter = '';
                    const shillBalanceAfter = getCurrentShillBalance();
                    const pickpocketBalanceAfter = getCurrentPickpocketBalance();
                    switch (action) {
                        case 'Demo, 2 nerve':
                        case 'Hype, 2 nerve':
                        case 'Lose, 2 nerve':
                        case 'Win, 2 nerve':
                            techSkillAfter = getCurrentTechSkill(row);
                            break;
                        case 'Gather, 4 nerve':
                        case 'Recruit, 4 nerve':
                        case 'Collect, 2 nerve':
                        default:
                            break;
                    }

                    const audienceAfter = getAudience();
                    const progressionBonus = document.querySelector('div.statsColumn___ATsSP > button.statistic___jgFf2:nth-child(3) > span.value___FdkAT').textContent;
                    const version = GM_info.script.version;

                    const hustleAttempt = new HustleAttempt(userId, date, time, action, game, skillLevelBefore, skillLevelAfter, techLevel, techSkillBefore, techSkillAfter, audienceBefore, audienceAfter, target, shillBalanceBefore, shillBalanceAfter, pickpocketBalanceBefore, pickpocketBalanceAfter, result, gained, progressionBonus, version);
                    const dateSplit = date.split('/');
                    const timeStampFormatted = `${dateSplit[2]}${dateSplit[1]}${dateSplit[0]}${time.replaceAll(':','')}`;
                    const newGuid = generateGuid();
                    GM_setValue(`silmaril-torn-log-hustling-attempt-${timeStampFormatted}-${newGuid}`, hustleAttempt);
                    updateLogsCount();
                });

                observer.disconnect();
            }
        }
    });
    setUpObserver();

    function handleSwitchPage() {
        attached = false;
        setUpObserver();
    }

    function getAudience() {
        let audience = '';
        try {
            audience = document.querySelector('div.crimeOptionSection___hslpu.audienceSection___f3jVs > span.srOnly___Nqywa').textContent;
        } catch (e) {
            console.error('[TornLogHustling] Error while parsing audience:', e);
            audience = 'Error';
        }
        return audience;
    }

    function addExportButton(root) {
        const outerExportDiv = document.createElement('div');
        outerExportDiv.className = 'title___MqBua';
        const exportButton = document.createElement('button');
        exportButton.className = 'torn-btn commitButton___uX7k_ silmaril-log-hustling silmaril-log-hustling-export';
        exportButton.ariaLabel = 'Copy Hustling logs';
        const exportButtonLabel = document.createElement('span');
        exportButtonLabel.className = 'title___KAQ9m';
        exportButtonLabel.textContent = isMobileView ? 'Copy logs' : 'Copy Hustling logs';
        const exportButtonDivider = document.createElement('span');
        exportButtonDivider.className = 'divider___RGx7a';
        const exportButtonLogCount = document.createElement('span');
        exportButtonLogCount.className = 'nerveCost___OoGfz';
        exportButtonLogCount.textContent = getAllLogsCount();
        exportButton.append(exportButtonLabel, exportButtonDivider, exportButtonLogCount);
        exportButton.addEventListener('click', exportLogs);

        const outerClearDiv = document.createElement('div');
        outerClearDiv.className = 'title___MqBua';
        const clearButton = document.createElement('button');
        clearButton.className = 'torn-btn commitButton___uX7k_ silmaril-log-hustling silmaril-log-hustling-clear';
        clearButton.ariaLabel = 'Clear Hustling logs';
        const clearButtonLabel = document.createElement('span');
        clearButtonLabel.textContent = isMobileView ? 'Clear logs' : 'Clear Hustling logs';
        clearButton.appendChild(clearButtonLabel);
        clearButton.addEventListener('click', clearLogs);

        outerExportDiv.append(exportButton);
        outerClearDiv.append(clearButton);

        root.append(outerExportDiv, outerClearDiv);

        const backButton = document.querySelector('div.topSection___U7sVi > div.linksContainer___LiOTN');
        backButton.addEventListener('click', function(){
            attached = false;
            setUpObserver();
        });
    }

    function setUpObserver() {
        observer.observe(targetNode, observerConfig);
    }

    function updateLogsCount(reset = false) {
        const countSpan = document.querySelector('button.silmaril-log-hustling-export > span.nerveCost___OoGfz');
        const count = reset ? 0 : parseInt(countSpan.textContent) + 1;
        countSpan.textContent = count;
    }

    function exportLogs() {
        let logs = getAllLogs();
        let logsRaw = JSON.stringify(logs);
        GM_setClipboard(logsRaw, 'json');
        const exportText = document.querySelector('button.silmaril-log-hustling-export > span.title___KAQ9m');
        exportText.textContent = isMobileView ? 'Copied!' : 'Copied to clipboard!';
        setTimeout(function(){exportText.textContent = isMobileView ? 'Copy logs' : 'Copy Hustling logs';}, 2500);
    }

    function clearLogs() {
        deleteAllLogs();
        updateLogsCount(true);
        const clearText = document.querySelector('button.silmaril-log-hustling-clear > span');
        clearText.textContent = isMobileView ? 'Cleared!' : 'Cleared all logs!';
        setTimeout(function(){clearText.textContent = isMobileView ? 'Clear logs' : 'Clear Hustling logs';}, 2500);
    }

    function getAllLogs() {
        const prefix = 'silmaril-torn-log-hustling-attempt-';
        const filteredRecords = {};
        const allKeys = GM_listValues();
        allKeys.forEach((key) => {
            if (key.startsWith(prefix)) {
                filteredRecords[key] = GM_getValue(key);
            }
        });
        return filteredRecords;
    }

    function getAllLogsCount() {
        const allLogsCount = Object.keys(GM_listValues()).length;
        return allLogsCount;
    }

    function deleteAllLogs() {
        const allKeys = GM_listValues();
        allKeys.forEach((key) => {
            GM_deleteValue(key);
        });
    }

    function getCurrentSkillLevelValue() {
        let currentSkillLevel = -1;
        if (document.querySelector('div.maxLevel___jTooz') == null) {
            const skillLevelDiv = document.querySelector('div.level___tAlsk');
            const levelFirstIndex = Math.abs(parseInt(skillLevelDiv.style.backgroundPositionX, 10)) / levelsImagePxGap;
            const levelSecondIndex = Math.abs(parseInt(skillLevelDiv.style.backgroundPositionY, 10)) / levelsImagePxGap;
            const skillLevelBefore = levelsArray[levelFirstIndex][levelSecondIndex];
            const skillProgressBar = document.querySelector('div.progressFill___ksrq5');
            const skillPercentageBefore = parseInt(skillProgressBar.style.width, 10) + (parseInt(skillProgressBar.style.left, 10) ?? -100);
            currentSkillLevel = skillLevelBefore + skillPercentageBefore / 100;
        } else {
            currentSkillLevel = 100;
        }
        return parseFloat(currentSkillLevel.toFixed(2));
    }

    function getCurrentShillBalance() {
        try {
            const hirelingsDiv = document.querySelectorAll('div.crimeOptionGroup___gQ6rI')[2];
            return hirelingsDiv.querySelector('div.crime-option:nth-child(1) > div.sections___tZPkg > div.crimeOptionSection___hslpu.mainSection___STO9B').textContent;
        } catch (e) {
            console.error('[TornLogHustling] Error while parsing shill balance', e);
            return "Can't parse";
        }
    }

    function getCurrentPickpocketBalance() {
        try {
            const hirelingsDiv = document.querySelectorAll('div.crimeOptionGroup___gQ6rI')[2];
            return hirelingsDiv.querySelector('div.crime-option:nth-child(2) > div.sections___tZPkg > div.crimeOptionSection___hslpu.mainSection___STO9B').textContent;
        } catch (e) {
            console.error('[TornLogHustling] Error while parsing pickpocket balance', e);
            return "Can't parse";
        }
    }

    function getCurrentTechSkill(element) {
        console.log('getCurrentTechSkill', element);
        let techSkill = '';
        const techSkillBar = element.querySelector('div.techniqueBar___JaXl6');
        try {
            const computedStyle = getComputedStyle(techSkillBar);
            techSkill = computedStyle.getPropertyValue('--progress');
        } catch (e) {
            console.error('[TornLogHustling] Error while parsing tech skill level', e);
            techSkill = "Can't parse";
        }
        return techSkill;
    }

    function findNearestButtonParent(element) {
        let currentElement = element;
        while (currentElement && currentElement.tagName !== 'BUTTON') {
            currentElement = currentElement.parentNode;
        }
        return currentElement;
    }

    function getTextValueWithoutChildren(element) {
        let text = '';
        for (let node of element.childNodes) {
            if (node.nodeType === Node.TEXT_NODE) {
                text += node.textContent;
            }
        }
        return text.trim();
    }

    function generateGuid() {
        const crypto = window.crypto || window.msCrypto;

        if (!crypto) {
            return Math.floor(Math.random() * (999999 - 100000) + 100000).toString();
        }

        const data = new Uint16Array(8);
        crypto.getRandomValues(data);

        data[3] = (data[3] & 0x0fff) | 0x4000; // Version 4
        data[4] = (data[4] & 0x3fff) | 0x8000; // Variant 10

        const guid = Array.from(data)
        .map((byte) => byte.toString(16).padStart(2, '0'))
        .join('');

        return (
            guid.substr(0, 8) + '-' +
            guid.substr(8, 4) + '-' +
            guid.substr(12, 4) + '-' +
            guid.substr(16, 4) + '-' +
            guid.substr(20, 12)
        );
    }

    function generateNumberRange(start, end) {
        return Array.from({ length: end - start + 1 }, (_, index) => start + index);
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    class HustleAttempt {
        constructor(playerId, date, time, action, game, skillBefore, skillAfter, techLevel, techSkillBefore, techSkillAfter, audienceBefore, audienceAfter, target, shillBalanceBefore, shillBalanceAfter, pickpocketBalanceBefore, pickpocketBalanceAfter, result, gained, progressionBonus, version) {
            this.playerId = playerId;
            this.date = date;
            this.time = time;
            this.action = action;
            this.game = game;
            this.skillBefore = skillBefore;
            this.skillAfter = skillAfter;
            this.audienceBefore = audienceBefore;
            this.audienceAfter = audienceAfter;
            this.techLevel = techLevel;
            this.techSkillBefore = techSkillBefore;
            this.techSkillAfter = techSkillAfter;
            this.target = target;
            this.shillBalanceBefore = shillBalanceBefore;
            this.shillBalanceAfter = shillBalanceAfter;
            this.pickpocketBalanceBefore = pickpocketBalanceBefore;
            this.pickpocketBalanceAfter = pickpocketBalanceAfter;
            this.result = result;
            this.gained = gained;
            this.progressionBonus = progressionBonus;
            this.version = version;
        }
    }
})();