Torn Stats Faction CPR Tracker

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

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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