OutcomeDB

Captures crime outcome, skill gain and target data for analysis

// ==UserScript==
// @name         OutcomeDB
// @namespace    de.sabbasofa.outcomedb
// @version      2.0.8
// @description  Captures crime outcome, skill gain and target data for analysis
// @author       Hemicopter [2780600], Lazerpent [2112641]
// @match        https://www.torn.com/loader.php?sid=crimes*
// @match        https://torn.com/loader.php?sid=crimes*
// @grant        GM_xmlhttpRequest
// @connect      api.lzpt.io

// ==/UserScript==

(function () {
    'use strict';

    const isTampermonkeyEnabled = typeof unsafeWindow !== 'undefined';
    const win = isTampermonkeyEnabled ? unsafeWindow : window;
    const {fetch: originalFetch} = win;
    let currentCrimesByTypeData;
    let serverTime = Math.floor(Date.now() / 1000);

    win.fetch = async (...args) => {
        let [resource, config] = args;
        return originalFetch(resource, config).then(response => detectCrimeDataRequest(resource, response));
    };
    console.log("[OutcomeDB] Watching for crime.");

    function detectCrimeDataRequest(resource, response) {
        if(!(resource.includes("sid=crimesData"))) return response;
        if (resource.includes("step=attempt")) response.clone().text().then(body => handleCrimeAttempt(body, resource));
        if (resource.includes("step=crimesList")) response.clone().text().then(body => handleCrimesList(body, resource));
        return response;
    }

    function handleCrimeAttempt(body, resource) {
        console.log("[OutcomeDB] Found crime attempt.");
        console.log("[OutcomeDB] url:", resource);

        //Most likely cloudflare turnstyle or server error
        if(containsHtml(body)) {
            console.error("[OutcomeDB] Unexpected HTML data, skipping...");
            return;
        }

        try {
            let data = JSON.parse(body);
            if (data.error) {
                console.log("[OutcomeDB] Failed crime attempt: " + data.error);
                console.log(JSON.stringify(data));
                return;
            }
            if (!(data.DB && data.DB.outcome)) return;
            if(data.DB.outcome.result === "error") {
                console.log("[OutcomeDB] Failed crime attempt.");
                console.log(JSON.stringify(data));
                return;
            }
            console.log("[OutcomeDB] Found outcome.");
            console.log("[OutcomeDB] Preparing bundle.");
            serverTime = data.DB.time;
            let bundle = {};
            bundle.outcome = data.DB.outcome;
            bundle.typeID = resource.split("typeID=")[1].split("&")[0];
            bundle.crimeID = resource.split("crimeID=")[1].split("&")[0];

            bundle.skillBefore = getStat("Skill");
            bundle.skillAfter = data.DB.currentUserStatistics[0].value;
            bundle.progressionBonus = getStat("Progression bonus");
            bundle.additionalData = getAdditionalData(data.DB.crimesByType, bundle.typeID, resource);
            if(!bundle.skillBefore || !bundle.skillAfter) {
                console.error("[OutcomeDB] Could not find skill data, skipping...");
                return;
            }

            console.log("[OutcomeDB] Ready to send bundle.", JSON.stringify(bundle));
            sendBundleToAPI(bundle);
            currentCrimesByTypeData = data.DB.crimesByType;

        } catch (e) {
            localStorage.setItem("outcomedb_last_error", JSON.stringify({error: {message: e.message, stack: e.stack}, body: body}));
            console.error("[OutcomeDB] Error parsing data:", body, e);
            if(e instanceof SyntaxError) return;
            alert("OutcomeDB error attempt_parse. Please check console and report this to Lazerpent [2112641] or Hemicopter [2780600].");
        }
    }

    function handleCrimesList(body, resource) {
        console.log("[OutcomeDB] Updating crimes data.");

        //Most likely cloudflare turnstyle or server error
        if(containsHtml(body)) {
            console.error("[OutcomeDB] Unexpected HTML data, skipping...");
            return;
        }

        try {
            let data = JSON.parse(body);
            if (data.error) {
                console.log("[OutcomeDB] Failed crimesList: " + data.error);
                console.log(JSON.stringify(data));
                return;
            }
            if (!(data.DB && data.DB.crimesByType)) return;
            currentCrimesByTypeData = data.DB.crimesByType;
            serverTime = data.DB.time;

        } catch (e) {
            localStorage.setItem("outcomedb_last_error", JSON.stringify({error: {message: e.message, stack: e.stack}, body: body}));
            console.error("[OutcomeDB] Error parsing data:", body, e);
            if(e instanceof SyntaxError) return;
            alert("OutcomeDB error list_parse. Please check console and report this to Lazerpent [2112641] or Hemicopter [2780600].");
        }
    }

    function sendBundleToAPI(bundle) {
        GM_xmlhttpRequest({
            method: "POST",
            url: "https://api.lzpt.io/outcomedb",
            headers: {"Content-Type": "application/json"},
            data: JSON.stringify(bundle),
            onload: function (response) {
                if(!response) return; // because pda doesn't know how to network i guess
                if(containsHtml(response.responseText)) {
                    console.error("[OutcomeDB] lzpt rate limit hit, skipping...");
                    return;
                }
                console.log("[OutcomeDB] Bundle successfully sent to API:", response.responseText);

                const json = JSON.parse(response.responseText);
                if (json.error) {
                    alert("OutcomeDB error post_invalid. Please check console and report this to Lazerpent [2112641] or Hemicopter [2780600]: " + response.responseText);
                }
            },
            onerror: function (e) {
                localStorage.setItem("outcomedb_last_error", JSON.stringify({error: {message: e.message, stack: e.stack}}));
                console.error("[OutcomeDB] Error sending bundle to API:", e);
                alert("OutcomeDB error post_error. Please check console and report this to Lazerpent [2112641] or Hemicopter [2780600]: " + e);
            }
        });
    }

    function getStat(name) {
        let allStatisticButtons = Array.from(win.document.querySelectorAll('button[class^="statistic___"]'));

        let statButton = allStatisticButtons.find(button => {
            return Array.from(button.querySelectorAll('span')).some(span => span.textContent.trim() === name);
        });

        if (statButton) {
            let valueSpan = statButton.querySelector('span[class^="value___"]');
            if (valueSpan) {
                console.log(`[OutcomeDB] Found stat (${name}): '${valueSpan.textContent}'`);
                return valueSpan.textContent;
            }
        }
        console.error(`[OutcomeDB] Could not find stat ${name}`);
    }

    function getAdditionalData(attemptData, typeID, resource) {
        try {
            if(typeID === "12") return extractScammingData(attemptData, resource);
            return null;

        } catch(error) {
            console.error("[OutcomeDB] Additional data failed, skipping:", error);
            return null;
        }
    }

    function extractScammingData(attemptData, resource) {

        //However Laz did that
        if(!currentCrimesByTypeData) return null;

        //Is it linked to a target?
        if(!(resource.includes("value1"))) return null;
        let subID = resource.split("value1=")[1].split("&")[0];

        console.log("[OutcomeDB] Extracting additional scamming data.");

        //Get target states
        let beforeTargetState = currentCrimesByTypeData.targets.find((target) => {return target.subID.includes(subID);});
        let afterTargetState = attemptData.targets.find((target) => {return target.subID.includes(subID);});

        let additionalData = {};

        //target information
        additionalData.gender = beforeTargetState.gender? beforeTargetState.gender : afterTargetState.gender;
        additionalData.target = beforeTargetState.target? beforeTargetState.target : afterTargetState.target;

        //action information
        if(!beforeTargetState.bar) additionalData.action = "read";
        else if(!afterTargetState) additionalData.action = "capitalize";
        else additionalData.action = afterTargetState.lastAction;

        const transformBar = (bar) => {
            if (!bar) return null;
            const stateMapping = {
                "neutral": "n",
                "fail": "f",
                "low": "l",
                "medium": "m",
                "high": "h",
                "sensitivity": "s",
                "temptation": "t",
                "hesitation": "w",
                "concern": "c"
            };
            return bar.map(state => stateMapping[state] || '?').join('');
        };

        if (beforeTargetState) {
            additionalData.targetBefore = {
                "multiplierUsed": beforeTargetState.multiplierUsed,
                "pip": beforeTargetState.pip,
                "turns": beforeTargetState.turns,
                "bar": transformBar(beforeTargetState.bar)
            };
        }

        if (afterTargetState) {
            additionalData.targetAfter = {
                "multiplierUsed": afterTargetState.multiplierUsed,
                "pip": afterTargetState.pip,
                "turns": afterTargetState.turns,
                "bar": transformBar(afterTargetState.bar)
            };
            additionalData.cooldown = afterTargetState.cooldown ? Math.floor(afterTargetState.cooldown - serverTime) : null;
        }

        //targetAfter is missing on capitalize
        if(!additionalData.targetAfter) additionalData.targetAfter = {};

        console.log("[OutcomeDB] Additional data gathered.");
        return additionalData;
    }

    function containsHtml(text) {
        return text.includes("!DOCTYPE") || text.includes("!doctype") || text.includes("<html") || text.includes("<head") || text.includes("<body");
    }
})();