Torn Stats Faction CPR Tracker

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

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==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;
    };
})();