Greasy Fork is available in English.

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.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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