Torn Loadout Switcher

Adds customisable quick loadout change buttons on Items page.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Torn Loadout Switcher
// @namespace    https://github.com/SOLiNARY
// @version      0.6.10
// @description  Adds customisable quick loadout change buttons on Items page.
// @author       Ramin Quluzade, Silmaril [2665762]
// @license      MIT
// @match        https://www.torn.com/item.php*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        unsafeWindow
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

(async function () {
    'use strict';

    // Change to 'false' to see only numbers, 'true' to see titles
    const showTitles = true;

    const includeLogo = false;
    const rfcvArg = "rfcv=";
    const isTampermonkeyEnabled = typeof unsafeWindow !== 'undefined';
    const getEquippedItemsUrl = "/page.php?sid=itemsLoadouts&step=getEquippedItems";
    let rfcv = localStorage.getItem("silmaril-loadout-switcher-rfcv") ?? null;
    let rfcvUpdatedThisSession = false;
    let loadoutTitles = {};
    try {
        const cachedTitles = localStorage.getItem("silmaril-loadout-switcher-titles");
        if (cachedTitles) loadoutTitles = JSON.parse(cachedTitles);
    } catch (e) {
        loadoutTitles = {};
    }

    // Capture rfcv via PerformanceObserver instead of monkey-patching fetch.
    // Works in both the page world (Tampermonkey/Violentmonkey) and the
    // userscript-isolated world (iOS Safari Userscripts app), because
    // performance entries are origin-scoped and visible to any same-origin script.
    function captureRfcvFromUrl(url) {
        if (rfcvUpdatedThisSession) return;
        if (typeof url !== 'string') return;
        const idx = url.indexOf(rfcvArg);
        if (idx < 0) return;
        rfcv = url.substring(idx + rfcvArg.length).split('&')[0];
        localStorage.setItem("silmaril-loadout-switcher-rfcv", rfcv);
        document.querySelectorAll("div.silmaril-torn-loadout-switcher-container button")
            .forEach((button) => button.classList.remove("disabled"));
        rfcvUpdatedThisSession = true;
    }

    try {
        performance.getEntriesByType('resource').forEach((entry) => captureRfcvFromUrl(entry.name));
        new PerformanceObserver((list) => {
            for (const entry of list.getEntries()) captureRfcvFromUrl(entry.name);
        }).observe({ type: 'resource', buffered: true });
    } catch (e) {
        console.warn("[TornLoadoutSwitcher] PerformanceObserver unavailable:", e);
    }

    let titlesReceivedThisSession = false;

    function urlMatchesLoadoutsEndpoint(url) {
        if (!url) return false;
        const s = typeof url === 'string' ? url : (url.href || url.url || String(url));
        return s.indexOf('sid=itemsLoadouts') >= 0 && s.indexOf('step=getEquippedItems') >= 0;
    }

    function consumeLoadoutResponse(payload) {
        if (!payload || !payload.currentLoadouts) return;
        let changed = false;
        for (const key in payload.currentLoadouts) {
            if (payload.currentLoadouts.hasOwnProperty(key)) {
                const title = payload.currentLoadouts[key].title;
                if (title && loadoutTitles[key] !== title) {
                    loadoutTitles[key] = title;
                    changed = true;
                }
            }
        }
        titlesReceivedThisSession = true;
        if (changed) {
            persistTitles();
            refreshButtonText();
        }
    }

    // Passively observe Torn's periodic equipped-items polls and pull titles out of them,
    // so renames propagate without us issuing a request. Patches unsafeWindow when the
    // script runs in an isolated world (Tampermonkey/Violentmonkey with @grant unsafeWindow);
    // otherwise patches the script's own window, which only reaches page traffic in
    // page-world managers. A 15s fallback (see end of script) handles the case where
    // neither approach catches a response.
    const netTarget = isTampermonkeyEnabled ? unsafeWindow : window;

    try {
        const originalFetch = netTarget.fetch;
        if (typeof originalFetch === 'function') {
            netTarget.fetch = function (...args) {
                const result = originalFetch.apply(this, args);
                try {
                    if (urlMatchesLoadoutsEndpoint(args[0])) {
                        result.then(resp => {
                            resp.clone().json().then(consumeLoadoutResponse).catch(() => {});
                        }).catch(() => {});
                    }
                } catch (e) { /* ignore */ }
                return result;
            };
        }
    } catch (e) {
        console.warn("[TornLoadoutSwitcher] fetch wrap failed:", e);
    }

    try {
        const XHR = netTarget.XMLHttpRequest;
        if (XHR && XHR.prototype) {
            const originalOpen = XHR.prototype.open;
            const originalSend = XHR.prototype.send;
            XHR.prototype.open = function (method, url) {
                this.__silmarilLoadoutUrl = url;
                return originalOpen.apply(this, arguments);
            };
            XHR.prototype.send = function () {
                try {
                    if (urlMatchesLoadoutsEndpoint(this.__silmarilLoadoutUrl)) {
                        this.addEventListener('load', () => {
                            try {
                                consumeLoadoutResponse(JSON.parse(this.responseText));
                            } catch (e) { /* ignore */ }
                        });
                    }
                } catch (e) { /* ignore */ }
                return originalSend.apply(this, arguments);
            };
        }
    } catch (e) {
        console.warn("[TornLoadoutSwitcher] XHR wrap failed:", e);
    }

    const styles = `
div#loadoutsRoot p[class^=title___] {
    overflow-y: hidden;
    overflow-x: auto;
    }

div.silmaril-torn-loadout-switcher-container {
    display: inline-flex;
    align-items: center;
    margin-left: 5px;
}

div.silmaril-torn-loadout-switcher-container a img {
    display: flex;
    height: 50px;
    flex-direction: row;
    align-content: stretch;
    justify-content: space-around;
    align-items: flex-start;
}

.wave-animation {
  position: relative;
  overflow: hidden;
}

.wave {
  pointer-events: none;
  position: absolute;
  width: 100%;
  height: 33px;
  background-color: transparent;
  opacity: 0;
  transform: translateX(-100%);
  animation: waveAnimation 3s cubic-bezier(0, 0, 0, 1);
}

@media (max-width: 768px) {
    div[class^=main___] > div[class^=content___] {
        margin-top: 10px;
    }
}

@keyframes waveAnimation {
  0% {
    opacity: 1;
    transform: translateX(-100%);
  }
  100% {
    opacity: 0;
    transform: translateX(100%);
  }
}
`;

    if (isTampermonkeyEnabled) {
        GM_addStyle(styles);
    } else {
        let style = document.createElement("style");
        style.type = "text/css";
        style.innerHTML = styles;
        while (document.head == null) {
            await sleep(50);
        }
        document.head.appendChild(style);
    }

    const setLoadoutUrl = "/page.php?sid=itemsLoadouts&step=changeLoadout&setID={loadoutId}&rfcv={rfcv}";
    let selectedLoadouts = localStorage.getItem("silmaril-loadout-switcher-selected-loadouts") ?? "1,2,3";
    let selectedLoadoutsArray = selectedLoadouts.split(',');

    function tryAttach() {
        const titleEl = [...document.querySelectorAll("#loadoutsRoot [class*=title___]")]
            .find(el => Array.from(el.classList).some(c => c.startsWith('title___')));
        if (!titleEl) return;
        if (titleEl.querySelector('.silmaril-torn-loadout-switcher-container')) return;

        const buttonContainer = document.createElement('div');
        buttonContainer.className = 'silmaril-torn-loadout-switcher-container';

        const waveDiv = document.createElement('div');
        waveDiv.className = 'wave';

        buttonContainer.appendChild(waveDiv);
        addLoadoutAndSettingButtons(buttonContainer);
        addLogo(buttonContainer);

        titleEl.appendChild(buttonContainer);
    }

    setInterval(tryAttach, 500);

    function addLogo(root) {
        if (!includeLogo) {
            return;
        }

        const logoLink = document.createElement('a');
        logoLink.href = '/factions.php?step=profile&ID=6731';
        logoLink.target = '_blank';

        const logoImg = document.createElement('img');
        logoImg.src = 'data:image/octet-stream;base64,UklGRpAFAABXRUJQVlA4WAoAAAAYAAAAfwAAfwAAQUxQSKcAAAABDzD/ERHCbW3tbSIRS4/gUTwaabEMa7ijxCVRj/jpl8ldRP8nwP0nx5AZVNBUcoOt0n/gUxSauzmCS9EzCFK400tCxBEcFGm8toWBRa6N0lQwgyzNSithbdHVNmnG3DbrERDjfO3c2/PPcXd8p5mgNOpqm6i1VlmsINNm2tYo10ZRA+1o4aHQnBaMehFBgl4NjiAVWt/Ma1tlDHkNKmg+KCHTxlF/RQBWUDggrAQAADAcAJ0BKoAAgAAAAAAlAGusoL8Y/ADVCuD/gr+rX9E5wzUrsB+pv8axQL4P+Ff8r4AD9CP55+O3AO/Tb+8/47hAP0A/iHCAfwf+O+jr/u/9F8C36q/2b/K/AT/Jf5R8/+3LeJf6B9AHjP/Sa6n+kU4BhD3961gj+Yfp160fzJ50PnT/He4J/G/5v/kvzY7pvoAfqqmnzJ4zUG87Va0zYvL6xWb+7JdFy9GX+yIyVf2p1gR6w+kkZGwvG/OF94fetMEdGx53ednF7J1scS7sPxiI6FjzydvQ4SbMdJNDC25UR6pUmPfvYcGGAAD+/97c8Ag//yLWLqou+QyhvH5zTSyXLSY2RoaUp2Wjt8xqZm4N5FaR+PoyTh8YUJM7QpH93+r4XaBryGMi0aFNVS8/7GOU+dSycbi/sz5Tz/hZhLoDMNx/CX7Gl1xcBT/L7AYCgsYxT9XAoH2pL3/9LpgQdOa6W/+JgLgV0/RBVejhSRsTkCyXl2UJH1kf9oU8GNvWkuD+mL+eZJVBocEr/9xsahGwJHFih+U67i9H5P/36l/Abt2sESXCK5WPOQn2zCQ+eAsAi6pLg5h+Sp+Vi6Kv0zH6AVXsPzb4N163PmwCeixL8S+oaebL0JSSbaOc3dOEcFV/7/yQN7cJmkJctgKW7TpzG1fBZjf3Vi3pVBgjt3DoFZ9H88FJsbZk4UQiSAdtgTBGGV0Qbako3/EaCDDhv6o3UXIy9VuFJpks3LnVLff/0tEUn/3H2mYOHRxXSCQhYbMo/6XcT8kbHRS/6WcF7Kq+JZpLoSWYlNcCRTgaomPehL6YDVV+P1MOwoKgPwzco3hVXqPoYRPjuqk9X6OWxjGQ/V4klAsdjwBL0nwpioJIYFxr5+e7qKBrLoO04+2st6nrmTVd/VSqEd/zP00iULw2nyDaeBgxC3T3J0fRf+s8oMIlseJhnGdpJRCUiaAEfGmACQG4zR+fsIkLKph96SQGxccfrou8foWYYSsrv1T5yJnbNXFgiNlMluCNU6JKxG7/ZZQJ/6vT/jD9Q5lNeaeXt//Ex/0JZFyx393d7XxhcoRvVUFWv/Mcj/ev2VLCojBVMPAn6f8SVZjwO54toOLjZfFY/oZc6jJnIH6bolYAD3Gt/wm55ZRHg2AlRSuMGsfBp1oTL0XxAMT/3Uxv56jhPLlFjPHZPRsN07Mp8ahvKqGaRjqc1NPylgQ0anN36Tkazqhznoj8OoNi1e/6Giff5NGL4f+Mbre0tl/9pdTw+4koHvkch/gnreoLlBf5Nb1OCy4J6L+hRhD0XZA/Z0za7xczd72XRx0cNO8l6mGOZuH98/4Lo3RIM2FLUV//u4oIuiJKtPt9/MNLMhcygG1EfRV6RICfU6QY4SxI/z3WEe439xw5wcpR2Vvzyy9srHuubX2rB0LJbtd0TvVlEiAdF1BC1he3fyzfhkY33hY4IJJ/c7na5AxArRy9TcD7D8hQc4UhVSsX4hlsH73UQSrPwn6v5YJHsQ30huCmqGJ/jbGu8jb18VcEFGB7wu/XqQ3juNRxhXEGqI3FH5GBeWSCinT3+epuVSD1dsxb9TvZq/IoGv6niKAAAAAARVhJRg4AAABNTQAqAAAACAAAAAAAAA==';
        logoImg.alt = 'Next Level logo';

        logoLink.appendChild(logoImg);
        root.appendChild(logoLink);
    }

    function addLoadoutAndSettingButtons(root) {
        addLoadoutButtons(root);

        const settings = document.createElement('button');
        settings.type = 'button';
        settings.title = 'Settings';
        settings.className = 'torn-btn';
        settings.textContent = '⚙';
        settings.addEventListener('click', () => {
            let userInput = prompt("Please, enter which loadouts from 1 to 9 you want to see, comma-separated (default: 1,2,3):", selectedLoadouts);
            let wave = root.querySelector("div.wave");
            if (userInput !== null && userInput.length > 0) {
                localStorage.setItem("silmaril-loadout-switcher-selected-loadouts", userInput);
                selectedLoadouts = userInput;
                selectedLoadoutsArray = selectedLoadouts.split(',');
                root.querySelectorAll("button, a").forEach((item) => item.remove());
                addLoadoutAndSettingButtons(root);
                addLogo(root);
                wave.style.backgroundColor = "green";
            } else {
                wave.style.animationDuration = "3s";
                wave.style.backgroundColor = "yellow";
                console.error("[TornLoadoutSwitcher] User cancelled input of selected loadouts.");
            }
            wave.style.animation = 'none';
            wave.offsetHeight;
            wave.style.animation = null;
        });

        root.appendChild(settings);
    }

    async function addLoadoutButtons(root) {
        selectedLoadoutsArray.forEach((loadout) => {
            const button = document.createElement('button');
            button.type = 'button';
            button.title = showTitles ? loadout : loadoutTitles[loadout] ?? '';
            button.className = rfcv === null ? 'torn-btn disabled' : 'torn-btn';
            button.textContent = showTitles ? (loadoutTitles[loadout] ?? loadout) : loadout;
            button.setAttribute('data-loadout-number', loadout);
            button.addEventListener('click', () => {
                handleLoadoutClick(root)
            });

            root.appendChild(button);
        })
    }

    async function handleLoadoutClick(root) {
        let loadout = event.target.getAttribute('data-loadout-number');
        if (event.target.classList.contains('disabled')) {
            return;
        }
        let url = setLoadoutUrl.replace("{loadoutId}", loadout).replace("{rfcv}", rfcv);
        await sendSetLoadoutRequest(url, root);
    }

    async function sendSetLoadoutRequest(url, root) {
        let wave = root.querySelector("div.wave");
        await fetch(url, {
            method: 'GET',
        })
            .then(response => {
                if (response.ok) {
                    wave.style.backgroundColor = "green";
                } else {
                    console.error("[TornLoadoutSwitcher] Set Loadout request failed:", response);
                    wave.style.backgroundColor = "red";
                    wave.style.animationDuration = "5s";
                }
            })
            .catch(error => {
                console.error("[TornLoadoutSwitcher] Error setting loadout:", error);
                wave.style.backgroundColor = "red";
                wave.style.animationDuration = "5s";
            });
        wave.style.animation = 'none';
        wave.offsetHeight;
        wave.style.animation = null;
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function persistTitles() {
        try {
            localStorage.setItem("silmaril-loadout-switcher-titles", JSON.stringify(loadoutTitles));
        } catch (e) { /* ignore quota errors */
        }
    }

    function refreshButtonText() {
        if (!showTitles) return;
        document.querySelectorAll('div.silmaril-torn-loadout-switcher-container button[data-loadout-number]').forEach(btn => {
            const loadout = btn.getAttribute('data-loadout-number');
            const title = loadoutTitles[loadout];
            if (title) {
                btn.textContent = title;
                btn.title = loadout;
            }
        });
    }

    async function fetchTitlesManually() {
        if (rfcv === null) return;
        try {
            const response = await fetch(`${getEquippedItemsUrl}&rfcv=${rfcv}`);
            consumeLoadoutResponse(await response.clone().json());
        } catch (e) {
            console.warn("[TornLoadoutSwitcher] Manual titles fetch failed:", e);
        }
    }

    setTimeout(() => {
        if (!titlesReceivedThisSession) fetchTitlesManually();
    }, 15000);
})();