OC Role Display - PERK_Ryan Edition

Dynamically numbers duplicate OC roles based on slot order, shows priority of roles, and stores your CPR for each crime/role

// ==UserScript==
// @name         OC Role Display - PERK_Ryan Edition
// @namespace    http://tampermonkey.net/
// @version      1.5.0
// @description  Dynamically numbers duplicate OC roles based on slot order, shows priority of roles, and stores your CPR for each crime/role
// @author       PERK_Ryan (made from Allenone and NotIbbyz work)
// @match        https://www.torn.com/factions.php?step=your*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        GM_info
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @license      MIT
// @connect      tornprobability.com
// ==/UserScript==

//OC Role Display - PERK_Ryan Edition
(async function() {
    'use strict';
 
    // Inject pulse animations
    const style = document.createElement('style');
    style.innerHTML = `
    @keyframes pulseRed {
        0% { box-shadow: 0 0 8px red; }
        50% { box-shadow: 0 0 18px red; }
        100% { box-shadow: 0 0 8px red; }
    }
    .pulse-border-red {
        animation: pulseRed 1s infinite;
    }
    `;
    document.head.appendChild(style);
 
    const defaultCPR1 = 70;
    const defaultCPR2 = 70;
    const defaultCPR3 = 70;

    const ocRoles = [
        {
            OCName: "Ace in the Hole",
            Positions: {
                "IMPERSONATOR": 70,
                "MUSCLE 1": 70,
                "MUSCLE 2": 70,
                "HACKER": 70,
                "DRIVER": 70
            },
            PositionPriority: {
                   "IMPERSONATOR": "P5",
                   "MUSCLE 1": "P3",
                   "MUSCLE 2": "P4",
                   "HACKER": "P1",
                   "DRIVER": "P2"
               }
        },
        {
            OCName: "Stacking the Deck",
            Positions: {
                "CAT BURGLAR": 70,
                "DRIVER": 70,
                "HACKER": 70,
                "IMPERSONATOR": 70
            },
            PositionPriority: {
                   "CAT BURGLAR": "P2",
                   "DRIVER": "P4",
                   "HACKER": "P3",
                   "IMPERSONATOR": "P1"
               }
        },
        {
            OCName: "Break the Bank",
            Positions: {
                "ROBBER": 60,
                "MUSCLE 1": 60,
                "MUSCLE 2": 60,
                "THIEF 1": 60,
                "MUSCLE 3": 60,
                "THIEF 2": 60
            },
            PositionPriority: {
                  "ROBBER": "P3",
                  "MUSCLE 1": "P4",
                  "MUSCLE 2": "P5",
                  "THIEF 1": "P6",
                  "MUSCLE 3": "P1",
                  "THIEF 2": "P2"
              }
        },
        {
            OCName: "Blast from the Past",
            Positions: {
                "PICKLOCK 1": 50,
                "HACKER": 50,
                "ENGINEER": 70,
                "BOMBER": 70,
                "MUSCLE": 70,
                "PICKLOCK 2": 40
            },
            PositionPriority: {
                "PICKLOCK 1": "P4",
                "HACKER": "P5",
                "ENGINEER": "P2",
                "BOMBER": "P3",
                "MUSCLE": "P1",
                "PICKLOCK 2": "P6"
            }
        },
        {
            OCName: "Bidding War",
            Positions: {
                "ROBBER 1": 60,
                "DRIVER": 60,
                "ROBBER 2": 60,
                "ROBBER 3": 70,
                "BOMBER 1": 60,
                "BOMBER 2": 60
            },
            PositionPriority: {
                "ROBBER 1": "P6",
                "DRIVER": "P3",
                "ROBBER 2": "P2",
                "ROBBER 3": "P1",
                "BOMBER 1": "P5",
                "BOMBER 2": "P4"
            }
        },
        { 
            OCName: "Honey Trap", 
            Positions: {
                "ENFORCER": 40,
                "MUSCLE 1": 60,
                "MUSCLE 2": 70
            },
            PositionPriority: {
                "ENFORCER": "P3",
                "MUSCLE 1": "P2",
                "MUSCLE 2": "P1"
            }
        },
        {
            OCName: "No Reserve",
            Positions: {
                "CAR THIEF": 60,
                "TECHIE": 70,
                "ENGINEER": 60
            },
            PositionPriority: {
                "CAR THIEF": "P3",
                "TECHIE": "P1",
                "ENGINEER": "P2"
            }
        },
        { 
            OCName: "Leave No Trace", 
            Positions: {
                "TECHIE": 60,
                "NEGOTIATOR": 60,
                "IMPERSONATOR": 60
            },
            PositionPriority: {
                "TECHIE": "P3",
                "NEGOTIATOR": "P2",
                "IMPERSONATOR": "P1"
            }
        },
        { 
            OCName: "Stage Fright", 
            Positions: {
                "ENFORCER": 60,
                "MUSCLE 1": 60,
                "MUSCLE 2": 60,
                "MUSCLE 3": 60,
                "LOOKOUT": 60,
                "SNIPER": 70
            },
            PositionPriority: {
                "ENFORCER": "P3",
                "MUSCLE 1": "P2",
                "MUSCLE 2": "P6",
                "MUSCLE 3": "P4",
                "LOOKOUT": "P5",
                "SNIPER": "P1"
            }
        },
        { 
            OCName: "Snow Blind", 
            Positions: {
                "HUSTLER": 60,
                "IMPERSONATOR": 60,
                "MUSCLE 1": 60,
                "MUSCLE 2": 60
            },
            PositionPriority: {
                   "HUSTLER": "P1",
                   "IMPERSONATOR": "P2",
                   "MUSCLE 1": "P3",
                   "MUSCLE 2": "P4"
               }
        },
        { 
            OCName: "Smoke and Wing Mirrors", 
            Positions: {
                "CAR THIEF": 70,
                "IMPERSONATOR": 70,
                "HUSTLER 1": 50,
                "HUSTLER 2": 50
            },
            PositionPriority: {
                "CAR THIEF": "P1",
                "IMPERSONATOR": "P2",
                "HUSTLER 1": "P4",
                "HUSTLER 2": "P3"
            }
        },
        { 
            OCName: "Market Forces",
            Positions: {
                "ENFORCER": 60,
                "NEGOTIATOR": 60,
                "LOOKOUT": 60,
                "ARSONIST": 60,
                "MUSCLE": 60
            },
            PositionPriority: {
                "ENFORCER": "P1",
                "NEGOTIATOR": "P2",
                "LOOKOUT": "P4",
                "ARSONIST": "P5",
                "MUSCLE": "P3"
            }
        },
        { 
            OCName: "Best of the Lot", 
            Positions: {
                "PICKLOCK": 60,
                "CAR THIEF": 60,
                "MUSCLE": 60,
                "IMPERSONATOR": 60
            },
            PositionPriority: {
                "PICKLOCK": "P2",
                "CAR THIEF": "P3",
                "MUSCLE": "P1",
                "IMPERSONATOR": "P4"
            }
        },
        { 
            OCName: "Cash Me if You Can",
            Positions: {
                "THIEF 1": 60,
                "THIEF 2": 60,
                "LOOKOUT": 60
            },
            PositionPriority: {
                "THIEF 1": "P1",
                "THIEF 2": "P3",
                "LOOKOUT": "P2"
            }
        },
        { 
            OCName: "Mob Mentality", 
            Positions: {
                "LOOTER 1": 10,
                "LOOTER 2": 10,
                "LOOTER 3": 10,
                "LOOTER 4": 10
            },
            PositionPriority: {
                "LOOTER 1": "P1",
                "LOOTER 2": "P2",
                "LOOTER 3": "P4",
                "LOOTER 4": "P3"
            }
        },
        { 
            OCName: "Pet Project",
            Positions: {
                "KIDNAPPER": 10,
                "MUSCLE": 10,
                "PICKLOCK": 10
            },
            PositionPriority: {
                "KIDNAPPER": "P1",
                "MUSCLE": "P3",
                "PICKLOCK": "P2"
            }
        }
    ];
 
    const roleMappings = {};

   function processScenario(panel) {
     if (panel.classList.contains('role-processed')) return;
     panel.classList.add('role-processed');
 
     const ocName = panel.querySelector('.panelTitle___aoGuV')?.innerText.trim() || "Unknown";
     const slots = panel.querySelectorAll('.wrapper___Lpz_D');
 
     if (!roleMappings[ocName]) {
       const slotsWithPos = Array.from(slots).map(slot => {
         const fiberKey = Object.keys(slot).find(k => k.startsWith('__reactFiber$'));
         if (!fiberKey) return null;
         const fiberNode = slot[fiberKey];
         const slotKey = fiberNode.return.key.replace('slot-', '');
         const posNum = parseInt(slotKey.match(/P(\d+)/)?.[1] || '0', 10);
         return { slot, positionNumber: posNum };
       }).filter(Boolean);
 
       slotsWithPos.sort((a, b) => a.positionNumber - b.positionNumber);
 
       const originalNames = slotsWithPos.map(({ slot }) =>
         slot.querySelector('.title___UqFNy')?.innerText.trim() || 'Unknown'
       );
 
       const freq = {};
       originalNames.forEach(name => {
         freq[name] = (freq[name] || 0) + 1;
       });
 
       const finalNames = [];
       const counter = {};
       originalNames.forEach(name => {
         if (freq[name] > 1) {
           counter[name] = (counter[name] || 0) + 1;
           finalNames.push(`${name} ${counter[name]}`);
         } else {
           finalNames.push(name);
         }
       });
 
       roleMappings[ocName] = finalNames;
     }
 
     Array.from(slots).forEach(slot => {
       const fiberKey = Object.keys(slot).find(k => k.startsWith('__reactFiber$'));
       if (!fiberKey) return;
       const fiberNode = slot[fiberKey];
       const positionKey = fiberNode.return.key.replace('slot-', '');
       const posNum = parseInt(positionKey.match(/P(\d+)/)?.[1] || '0', 10);
       const roleIndex = posNum - 1;
 
       const finalName = roleMappings[ocName][roleIndex];
       const successStr = slot.querySelector('.successChance___ddHsR')?.textContent.trim() || '';
       const successChance = parseInt(successStr, 10) || 0;
       const joinBtn = slot.querySelector("button[class^='torn-btn joinButton']");
 
       const ocData = ocRoles.find(o => o.OCName === ocName);
       let required = null;
       let priorityPrefix = '';
 
       if (ocData) {
         if (typeof ocData.Positions === 'object') {
           const baseName = finalName.replace(/\s\d+$/, '');
           if (ocData.Positions[finalName] !== undefined) {
             required = ocData.Positions[finalName];
           } else if (ocData.Positions[baseName] !== undefined) {
             required = ocData.Positions[baseName];
           }
 
           if (ocData.PositionPriority && ocData.PositionPriority[finalName]) {
             priorityPrefix = `${ocData.PositionPriority[finalName]} `;
           }
         }
       }
 
       if (required !== null) {
         const honorTextSpans = slot.querySelectorAll('.honor-text');
         const userName = (honorTextSpans.length > 1) ? honorTextSpans[1].textContent.trim() : null;
 
         if (!userName) {
           slot.style.backgroundColor =
             (successChance < required) ? '#ff000061' : '#21a61c61';
         }
 
         if (joinBtn) {
           if (successChance < required) {
             joinBtn.setAttribute('disabled', '');
           }
         }
 
         if (userName && successChance < required) {
           slot.classList.add('pulse-border-red');
           slot.style.outline = '4px solid red';
           slot.style.outlineOffset = '0px';
         }
       }
 
       const roleElem = slot.querySelector('.title___UqFNy');
       if (finalName && roleElem) {
         const priority = ocData?.PositionPriority?.[finalName] || 'idk';
         const updatedName = `${priority ? priority + ' ' : ''}${finalName}`;
         roleElem.innerText = updatedName;
       }
     });
   }
 
   const observer = new MutationObserver(mutations => {
     mutations.forEach(mutation => {
       if (mutation.addedNodes.length) {
         mutation.addedNodes.forEach(node => {
           if (node.nodeType === 1 && node.matches('.wrapper___U2Ap7')) {
             processScenario(node);
           }
           if (node.nodeType === 1) {
             const innerPanels = node.querySelectorAll?.('.wrapper___U2Ap7') || [];
             innerPanels.forEach(inner => processScenario(inner));
           }
         });
       }
     });
   });
 
   const targetNode = document.querySelector('#factionCrimes-root') || document.body;
   observer.observe(targetNode, {
     childList: true,
     subtree: true
   });
 
   window.addEventListener('load', () => {
     document.querySelectorAll('.wrapper___U2Ap7').forEach(panel => {
       processScenario(panel);
     });
   });
 
 })();


