Torn Stats Faction CPR Tracker

Tracks CPR data for organized crimes by intercepting Fetch requests, compatible with TornPDA and PC

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Torn Stats Faction CPR Tracker
// @namespace    http://tampermonkey.net/
// @version      1.4.0
// @description  Tracks CPR data for organized crimes by intercepting Fetch requests, compatible with TornPDA and PC
// @author       Allenone[2033011], IceBlueFire[776]
// @license      MIT
// @match        https://www.torn.com/factions.php?step=your*
// @run-at       document-idle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @connect      tornstats.com
// ==/UserScript==

(function() {
    'use strict';

    const TARGET_URL_BASE = 'page.php?sid=organizedCrimesData&step=crimeList';
    const STORAGE_PREFIX = 'TSFactionCPRTracker_';
    const isTornPDA = typeof window.flutter_inappwebview !== 'undefined';

    // Storage functions (only used for API key now)
    const getValue = isTornPDA
        ? (key, def) => JSON.parse(localStorage.getItem(key) || JSON.stringify(def))
        : GM_getValue;
    const setValue = isTornPDA
        ? (key, value) => localStorage.setItem(key, JSON.stringify(value))
        : GM_setValue;
    const deleteValue = isTornPDA
        ? (key) => localStorage.removeItem(key)
        : GM_deleteValue;

    // HTTP request function
    const xmlhttpRequest = isTornPDA
        ? (details) => {
            window.flutter_inappwebview.callHandler('PDA_httpPost', details.url, details.headers, details.data)
                .then(response => {
                    details.onload({
                        status: response.status,
                        responseText: response.data
                    });
                })
                .catch(err => details.onerror(err));
        }
        : GM_xmlhttpRequest;

    // API key handling
    let API_KEY;
    if (isTornPDA) {
        API_KEY = "#############"; // Hardcoded for TornPDA. Set this to your Torn Stats API key.
        setValue(`${STORAGE_PREFIX}api_key`, API_KEY);
    } else {
        API_KEY = getValue(`${STORAGE_PREFIX}api_key`, null);
        if (!API_KEY) {
            API_KEY = prompt('Please enter your Torn API key that you use with Torn Stats:');
            if (!API_KEY) {
                alert('Faction CPR Tracker: API key is required for functionality.');
                return;
            }
            setValue(`${STORAGE_PREFIX}api_key`, API_KEY);
        }
    }

    // Queue to store data captured while page was hidden
    let pendingSubmission = null;

    async function submitCPRData(apiKey, checkpointPassRates) {
        // Only submit if page is visible
        if (document.visibilityState === 'hidden') {
            console.log('Page hidden, queuing CPR data for later submission');
            pendingSubmission = { apiKey, checkpointPassRates };
            return;
        }

        return new Promise((resolve, reject) => {
            xmlhttpRequest({
                method: 'POST',
                url: 'https://www.tornstats.com/api/v2/' + apiKey + '/crime_pass_rates/store',
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify(checkpointPassRates),
                onload: (response) => {
                    try {
                        const jsonResponse = JSON.parse(response.responseText);
                        console.log('Response JSON:', jsonResponse);

                        // Check for user-not-found error
                        if (jsonResponse.status === false && jsonResponse.message === 'ERROR: User not found.') {
                            console.warn('Invalid API key detected. Clearing stored key...');
                            deleteValue(`${STORAGE_PREFIX}api_key`);
                            showPopup('Error: API key invalid. Prompted for new key.', 'error');

                            const newKey = prompt("Your API key appears to be invalid or expired. Please enter a new Torn Stats API key:");
                            if (newKey) {
                                setValue(`${STORAGE_PREFIX}api_key`, newKey);
                                console.log('New API key saved. Please retry the action.');
                            }

                            reject(new Error('Invalid API key.'));
                            return;
                        }

                        if (jsonResponse.status === true && jsonResponse.successes) {
                            const changes = [];
                            let recordedCount = 0;

                            for (const crime in jsonResponse.successes) {
                                const roles = jsonResponse.successes[crime];
                                for (const role in roles) {
                                    const serverMessage = roles[role];

                                    if (serverMessage.includes('increased')) {
                                        changes.push(`${crime} - ${role}: Increased!`);
                                    } else if (serverMessage.includes('decreased')) {
                                        changes.push(`${crime} - ${role}: Decreased`);
                                    } else {
                                        recordedCount++;
                                    }
                                }
                            }

                            let popupMessage = 'CPR data submitted.';
                            let popupType = 'info';

                            if (changes.length > 0) {
                                popupMessage = `CPR Changes Detected:\n${changes.join('\n')}`;
                                popupType = 'success';
                            } else if (recordedCount > 0) {
                                popupMessage = `CPR data recorded. No changes detected.\n(${recordedCount} positions updated)`;
                            }

                            if (jsonResponse.errors && Object.keys(jsonResponse.errors).length > 0) {
                                const errorCount = Object.values(jsonResponse.errors)
                                    .reduce((sum, obj) => sum + (typeof obj === 'object' ? Object.keys(obj).length : 1), 0);
                                popupMessage += `\n\n${errorCount} position(s) not found.`;
                            }

                            showPopup(popupMessage, popupType);
                            resolve();
                            return;
                        }
                    } catch (e) {
                        console.error('Could not parse JSON:', e);
                    }
                    console.error('API error:', response.status, response.responseText);
                    showPopup('CPR submission failed.', 'error');
                    reject(new Error('API error'));
                },
                onerror: (err) => {
                    console.error('Submission error:', err);
                    showPopup('CPR submission failed (network error).', 'error');
                    reject(err);
                }
            });
        });
    }

    // Handle visibility change - submit pending data when page becomes visible
    document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'visible' && pendingSubmission) {
            console.log('Page visible, submitting queued CPR data');
            const { apiKey, checkpointPassRates } = pendingSubmission;
            pendingSubmission = null;
            submitCPRData(apiKey, checkpointPassRates);
        }
    });

    function processCPRs(data, checkpointPassRates) {
        const scenarioName = String(data.scenario.name);

        if (!checkpointPassRates[scenarioName]) {
            checkpointPassRates[scenarioName] = {};
        }

        // Only capture from EMPTY slots (slot.player === null)
        // Filled slots show the other player's CPR, not yours
        data.playerSlots.forEach(slot => {
            const slotName = String(slot.name);
            if (slot.player === null && slot.successChance > 0) {
                checkpointPassRates[scenarioName][slotName] = slot.successChance;
            }
        });

        return checkpointPassRates;
    }

    function showPopup(message, type = 'info') {
        const popup = document.createElement('div');
        popup.innerHTML = message.replace(/\n/g, '<br>');

        popup.style.position = 'fixed';
        popup.style.top = '80px';
        popup.style.right = '20px';
        popup.style.zIndex = 9999;
        popup.style.padding = '10px 15px';
        popup.style.borderRadius = '5px';
        popup.style.fontFamily = 'Verdana, sans-serif';
        popup.style.fontSize = '13px';
        popup.style.fontWeight = 'bold';
        popup.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.6)';
        popup.style.opacity = '1';
        popup.style.transition = 'opacity 1s ease';
        popup.style.maxWidth = '350px';

        switch (type) {
            case 'error':
                popup.style.backgroundColor = '#2b1b1b';
                popup.style.color = '#f14c4c';
                break;
            case 'success':
                popup.style.backgroundColor = '#1d2b1d';
                popup.style.color = '#70db70';
                break;
            case 'info':
            default:
                popup.style.backgroundColor = '#182432';
                popup.style.color = '#5ec8f2';
                break;
        }

        document.body.appendChild(popup);

        setTimeout(() => {
            popup.style.opacity = '0';
            setTimeout(() => popup.remove(), 1000);
        }, 5000);
    }

    // Fetch Interception
    const win = isTornPDA ? window : (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);
    const originalFetch = win.fetch;
    win.fetch = async function(resource, config) {
        const url = typeof resource === 'string' ? resource : resource.url;
        if (config?.method?.toUpperCase() !== 'POST' || !url.includes(TARGET_URL_BASE)) {
            return originalFetch.apply(this, arguments);
        }

        let isRecruitingGroup = false;
        if (config?.body instanceof FormData) {
            isRecruitingGroup = config.body.get('group') === 'Recruiting';
        } else if (config?.body) {
            isRecruitingGroup = config.body.toString().includes('group=Recruiting');
        }

        if (!isRecruitingGroup) {
            return originalFetch.apply(this, arguments);
        }

        const response = await originalFetch.apply(this, arguments);
        try {
            const json = JSON.parse(await response.clone().text());
            if (json.success && json.data && json.data.length > 1) {
                // Build fresh data from current game state - no caching
                let checkpointPassRates = {};
                json.data.forEach(crimeData => {
                    checkpointPassRates = processCPRs(crimeData, checkpointPassRates);
                });

                const storedApiKey = getValue(`${STORAGE_PREFIX}api_key`, null);
                if (storedApiKey) {
                    submitCPRData(storedApiKey, checkpointPassRates);
                }
            }
        } catch (err) {
            console.error('Error processing response:', err);
        }
        return response;
    };
})();