Torn Loadout Copier

Copy loadouts between slots and quick loadout switcher\

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Torn Loadout Copier
// @namespace    tornloadoutcopier.zero.nao
// @version      1.1
// @description  Copy loadouts between slots and quick loadout switcher\
// @author       nao [2669774]
// @match        https://www.torn.com/item.php*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    let loadoutData = null;
    let activeCopy = null;
    let selectedSourceID = null;
    let selectedTargetID = null;

    function getRFC() {
        for (const c of document.cookie.split('; ')) {
            const [k, v] = c.split('=');
            if (k === 'rfc_v') return v;
        }
        return null;
    }

    function parseResponse(res) {
        if (typeof res === 'string') {
            try { return JSON.parse(res); } catch (e) { }
        }
        return res;
    }

    function getItemsForSet(setID) {
        if (loadoutData?.currentSettings?.currentSetID == setID) return loadoutData.currentItems;
        return loadoutData.currentLoadouts[setID]?.items;
    }

    // Intercept fetch to capture loadout data
    let lastDataJson = '';
    const _fetch = window.fetch;
    window.fetch = async function (...args) {
        const res = await _fetch.apply(this, args);
        const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
        if (url.includes('sid=itemsLoadouts') && url.includes('step=getEquippedItems')) {
            res.clone().text().then(text => {
                if (text === lastDataJson) return;
                lastDataJson = text;
                loadoutData = JSON.parse(text);
                refreshUI();
            }).catch(() => { });
        }
        return res;
    };

    function refreshUI() {
        if (!loadoutData) return;
        const container = document.querySelector('.lc-container');
        if (container) container.remove();
        injectAll();
    }

    function refreshSwitcher() {
        const container = document.querySelector('.lc-container');
        if (!container) return;
        const old = container.querySelector('.lc-switcher');
        const next = buildSwitcher();
        if (next) {
            if (old) container.replaceChild(next, old);
            else container.appendChild(next);
        }
    }

    function getSortedSetIDs() {
        if (!loadoutData?.currentLoadouts) return [];
        return Object.keys(loadoutData.currentLoadouts).sort((a, b) => a - b);
    }

    function buildActions(sourceSetID, targetSetID) {
        const sourceItemsObj = getItemsForSet(sourceSetID);
        const targetItemsObj = getItemsForSet(targetSetID);
        if (!sourceItemsObj) return [];

        const isTargetCurrent = loadoutData?.currentSettings?.currentSetID == targetSetID;
        const targetItemsArray = targetItemsObj ? Object.values(targetItemsObj) : [];
        const actions = [];

        if (!isTargetCurrent) {
            actions.push({ type: 'changeLoadout', setID: targetSetID, label: 'Switch to target loadout' });
        }

        for (const slotKey in sourceItemsObj) {
            const src = sourceItemsObj[slotKey];
            if (!src?.itemID && !src?.ID) continue;

            const srcItemID = src.itemID || src.ID;
            const targetItem = targetItemsArray.find(t =>
                t.armouryID === src.armouryID ||
                (t.itemID === srcItemID && t.name === src.name)
            );

            if (!targetItem) {
                actions.push({
                    type: 'equipItem',
                    itemID: srcItemID,
                    armouryID: src.armouryID,
                    label: `Equip ${src.name}`
                });
            }

            if (src.currentUpgrades?.length) {
                for (const upg of src.currentUpgrades) {
                    if (targetItem?.currentUpgrades?.some(t => t.upgradeID === upg.upgradeID)) continue;
                    actions.push({
                        type: 'reattach',
                        armouryID: src.armouryID,
                        upgradeID: upg.upgradeID,
                        label: `Attach ${upg.title || 'Mod'} → ${src.name}`
                    });
                }
            }

            if (src.currentAmmoType) {
                actions.push({
                    type: 'changeAmmoType',
                    armouryID: src.armouryID,
                    targetAmmoType: src.currentAmmoType,
                    itemName: src.name,
                    label: `Sync Ammo → ${src.name}`
                });
            }
        }
        return actions;
    }

    async function executeAction(action) {
        const rfc = getRFC();
        if (action.type === 'changeLoadout') {
            const data = parseResponse(await $.post(`/page.php?sid=itemsLoadouts&step=changeLoadout&setID=${action.setID}&rfcv=${rfc}`));
            if (data?.currentItems) loadoutData.currentItems = data.currentItems;
            if (data?.currentSettings) loadoutData.currentSettings = data.currentSettings;
            return data;
        }
        if (action.type === 'equipItem') {
            return $.post(`/item.php?rfcv=${rfc}`, {
                step: 'actionForm',
                item_id: action.itemID,
                type: '1',
                action: 'equip',
                item: action.itemID,
                id: action.armouryID,
                confirm: '1'
            });
        }
        if (action.type === 'reattach') {
            return $.post(`/page.php?sid=itemsModsData&step=reattach&armouryID=${action.armouryID}&upgradeID=${action.upgradeID}&replaceUpgradeID=&rfcv=${rfc}`);
        }
        if (action.type === 'changeAmmoType') {
            return $.post(`/page.php?sid=itemsLoadouts&step=changeAmmoType&armouryID=${action.armouryID}&rfcv=${rfc}`);
        }
    }

    // Loadout Switcher
    const GLOW_COLORS = {
        'glow-red': '#c0392b', 'glow-orange': '#e67e22', 'glow-yellow': '#f1c40f',
        'glow-green': '#27ae60', 'glow-blue': '#2980b9', 'glow-purple': '#8e44ad', 'glow-white': '#bdc3c7',
    };
    const WEAPON_TYPES = new Set(['Primary', 'Secondary', 'Melee', 'Temporary']);
    const ARMOR_TYPES = new Set(['Defensive']);

    function buildItemRow(item, isWeapon) {
        const color = GLOW_COLORS[item?.glowClass] || '#888';
        const row = document.createElement('div');
        row.style.cssText = `display:flex;flex-direction:column;gap:1px;padding:3px 5px;border-left:2px solid ${color};margin-bottom:3px;`;

        const nameLine = document.createElement('div');
        nameLine.style.cssText = `font-size:10px;font-weight:bold;color:${color};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
        nameLine.textContent = item?.name || '—';
        row.appendChild(nameLine);

        const toStat = v => { const n = parseFloat(v); return isFinite(n) && n > 0 ? n : 0; };
        const parts = [];
        if (isWeapon) {
            const dmg = toStat(item?.dmg) || toStat(item?.damage);
            const acc = toStat(item?.acc) || toStat(item?.accuracy);
            const qual = toStat(item?.quality);
            if (dmg) parts.push('DMG ' + dmg.toFixed(0));
            if (acc) parts.push('ACC ' + acc.toFixed(0));
            if (qual) parts.push('Q ' + qual.toFixed(0));
        } else {
            const arm = toStat(item?.arm);
            const qual = toStat(item?.quality);
            if (arm) parts.push('MIT ' + arm.toFixed(0));
            if (qual) parts.push('Q ' + qual.toFixed(0));
        }
        if (parts.length) {
            const stats = document.createElement('div');
            stats.style.cssText = 'font-size:9px;color:#888;';
            stats.textContent = parts.join('  ');
            row.appendChild(stats);
        }

        const bonuses = Array.isArray(item?.currentBonuses) ? item.currentBonuses : [];
        const bonusText = bonuses
            .filter(b => b?.title)
            .map(b => b.title + (b.value != null ? ' ' + b.value + '%' : ''))
            .join(' · ');
        if (bonusText) {
            const bonusEl = document.createElement('div');
            bonusEl.style.cssText = 'font-size:9px;color:#6b8;';
            bonusEl.textContent = bonusText;
            row.appendChild(bonusEl);
        }
        return row;
    }

    function buildSwitcherCard(setID, isActive, isMinified) {
        const loadout = loadoutData.currentLoadouts[setID];
        const items = Object.values(getItemsForSet(setID) || {}).filter(i => i?.name);
        const weapons = items.filter(i => WEAPON_TYPES.has(i.type2));
        const armors = items.filter(i => ARMOR_TYPES.has(i.type2));

        const card = document.createElement('div');
        card.style.cssText = [
            isMinified ? 'min-width:100px;' : 'min-width:140px;max-width:200px;',
            'flex:1;',
            'background:' + (isActive ? '#1a2a1a' : '#1a1a1a') + ';',
            'border:1px solid ' + (isActive ? '#4a7a2a' : '#333') + ';',
            'border-radius:5px;padding:6px 7px;cursor:pointer;',
            'transition:border-color 0.15s,background 0.15s;font-family:monospace;',
        ].join('');

        card.addEventListener('mouseenter', () => {
            if (!isActive && setID !== selectedSourceID && setID !== selectedTargetID) {
                card.style.borderColor = '#555';
                card.style.background = '#222';
            }
        });
        card.addEventListener('mouseleave', () => {
            if (!isActive) {
                if (setID === selectedSourceID) card.style.borderColor = '#e67e22';
                else if (setID === selectedTargetID) card.style.borderColor = '#2980b9';
                else card.style.borderColor = '#333';
                card.style.background = '#1a1a1a';
            }
        });

        const header = document.createElement('div');
        header.style.cssText = `display:flex;align-items:center;justify-content:space-between;${isMinified ? '' : 'margin-bottom:5px;'}`;

        const title = document.createElement('span');
        title.style.cssText = 'font-size:11px;font-weight:bold;color:' + (isActive ? '#6b8e23' : '#bbb') + ';letter-spacing:0.5px;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
        title.textContent = loadout?.title || 'Set ' + setID;
        header.appendChild(title);

        if (isActive) {
            const badge = document.createElement('span');
            badge.style.cssText = 'font-size:8px;color:#6b8e23;border:1px solid #6b8e23;border-radius:2px;padding:0 3px;margin-left:4px;white-space:nowrap;';
            badge.textContent = 'ON';
            header.appendChild(badge);
        }

        if (setID === selectedSourceID) {
            const badge = document.createElement('span');
            badge.style.cssText = 'font-size:8px;color:#e67e22;border:1px solid #e67e22;border-radius:2px;padding:0 3px;margin-left:4px;white-space:nowrap;font-weight:bold;';
            badge.textContent = 'SOURCE ➔';
            header.appendChild(badge);
            card.style.boxShadow = 'inset 0 0 10px rgba(230, 126, 34, 0.2)';
            card.style.borderColor = '#e67e22';
        } else if (setID === selectedTargetID) {
            const badge = document.createElement('span');
            badge.style.cssText = 'font-size:8px;color:#2980b9;border:1px solid #2980b9;border-radius:2px;padding:0 3px;margin-left:4px;white-space:nowrap;font-weight:bold;';
            badge.textContent = '➔ TARGET';
            header.appendChild(badge);
            card.style.boxShadow = 'inset 0 0 10px rgba(41, 128, 185, 0.2)';
            card.style.borderColor = '#2980b9';
        }

        card.appendChild(header);

        if (!isMinified) {
            if (weapons.length) {
                const sec = document.createElement('div');
                sec.style.cssText = 'font-size:8px;color:#555;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px;';
                sec.textContent = 'WEAPONS';
                card.appendChild(sec);
                weapons.forEach(w => card.appendChild(buildItemRow(w, true)));
            }
            if (armors.length) {
                const sec = document.createElement('div');
                sec.style.cssText = 'font-size:8px;color:#555;text-transform:uppercase;letter-spacing:0.5px;margin:4px 0 2px;';
                sec.textContent = 'ARMOR';
                card.appendChild(sec);
                armors.forEach(a => card.appendChild(buildItemRow(a, false)));
            }
        }

        if (!isActive) {
            card.addEventListener('click', async () => {
                card.style.opacity = '0.5';
                card.style.pointerEvents = 'none';
                try {
                    await executeAction({ type: 'changeLoadout', setID });
                    refreshUI();
                } catch (e) {
                    card.style.opacity = '1';
                    card.style.pointerEvents = '';
                }
            });
        }

        return card;
    }

    function buildSwitcher() {
        if (!loadoutData?.currentLoadouts) return null;

        const setIDs = getSortedSetIDs();
        if (!setIDs.length) return null;

        const isMinified = localStorage.getItem('lc-switcher-minified') === 'true';
        const activeSetID = String(loadoutData?.currentSettings?.currentSetID);

        const wrap = document.createElement('div');
        wrap.className = 'lc-switcher';
        wrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;padding:10px 15px;margin-bottom:10px;background:#111;border:1px solid #2a2a2a;border-radius:5px;';

        const labelRow = document.createElement('div');
        labelRow.style.cssText = 'width:100%;display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;';

        const label = document.createElement('div');
        label.style.cssText = 'font-size:10px;color:#444;font-family:monospace;letter-spacing:1px;text-transform:uppercase;';
        label.textContent = 'QUICK SWITCH';
        labelRow.appendChild(label);

        const toggle = document.createElement('button');
        toggle.style.cssText = 'background:none;border:1px solid #333;color:#555;font-size:9px;padding:1px 6px;cursor:pointer;border-radius:3px;font-family:monospace;transition:all 0.2s;';
        toggle.textContent = isMinified ? 'EXPAND' : 'MINIFY';
        toggle.onmouseover = () => { toggle.style.color = '#888'; toggle.style.borderColor = '#444'; };
        toggle.onmouseout = () => { toggle.style.color = '#555'; toggle.style.borderColor = '#333'; };
        toggle.onclick = (e) => {
            e.preventDefault();
            localStorage.setItem('lc-switcher-minified', !isMinified);
            refreshUI();
        };
        labelRow.appendChild(toggle);
        wrap.appendChild(labelRow);

        const cardsContainer = document.createElement('div');
        cardsContainer.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;width:100%;';

        for (const sid of setIDs) {
            cardsContainer.appendChild(buildSwitcherCard(sid, sid === activeSetID, isMinified));
        }
        wrap.appendChild(cardsContainer);
        return wrap;
    }

    function buildCopier() {
        if (!loadoutData?.currentLoadouts) return null;

        const setIDs = getSortedSetIDs();
        if (setIDs.length < 2) return null;

        const wrap = document.createElement('div');
        wrap.style.cssText = 'display:flex;flex-direction:column;padding:10px 15px;margin-bottom:10px;background:#222;border:1px solid #333;border-radius:5px;gap:6px;font-family:monospace;box-shadow:0 2px 4px rgba(0,0,0,0.5);';

        const controls = document.createElement('div');
        controls.style.cssText = 'display:flex;align-items:center;gap:12px;';

        const title = document.createElement('span');
        title.textContent = 'LOADOUT COPIER';
        title.style.cssText = 'font-size:12px;color:#6b8e23;font-weight:bold;letter-spacing:1px;margin-right:8px;';

        const labelFrom = document.createElement('span');
        labelFrom.textContent = 'FROM';
        labelFrom.style.cssText = 'font-size:11px;color:#777;letter-spacing:0.5px;';

        const selectFrom = document.createElement('select');
        selectFrom.style.cssText = 'font-size:11px;padding:3px 6px;background:#111;color:#ccc;border:1px solid #444;border-radius:3px;outline:none;text-transform:uppercase;cursor:pointer;';

        const labelTo = document.createElement('span');
        labelTo.textContent = 'TO';
        labelTo.style.cssText = 'font-size:11px;color:#777;letter-spacing:0.5px;';

        const selectTo = document.createElement('select');
        selectTo.style.cssText = 'font-size:11px;padding:3px 6px;background:#111;color:#ccc;border:1px solid #444;border-radius:3px;outline:none;text-transform:uppercase;cursor:pointer;';

        selectFrom.appendChild(new Option('-- SELECT SOURCE --', ''));
        selectTo.appendChild(new Option('-- SELECT TARGET --', ''));

        for (const sid of setIDs) {
            const name = loadoutData.currentLoadouts[sid].title || `Set ${sid}`;
            selectFrom.appendChild(new Option(name, sid));
            selectTo.appendChild(new Option(name, sid));
        }

        selectFrom.value = selectedSourceID || '';
        selectTo.value = selectedTargetID || '';

        const btn = document.createElement('button');
        btn.innerHTML = '&#9658;';
        btn.title = 'Start copy';
        btn.disabled = !(selectedSourceID && selectedTargetID);
        btn.style.cssText = `display:flex;align-items:center;justify-content:center;font-size:16px;width:34px;height:28px;background:transparent;color:#6b8e23;border:1px solid #6b8e23;border-radius:4px;cursor:pointer;transition:all 0.2s;opacity:${btn.disabled ? '0.4' : '1'};flex-shrink:0;`;

        const updateSelection = () => {
            selectedSourceID = selectFrom.value;
            selectedTargetID = selectTo.value;

            if (selectedSourceID && selectedSourceID === selectedTargetID) {
                selectTo.value = '';
                selectedTargetID = null;
            }

            btn.disabled = !(selectedSourceID && selectedTargetID);
            btn.style.opacity = btn.disabled ? '0.4' : '1';
            refreshSwitcher();
        };

        selectFrom.addEventListener('change', updateSelection);
        selectTo.addEventListener('change', updateSelection);
        btn.onmouseover = () => { if (!btn.disabled) { btn.style.background = '#6b8e23'; btn.style.color = '#111'; } };
        btn.onmouseout = () => { if (!btn.disabled) { btn.style.background = 'transparent'; btn.style.color = '#6b8e23'; } };

        const status = document.createElement('div');
        status.style.cssText = 'font-size:11px;color:#aaa;min-height:14px;';

        const resetCopier = () => {
            status.textContent = '';
            btn.innerHTML = '&#9658;';
            btn.title = 'Start copy';
            btn.style.borderColor = '#6b8e23';
            btn.style.color = '#6b8e23';
            selectFrom.disabled = false;
            selectTo.disabled = false;
            selectFrom.style.opacity = '1';
            selectTo.style.opacity = '1';
            activeCopy = null;
        };

        const showDone = () => {
            status.textContent = 'DONE AND EQUIPPED';
            status.style.color = '#6b8e23';
            btn.innerHTML = '&#8634;';
            btn.title = 'Reset';
        };

        const skipCompletedAmmo = () => {
            while (activeCopy.index < activeCopy.actions.length) {
                const a = activeCopy.actions[activeCopy.index];
                if (a.type !== 'changeAmmoType') break;
                const matched = Object.values(loadoutData.currentItems || {}).find(i => i.armouryID == a.armouryID);
                if (!matched || matched.currentAmmoType != a.targetAmmoType) break;
                activeCopy.index++;
            }
        };

        btn.addEventListener('click', async (e) => {
            e.preventDefault();
            e.stopPropagation();

            const sourceID = selectFrom.value;
            const targetID = selectTo.value;

            if (!activeCopy || activeCopy.sourceSetID !== sourceID || activeCopy.targetSetID !== targetID) {
                const actions = buildActions(sourceID, targetID);
                if (!actions.length) {
                    status.textContent = 'NO CHANGES DETECTED';
                    status.style.color = '#aaa';
                    return;
                }
                activeCopy = { sourceSetID: sourceID, targetSetID: targetID, actions, index: 0 };
                selectFrom.disabled = true;
                selectTo.disabled = true;
                selectFrom.style.opacity = '0.5';
                selectTo.style.opacity = '0.5';
                btn.style.borderColor = '#aaa';
                btn.style.color = '#aaa';
            }

            skipCompletedAmmo();

            if (activeCopy.index >= activeCopy.actions.length) {
                if (btn.title !== 'Reset') { showDone(); return; }
                resetCopier();
                return;
            }

            const action = activeCopy.actions[activeCopy.index];
            status.textContent = action.label;
            status.style.color = '#888';
            btn.innerHTML = '&#8987;';
            btn.disabled = true;

            try {
                const res = await executeAction(action);

                if (action.type === 'changeAmmoType') {
                    if (!action.seenAmmoTypes) action.seenAmmoTypes = new Set();
                    const data = parseResponse(res);

                    if (data?.currentAmmoType) {
                        if (data.currentAmmoType === action.targetAmmoType) {
                            activeCopy.index++;
                        } else if (action.seenAmmoTypes.has(data.currentAmmoType)) {
                            activeCopy.index++;
                        } else {
                            action.seenAmmoTypes.add(data.currentAmmoType);
                            action.label = `Match ammo -> ${data.clips?.title || data.currentAmmoType} -> ${action.itemName}`;
                        }
                    } else {
                        activeCopy.index++;
                    }
                } else {
                    activeCopy.index++;
                }

                refreshSwitcher();

                if (activeCopy.index >= activeCopy.actions.length) {
                    showDone();
                } else {
                    const progress = `${activeCopy.index}/${activeCopy.actions.length}`;
                    status.textContent = `${progress} — ${activeCopy.actions[activeCopy.index].label}`;
                    status.style.color = '#aaa';
                    btn.innerHTML = '&#9658;';
                    btn.title = 'Next action';
                }
            } catch (err) {
                status.textContent = 'ERROR - RETRY';
                status.style.color = '#d9534f';
                btn.innerHTML = '&#8635;';
                btn.style.borderColor = '#d9534f';
                btn.style.color = '#d9534f';
            }

            btn.disabled = false;
        });

        controls.append(title, labelFrom, selectFrom, labelTo, selectTo, btn);
        wrap.append(controls, status);
        return wrap;
    }

    function injectAll() {
        if (!loadoutData?.currentLoadouts) return;
        const target = document.querySelector('.equipped-items-wrap');
        if (!target || document.querySelector('.lc-container')) return;

        const container = document.createElement('div');
        container.className = 'lc-container';

        const copier = buildCopier();
        const switcher = buildSwitcher();

        if (copier) container.appendChild(copier);
        if (switcher) container.appendChild(switcher);

        target.insertAdjacentElement('beforebegin', container);
    }

    const observer = new MutationObserver(() => {
        if (loadoutData && !document.querySelector('.lc-container') && document.querySelector('.equipped-items-wrap')) {
            injectAll();
        }
    });

    observer.observe(document.documentElement, { childList: true, subtree: true });
    injectAll();

})();