Torn Armoury Loan Button

Caches loanable faction armoury items and adds a "Loan" button on your own organized crime role when it requires an item, loaning it to you in one click.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn Armoury Loan Button
// @namespace    https://github.com/SOLiNARY
// @version      0.4.0
// @description  Caches loanable faction armoury items and adds a "Loan" button on your own organized crime role when it requires an item, loaning it to you in one click.
// @author       Ramin Quluzade, Silmaril [2665762]
// @license      MIT License
// @match        https://www.torn.com/factions.php*
// @match        https://torn.com/factions.php*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = '[TornArmouryLoanButton]';
    const ITEMS_KEY = 'silmaril-armoury-loan-items';
    const SLOT_ITEMS_KEY = 'silmaril-armoury-loan-slots';
    const USER_KEY = 'silmaril-armoury-loan-user';
    const RFCV_KEY = 'silmaril-armoury-loan-rfcv';
    const USED_ITEM_MARKER = 'used item';
    const RFCV_ARG = 'rfcv=';
    const USER_ID_KEYS = ['userID', 'userId', 'user_id', 'playerId', 'playerID', 'uid'];
    const USER_NAME_KEYS = ['playername', 'playerName', 'username', 'userName', 'user_name'];

    addStyle(`
.silmaril-oc-loan-wrap {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    margin-left: 8px;
    vertical-align: middle;
    flex-shrink: 0;
    white-space: nowrap;
}

.silmaril-oc-loan-wrap .silmaril-oc-loan-btn {
    cursor: pointer;
}

.silmaril-oc-loan-wrap .silmaril-oc-loan-btn.silmaril-busy {
    opacity: 0.6;
    pointer-events: none;
}

.silmaril-oc-loan-wrap .silmaril-oc-loan-btn.silmaril-success {
    color: #85b200;
}

.silmaril-oc-loan-msg {
    font-size: 11px;
    line-height: 1.3;
    max-width: 250px;
}

.silmaril-oc-loan-msg.success {
    color: #85b200;
}

.silmaril-oc-loan-msg.failure {
    color: #ff6b6b;
}
    `);

    // --- rfcv token ------------------------------------------------------------
    // The rfcv value changes from time to time, so it is captured from Torn's own
    // ajax traffic via PerformanceObserver (works in both page and isolated
    // userscript worlds), with the rfc_v cookie as the primary source.

    let capturedRfcv = null;

    function captureRfcvFromUrl(url) {
        if (typeof url !== 'string') return;
        const idx = url.indexOf(RFCV_ARG);
        if (idx < 0) return;
        const value = url.substring(idx + RFCV_ARG.length).split('&')[0];
        if (!value || value === capturedRfcv) return;
        capturedRfcv = value;
        try {
            localStorage.setItem(RFCV_KEY, value);
        } catch (e) { /* ignore quota errors */ }
    }

    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(`${LOG_PREFIX} PerformanceObserver unavailable:`, e);
    }

    function getCookie(name) {
        const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
        return match ? decodeURIComponent(match[1]) : null;
    }

    function getRfcv() {
        return getCookie('rfc_v') ?? capturedRfcv ?? localStorage.getItem(RFCV_KEY);
    }

    // --- armoury items cache ---------------------------------------------------

    function getStoredItems() {
        try {
            const parsed = JSON.parse(localStorage.getItem(ITEMS_KEY));
            return parsed && typeof parsed === 'object' ? parsed : {};
        } catch (e) {
            return {};
        }
    }

    function saveItems(items) {
        try {
            localStorage.setItem(ITEMS_KEY, JSON.stringify(items));
        } catch (e) { /* ignore quota errors */ }
    }

    function getItemName(row) {
        const nameEl = row.querySelector('.name');
        if (!nameEl) return '';
        const clone = nameEl.cloneNode(true);
        clone.querySelectorAll('.qty').forEach((qty) => qty.remove());
        return clone.textContent.trim().replace(/\s*x$/i, '');
    }

    function isRowLoanable(row) {
        const loanBtn = row.querySelector('.item-action [data-role="loan"], .item-action .loan');
        if (!loanBtn || !loanBtn.classList.contains('active')) return false;
        const loanedEl = row.querySelector('.loaned');
        if (!loanedEl) return false;
        const clone = loanedEl.cloneNode(true);
        clone.querySelectorAll('.t-show').forEach((label) => label.remove());
        return clone.textContent.trim().toLowerCase().includes('available');
    }

    function itemsEqual(a, b) {
        if (!a || !b) return false;
        if (a.name !== b.name || a.type !== b.type) return false;
        const aIds = a.armoryIds ?? [];
        const bIds = b.armoryIds ?? [];
        return aIds.length === bIds.length && aIds.every((id, i) => id === bIds[i]);
    }

    // Collects every armoury row currently rendered and remembers which armoury
    // ids of each item can be loaned right now. Items not rendered at the moment
    // (other categories/pages) keep their previously cached state.
    function scanArmoury() {
        const wraps = document.querySelectorAll('li .img-wrap[data-armoryid][data-itemid]');
        if (wraps.length === 0) return;

        const seen = {};
        for (const wrap of wraps) {
            const row = wrap.closest('li');
            const itemId = wrap.getAttribute('data-itemid');
            const armoryId = wrap.getAttribute('data-armoryid');
            if (!row || !itemId || !armoryId) continue;

            let entry = seen[itemId];
            if (!entry) {
                entry = seen[itemId] = {
                    name: getItemName(row),
                    type: row.querySelector('.type')?.textContent.trim() ?? '',
                    armoryIds: []
                };
            }
            if (isRowLoanable(row) && !entry.armoryIds.includes(armoryId)) {
                entry.armoryIds.push(armoryId);
            }
        }

        const stored = getStoredItems();
        let changed = false;
        for (const itemId of Object.keys(seen)) {
            if (!itemsEqual(stored[itemId], seen[itemId])) {
                stored[itemId] = { ...seen[itemId], updated: Date.now() };
                changed = true;
            }
        }
        if (changed) {
            saveItems(stored);
            console.log(`${LOG_PREFIX} Armoury cache updated:`, Object.keys(seen).length, 'item(s) on screen');
        }
    }

    // --- current user ----------------------------------------------------------

    let cachedUser = null;

    function deepFindUser(node, depth) {
        if (!node || typeof node !== 'object' || depth > 4) return null;
        const idKey = USER_ID_KEYS.find((key) => node[key] != null && /^\d+$/.test(String(node[key])));
        const nameKey = USER_NAME_KEYS.find((key) => typeof node[key] === 'string' && node[key].trim() !== '');
        if (idKey && nameKey) return { id: String(node[idKey]), name: node[nameKey].trim() };
        for (const key of Object.keys(node)) {
            if (node[key] && typeof node[key] === 'object') {
                const found = deepFindUser(node[key], depth + 1);
                if (found) return found;
            }
        }
        return null;
    }

    function findUserInJsonScripts() {
        const scripts = document.querySelectorAll('#websocketConnectionData, script[type="application/json"]');
        for (const script of scripts) {
            try {
                const found = deepFindUser(JSON.parse(script.textContent), 0);
                if (found) return found;
            } catch (e) { /* not JSON, skip */ }
        }
        return null;
    }

    function findUserInHeader() {
        for (const rootSelector of ['#header-root', '#sidebarroot', '#sidebar']) {
            const link = document.querySelector(`${rootSelector} a[href*="profiles.php?XID="]`);
            if (!link) continue;
            const id = link.href.match(/XID=(\d+)/)?.[1];
            const name = link.textContent.trim();
            if (id && /^[\w-]{1,25}$/.test(name)) return { id, name };
        }
        return null;
    }

    function getUser() {
        if (cachedUser) return cachedUser;
        const found = findUserInJsonScripts() ?? findUserInHeader();
        if (found) {
            cachedUser = found;
            try {
                localStorage.setItem(USER_KEY, JSON.stringify(found));
            } catch (e) { /* ignore quota errors */ }
            return found;
        }
        try {
            const stored = JSON.parse(localStorage.getItem(USER_KEY));
            if (stored?.id && stored?.name) {
                cachedUser = stored;
                return stored;
            }
        } catch (e) { /* ignore */ }
        return null;
    }

    // --- organized crimes page -------------------------------------------------

    function extractItemId(img) {
        for (const attr of ['src', 'srcset']) {
            const id = img.getAttribute(attr)?.match(/(?:^|\/)images\/items\/(\d+)\//)?.[1];
            if (id) return id;
        }
        return null;
    }

    // Falls back to resolving the item by the name written after "Used item:",
    // matched against the cached armoury item names.
    function findItemIdByName(blockText) {
        const captured = blockText?.match(/used item:?\s*(.{1,60})/i)?.[1].trim().toLowerCase();
        if (!captured) return null;
        let best = null;
        for (const [itemId, entry] of Object.entries(getStoredItems())) {
            const name = entry?.name?.trim().toLowerCase();
            if (!name || !captured.startsWith(name)) continue;
            if (!best || name.length > best.nameLength) best = { itemId, nameLength: name.length };
        }
        return best?.itemId ?? null;
    }

    // Climbs from the item image to the smallest ancestor containing the
    // "Used item: ..." text; OC class names are hashed and the text may be
    // split across word-level spans, so the ancestors' combined textContent is
    // the only stable marker. The size cap keeps it from latching onto huge
    // containers that merely happen to contain the text somewhere far away.
    //
    // This block itself lives inside a hover/tooltip element that Torn hides
    // (or unmounts) the instant the cursor leaves it, so it is only ever used
    // to read the item requirement - never as the place to put the button.
    function findUsedItemBlock(img) {
        let el = img.parentElement;
        for (let depth = 0; el && el !== document.body && depth < 8; depth++) {
            const text = el.textContent ?? '';
            if (text.length > 600) return null;
            if (text.toLowerCase().includes(USED_ITEM_MARKER)) return el;
            el = el.parentElement;
        }
        return null;
    }

    // Climbs from a descendant up to the smallest ancestor that represents
    // exactly one role slot (role name, skill value, member name, View
    // Profile / Leave Role) - the permanent card that stays visible
    // regardless of hover state, unlike the "Used item" tooltip, which Torn
    // only shows while hovering and can vanish before a click registers.
    // Bails out (returns null) if climbing ever passes a single slot into a
    // multi-slot container (more than one title found) - which happens when
    // starting from a tooltip that renders as a portal disconnected from its
    // slot's own subtree - rather than risk anchoring to the wrong role.
    function findRoleCard(el) {
        let node = el.parentElement;
        for (let depth = 0; node && node !== document.body && depth < 12; depth++) {
            const titles = node.querySelectorAll('[class*="title___"]');
            if (titles.length > 1) return null;
            if (titles.length === 1 && node.querySelector('[class*="successChance___"]')) return node;
            node = node.parentElement;
        }
        return null;
    }

    // Only a role the current user occupies shows a "Leave Role" action -
    // that is the one reliable, always-visible signal for "this is my slot".
    function findOwnRoleSlots() {
        const slots = [];
        const seenCards = new Set();
        document.querySelectorAll('button, a').forEach((el) => {
            if (el.textContent.trim().toLowerCase() !== 'leave role') return;
            const card = findRoleCard(el);
            if (!card || seenCards.has(card)) return;
            seenCards.add(card);
            slots.push({ card, leaveBtn: el });
        });
        return slots;
    }

    // Identifies a slot across scans/rescans: the OC id disambiguates between
    // crimes, the role title (e.g. "Muscle #1") disambiguates roles within one.
    function getSlotKey(slotCard) {
        const ocId = slotCard.closest('[data-oc-id]')?.getAttribute('data-oc-id') ?? 'oc?';
        const title = slotCard.querySelector('[class*="title___"]')?.textContent.trim() ?? 'role?';
        return `${ocId}::${title}`;
    }

    function getSlotItemCache() {
        try {
            const parsed = JSON.parse(localStorage.getItem(SLOT_ITEMS_KEY));
            return parsed && typeof parsed === 'object' ? parsed : {};
        } catch (e) {
            return {};
        }
    }

    function saveSlotItemCache(cache) {
        try {
            localStorage.setItem(SLOT_ITEMS_KEY, JSON.stringify(cache));
        } catch (e) { /* ignore quota errors */ }
    }

    function scanCrimes() {
        const slotCache = getSlotItemCache();
        let slotCacheChanged = false;

        // Discovery: correlate a currently-visible "Used item" tooltip to a
        // role. Climbing up from the tooltip text is precise and preferred
        // when it resolves (handles multiple tooltips coexisting in the DOM);
        // the header Torn itself marks as open (data-is-tooltip-opened="true")
        // is the fallback for when the tooltip renders as a portal
        // disconnected from its slot's own subtree, since that marker works
        // no matter where the tooltip content actually renders.
        const activeHeader = document.querySelector('[data-is-tooltip-opened="true"]');
        const imgs = document.querySelectorAll('img[src*="images/items/"], img[srcset*="images/items/"]');
        for (const img of imgs) {
            if (img.closest('.img-wrap[data-armoryid], .silmaril-oc-loan-wrap, #chatRoot, [class^="chat-box"]')) continue;
            const textBlock = findUsedItemBlock(img);
            if (!textBlock) continue;
            const itemId = extractItemId(img) ?? findItemIdByName(textBlock.textContent);
            if (!itemId) continue;

            const source = findRoleCard(textBlock) ?? activeHeader;
            if (!source) continue;
            const key = getSlotKey(source);
            if (slotCache[key] !== itemId) {
                slotCache[key] = itemId;
                slotCacheChanged = true;
            }
        }
        if (slotCacheChanged) saveSlotItemCache(slotCache);

        // Injection: only on roles the current user occupies (a "Leave Role"
        // action is present), and only once that role's item is known - from
        // the discovery pass above, or cached from an earlier scan.
        findOwnRoleSlots().forEach(({ card, leaveBtn }) => {
            if (card.querySelector('.silmaril-oc-loan-btn')) return;
            const itemId = slotCache[getSlotKey(card)];
            if (!itemId) return;
            injectButton(leaveBtn.parentElement ?? card, itemId);
        });
    }

    function injectButton(container, itemId) {
        const wrap = document.createElement('span');
        wrap.className = 'silmaril-oc-loan-wrap';

        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'torn-btn silmaril-oc-loan-btn';
        button.textContent = 'Loan';

        const entry = getStoredItems()[itemId];
        button.title = entry
            ? `Loan "${entry.name}" x1 to yourself from the faction armoury (${entry.armoryIds?.length ?? 0} available)`
            : 'Loan this item to yourself from the faction armoury';

        button.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            loanItem(itemId, button, wrap);
        });

        wrap.appendChild(button);
        container.appendChild(wrap);
    }

    function showMessage(wrap, message, success) {
        let msg = wrap.querySelector('.silmaril-oc-loan-msg');
        if (!msg) {
            msg = document.createElement('span');
            msg.className = 'silmaril-oc-loan-msg';
            wrap.appendChild(msg);
        }
        msg.textContent = message;
        msg.classList.toggle('success', success);
        msg.classList.toggle('failure', !success);
        clearTimeout(msg.silmarilHideTimer);
        msg.silmarilHideTimer = setTimeout(() => msg.remove(), 10000);
    }

    function stripHtml(html) {
        let text;
        try {
            text = new DOMParser().parseFromString(String(html), 'text/html').body?.textContent;
        } catch (e) { /* fall through to the regex strip */ }
        text ??= String(html).replace(/<[^>]*>/g, ' ');
        return text.replace(/\s+/g, ' ').trim().slice(0, 180);
    }

    function parseLoanResponse(response, text) {
        if (!response.ok) {
            return { success: false, message: `Request failed: HTTP ${response.status}` };
        }
        try {
            const json = JSON.parse(text);
            const message = stripHtml(String(json.message ?? json.text ?? json.error ?? ''));
            const success = json.success !== false && json.error == null;
            return { success, message };
        } catch (e) {
            const message = stripHtml(text);
            const lower = message.toLowerCase();
            const failed = lower.includes('error') || lower.includes('you cannot') || lower.includes("you can't");
            return { success: !failed, message };
        }
    }

    async function loanItem(itemId, button, wrap) {
        if (button.classList.contains('silmaril-busy')) return;

        const store = getStoredItems();
        const entry = store[itemId];
        if (!entry) {
            showMessage(wrap, 'No armoury data for this item yet. Open Faction → Armoury and view it once to cache it.', false);
            return;
        }
        if (!entry.armoryIds?.length) {
            showMessage(wrap, `No available "${entry.name || 'item'}" in the armoury cache. Revisit the armoury to refresh it.`, false);
            return;
        }
        const user = getUser();
        if (!user) {
            showMessage(wrap, 'Could not detect your player name/ID. Reload the page and try again.', false);
            return;
        }
        const rfcv = getRfcv();
        if (!rfcv) {
            showMessage(wrap, 'Could not detect the rfcv token yet. Browse around Torn and try again.', false);
            return;
        }

        const armoryId = entry.armoryIds[0];
        const body = new URLSearchParams({
            ajax: 'true',
            step: 'armouryActionItem',
            role: 'loan',
            item: armoryId,
            itemID: itemId,
            type: entry.type ?? '',
            user: `${user.name} [${user.id}]`,
            quantity: '1'
        });

        button.classList.add('silmaril-busy');
        const originalText = button.textContent;
        button.textContent = '…';
        console.log(`${LOG_PREFIX} Loaning item ${itemId} (armoury id ${armoryId}) to ${user.name} [${user.id}]`);

        try {
            const response = await fetch(`/factions.php?rfcv=${encodeURIComponent(rfcv)}`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'X-Requested-With': 'XMLHttpRequest'
                },
                body: body.toString()
            });
            const text = await response.text();
            const result = parseLoanResponse(response, text);
            if (result.success) {
                // The armoury id is loaned out now, drop it so the next click uses a fresh one.
                const freshStore = getStoredItems();
                const freshEntry = freshStore[itemId];
                if (freshEntry?.armoryIds) {
                    freshEntry.armoryIds = freshEntry.armoryIds.filter((id) => id !== armoryId);
                    saveItems(freshStore);
                }
                button.textContent = 'Loaned ✓';
                button.classList.add('silmaril-success');
                showMessage(wrap, result.message || `Loaned "${entry.name || 'item'}" x1 to ${user.name}.`, true);
            } else {
                button.textContent = originalText;
                showMessage(wrap, result.message || 'Loan request failed.', false);
                console.error(`${LOG_PREFIX} Loan request failed:`, text);
            }
        } catch (error) {
            button.textContent = originalText;
            showMessage(wrap, `Request error: ${error.message}`, false);
            console.error(`${LOG_PREFIX} Loan request error:`, error);
        } finally {
            button.classList.remove('silmaril-busy');
        }
    }

    // --- wiring ----------------------------------------------------------------

    function addStyle(css) {
        const style = document.createElement('style');
        style.type = 'text/css';
        style.innerHTML = css;
        document.head.appendChild(style);
    }

    function debounce(fn, delay) {
        let timer = null;
        return function () {
            if (timer) clearTimeout(timer);
            timer = setTimeout(() => {
                timer = null;
                fn();
            }, delay);
        };
    }

    function runScan() {
        scanArmoury();
        scanCrimes();
    }

    const scheduleScan = debounce(runScan, 150);

    new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true });
    // Torn mutates the DOM near-constantly (countdowns, chat, sidebar timers),
    // which can starve the debounced observer callback forever — so the
    // interval calls the scan directly to guarantee it keeps running.
    setInterval(runScan, 1000);
    runScan();
})();