Torn Loadout Copier

Copy loadouts between slots and quick loadout switcher\

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

})();