/*  Faction CPR Tracker  */
(function() {
    'use strict';
 
    const API_ENDPOINT = 'https://tornprobability.com:3000/api/SubmitCPR';
    const TARGET_URL_BASE = 'page.php?sid=organizedCrimesData&step=crimeList';
    const STORAGE_PREFIX = 'FactionCPRTracker_';
    const isTornPDA = typeof window.flutter_inappwebview !== 'undefined';
 
    // Storage functions
    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;
 
    // 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 = "###PDA-APIKEY###"; // Hardcoded for TornPDA Change this manually if you want to use a different 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 (Public Access):');
            if (!API_KEY) {
                alert('Faction CPR Tracker: API key is required for functionality.');
                return;
            }
            setValue(`${STORAGE_PREFIX}api_key`, API_KEY);
            if (API_KEY) {
                submitCPRData(API_KEY, getValue(`${STORAGE_PREFIX}CheckpointPassRates`, {}));
            }
        }
    }
 
    async function submitCPRData(apiKey, checkpointPassRates) {
        return new Promise((resolve, reject) => {
            xmlhttpRequest({
                method: 'POST',
                url: API_ENDPOINT,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify({ apiKey, checkpointPassRates }),
                onload: (response) => {
                    if (response.status >= 200 && response.status < 300) {
                        console.log('CPR data submitted successfully');
                        resolve();
                    } else {
                        console.error('API error:', response.status, response.responseText);
                        reject(new Error('API error'));
                    }
                },
                onerror: (err) => {
                    console.error('Submission error:', err);
                    reject(err);
                }
            });
        });
    }
 
    function processCPRs(data) {
        const scenarioName = data.scenario.name;
        let CheckpointPassRates = getValue(`${STORAGE_PREFIX}CheckpointPassRates`, {});
 
        if (!CheckpointPassRates[scenarioName]) {
            CheckpointPassRates[scenarioName] = {};
            data.playerSlots.forEach(slot => {
                CheckpointPassRates[scenarioName][slot.name] = slot.player === null ? slot.successChance : 0;
            });
        } else {
            data.playerSlots.forEach(slot => {
                if (slot.player === null) {
                    CheckpointPassRates[scenarioName][slot.name] = slot.successChance;
                }
            });
        }
 
        setValue(`${STORAGE_PREFIX}CheckpointPassRates`, CheckpointPassRates);
    }
 
    // 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) {
                json.data.forEach(processCPRs);
                const API_KEY = getValue(`${STORAGE_PREFIX}api_key`, null);
                if (API_KEY) {
                    submitCPRData(API_KEY, getValue(`${STORAGE_PREFIX}CheckpointPassRates`, {}));
                }
            }
        } catch (err) {
            console.error('Error processing response:', err);
        }
        return response;
    };
})();