Scan faction armory pages, save loaned item data locally, show member loan popups, and recommend recipients in armory tooltips. Includes PDA scan support.
// ==UserScript==
// @name -Fortie- Faction : Armory Loan Scanner
// @namespace https://torn.com/
// @version 1.2.1
// @description Scan faction armory pages, save loaned item data locally, show member loan popups, and recommend recipients in armory tooltips. Includes PDA scan support.
// @author -Fortie-
// @match https://www.torn.com/*
// @grant none
// @run-at document-end
// ==/UserScript==
(() => {
"use strict";
const STYLE_ID = "fortie-Armory-loan-style";
const SCAN_BTN_ID = "fortie-Armory-scan-btn";
const PDA_SCAN_BTN_ID = "fortie-Armory-scan-pda-btn";
const STORAGE_KEY = "fortie_Armory_scan_data_v10";
const POPUP_ID = "fortie-loaned-items-popup";
let observer = null;
let updateTimer = null;
let hoverTimer = null;
let lastPageSignature = "";
let tooltipBindDone = false;
let uiHeartbeat = null;
let cachedStoredItems = null;
let cachedMemberSummary = null;
let recommendationCache = new Map();
const log = (...a) => console.log("[Fortie Armory Loan Scanner]", ...a);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function cleanText(s) {
return String(s || "").replace(/\s+/g, " ").trim();
}
function escapeHtml(str) {
return String(str || "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function xidFromHref(href) {
const m = String(href || "").match(/[?&]XID=(\d+)/i);
return m ? m[1] : null;
}
function itemIdFromSrc(src) {
const m = String(src || "").match(/\/images\/items\/(\d+)\//i);
return m ? m[1] : null;
}
function readLS(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function writeLS(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
function invalidateCaches() {
cachedStoredItems = null;
cachedMemberSummary = null;
recommendationCache.clear();
}
function applyFortieThemeBridge() {
const root = document.documentElement;
const styles = getComputedStyle(root);
const getVar = (name, fallback) => {
const v = styles.getPropertyValue(name).trim();
return v || fallback;
};
root.style.setProperty("--fls-bg", getVar("--fortie-bg", "#0f141c"));
root.style.setProperty("--fls-bg-soft", getVar("--fortie-bgSoft", "#151c26"));
root.style.setProperty("--fls-panel", getVar("--fortie-panel", "#18212d"));
root.style.setProperty("--fls-panel2", getVar("--fortie-panel2", "#1d2836"));
root.style.setProperty("--fls-elevated", getVar("--fortie-elevated", "#223041"));
root.style.setProperty("--fls-border", getVar("--fortie-border", "#314154"));
root.style.setProperty("--fls-border-strong", getVar("--fortie-borderStrong", "#4a6079"));
root.style.setProperty("--fls-accent", getVar("--fortie-faction", getVar("--fortie-blue2", "#4ea0ff")));
root.style.setProperty("--fls-success", getVar("--fortie-success", getVar("--fortie-green2", "#3ecf6c")));
root.style.setProperty("--fls-warning", getVar("--fortie-warning", getVar("--fortie-yellow2", "#f0c94d")));
root.style.setProperty("--fls-danger", getVar("--fortie-danger", getVar("--fortie-red2", "#e04848")));
root.style.setProperty("--fls-text", getVar("--fortie-text", "#e7edf5"));
root.style.setProperty("--fls-text-soft", getVar("--fortie-textSoft", "#a9b7c8"));
root.style.setProperty("--fls-text-muted", getVar("--fortie-textMuted", "#6b7f91"));
root.style.setProperty("--fls-weapons", getVar("--fortie-faction", getVar("--fortie-danger", "#d85b6a")));
root.style.setProperty("--fls-armour", getVar("--fortie-info", getVar("--fortie-blue2", "#4ea0ff")));
root.style.setProperty("--fls-temporary", getVar("--fortie-success", getVar("--fortie-green2", "#3ecf6c")));
}
window.addEventListener("fortie:theme-updated", applyFortieThemeBridge);
function isFactionPage() {
const u = location.href.toLowerCase();
return u.includes("factions.php") && u.includes("step=your");
}
function getHashValue(key) {
const hash = String(location.hash || "");
const m = hash.match(new RegExp(`[?&#/]?${key}=([^&#]+)`, "i"));
return m ? m[1] ? decodeURIComponent(m[1]).toLowerCase() : "" : "";
}
function isFactionInfoPage() {
if (!isFactionPage()) return false;
return getHashValue("tab") === "info";
}
function getArmorySubTab() {
if (!isFactionPage()) return null;
const tab = getHashValue("tab");
if (tab !== "armoury" && tab !== "armory") return null;
const sub = getHashValue("sub");
if (sub === "weapons") return "weapons";
if (sub === "armour" || sub === "armor") return "armour";
if (sub === "temporary") return "temporary";
return null;
}
function isSupportedArmoryPage() {
return ["weapons", "armour", "temporary"].includes(getArmorySubTab());
}
function getStartValue() {
const hash = String(location.hash || "");
const match = hash.match(/(?:^|[#/&?])start=([^&#/]+)/i);
const raw = match ? decodeURIComponent(match[1]) : "0";
if (!raw || raw === "undefined" || raw === "null") return "0";
if (/^\d+$/.test(raw)) return raw;
return "0";
}
function toNum(v) {
const n = parseFloat(String(v || "").replace(/[^\d.]/g, ""));
return Number.isFinite(n) ? n : 0;
}
function normalizeText(v) {
return cleanText(v || "").toLowerCase();
}
function readStoredData() {
return readLS(STORAGE_KEY, {});
}
function saveStoredData(subTab, pageStart, items) {
const existing = readStoredData();
const section = existing[subTab] || {};
const pageKey = String(pageStart || "0");
const payload = {
...existing,
[subTab]: {
...section,
[pageKey]: {
subTab,
pageStart: pageKey,
scannedAt: Date.now(),
url: location.href,
count: items.length,
items
}
}
};
writeLS(STORAGE_KEY, payload);
invalidateCaches();
return payload;
}
function flattenStoredItems() {
if (cachedStoredItems) return cachedStoredItems;
const data = readStoredData();
const out = [];
for (const subTab of Object.keys(data)) {
const pages = data[subTab] || {};
for (const pageStart of Object.keys(pages)) {
const page = pages[pageStart];
const items = Array.isArray(page?.items) ? page.items : [];
for (const item of items) out.push(item);
}
}
cachedStoredItems = out;
return out;
}
function getMemberKey(item) {
return String(item.loanedToXid || item.loanedTo || "").trim();
}
function buildMemberLoanSummary() {
const items = flattenStoredItems().filter(i => getMemberKey(i));
const byMember = new Map();
for (const item of items) {
const key = getMemberKey(item);
if (!key) continue;
if (!byMember.has(key)) {
byMember.set(key, {
xid: item.loanedToXid || key,
key,
name: item.loanedTo || `Member ${key}`,
weapons: [],
armour: [],
temporary: []
});
}
const member = byMember.get(key);
if (item.category === "weapons") member.weapons.push(item);
if (item.category === "armour") member.armour.push(item);
if (item.category === "temporary") member.temporary.push(item);
}
return Array.from(byMember.values());
}
function getMemberLoanSummary() {
if (!cachedMemberSummary) cachedMemberSummary = buildMemberLoanSummary();
return cachedMemberSummary;
}
function getItemsForMember(memberKey) {
const target = String(memberKey || "").trim();
if (!target) return [];
const items = flattenStoredItems().filter(item => getMemberKey(item) === target);
const seen = new Set();
return items.filter(item => {
const key = [
item.category || "",
item.armoryId || "",
item.itemId || "",
item.itemName || "",
item.weaponType || "",
item.weaponClass || "",
item.armorType || "",
item.damage || "",
item.accuracy || "",
item.armorValue || "",
item.quantity || "",
getMemberKey(item)
].join("|");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function addStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
:root {
--fls-bg: #0f141c;
--fls-bg-soft: #151c26;
--fls-panel: #18212d;
--fls-panel2: #1d2836;
--fls-elevated: #223041;
--fls-border: #314154;
--fls-border-strong: #4a6079;
--fls-accent: #4ea0ff;
--fls-success: #3ecf6c;
--fls-warning: #f0c94d;
--fls-danger: #e04848;
--fls-text: #e7edf5;
--fls-text-soft: #a9b7c8;
--fls-text-muted: #6b7f91;
--fls-weapons: #d85b6a;
--fls-armour: #4ea0ff;
--fls-temporary: #3ecf6c;
}
li:has(> a[href="#faction-info"]) {
background: var(--fls-bg-soft) !important;
border-color: var(--fls-border) !important;
}
li:has(> a[href="#faction-info"]):hover {
background: var(--fls-panel) !important;
border-color: var(--fls-border-strong) !important;
}
li:has(> a[href="#faction-info"]).ui-tabs-active,
li:has(> a[href="#faction-info"]).ui-state-active {
background: linear-gradient(180deg, var(--fls-panel), var(--fls-bg)) !important;
border-color: var(--fls-accent) !important;
border-bottom-color: transparent !important;
}
li:has(> a[href="#faction-info"]) .tab-name {
color: var(--fls-text-soft) !important;
}
li:has(> a[href="#faction-info"]).ui-tabs-active .tab-name,
li:has(> a[href="#faction-info"]).ui-state-active .tab-name {
color: var(--fls-text) !important;
}
li:has(> a[href="#faction-info"]) svg path {
fill: var(--fls-text-muted) !important;
}
li:has(> a[href="#faction-info"]).ui-tabs-active svg path,
li:has(> a[href="#faction-info"]).ui-state-active svg path {
fill: var(--fls-accent) !important;
}
li.weapons,
li.armor,
li.armour,
li.temporary {
background: var(--fls-bg-soft) !important;
border-color: var(--fls-border) !important;
}
li.weapons,
li.weapons a,
li.weapons .ui-tabs-anchor {
color: var(--fls-weapons) !important;
}
li.armor,
li.armour,
li.armor a,
li.armour a,
li.armor .ui-tabs-anchor,
li.armour .ui-tabs-anchor {
color: var(--fls-armour) !important;
}
li.temporary,
li.temporary a,
li.temporary .ui-tabs-anchor {
color: var(--fls-temporary) !important;
}
li.weapons:hover {
background: color-mix(in srgb, var(--fls-weapons) 16%, var(--fls-panel)) !important;
border-color: var(--fls-weapons) !important;
}
li.armor:hover,
li.armour:hover {
background: color-mix(in srgb, var(--fls-armour) 16%, var(--fls-panel)) !important;
border-color: var(--fls-armour) !important;
}
li.temporary:hover {
background: color-mix(in srgb, var(--fls-temporary) 16%, var(--fls-panel)) !important;
border-color: var(--fls-temporary) !important;
}
li.weapons.ui-tabs-active,
li.weapons.ui-state-active {
background: linear-gradient(180deg, color-mix(in srgb, var(--fls-weapons) 18%, var(--fls-panel)), var(--fls-bg)) !important;
border-color: var(--fls-weapons) !important;
border-bottom-color: transparent !important;
}
li.armor.ui-tabs-active,
li.armor.ui-state-active,
li.armour.ui-tabs-active,
li.armour.ui-state-active {
background: linear-gradient(180deg, color-mix(in srgb, var(--fls-armour) 18%, var(--fls-panel)), var(--fls-bg)) !important;
border-color: var(--fls-armour) !important;
border-bottom-color: transparent !important;
}
li.temporary.ui-tabs-active,
li.temporary.ui-state-active {
background: linear-gradient(180deg, color-mix(in srgb, var(--fls-temporary) 18%, var(--fls-panel)), var(--fls-bg)) !important;
border-color: var(--fls-temporary) !important;
border-bottom-color: transparent !important;
}
li.weapons.ui-tabs-active a,
li.weapons.ui-state-active a,
li.weapons.ui-tabs-active .ui-tabs-anchor,
li.weapons.ui-state-active .ui-tabs-anchor {
color: var(--fls-weapons) !important;
font-weight: 800 !important;
}
li.armor.ui-tabs-active a,
li.armor.ui-state-active a,
li.armour.ui-tabs-active a,
li.armour.ui-state-active a,
li.armor.ui-tabs-active .ui-tabs-anchor,
li.armor.ui-state-active .ui-tabs-anchor,
li.armour.ui-tabs-active .ui-tabs-anchor,
li.armour.ui-state-active .ui-tabs-anchor {
color: var(--fls-armour) !important;
font-weight: 800 !important;
}
li.temporary.ui-tabs-active a,
li.temporary.ui-state-active a,
li.temporary.ui-tabs-active .ui-tabs-anchor,
li.temporary.ui-state-active .ui-tabs-anchor {
color: var(--fls-temporary) !important;
font-weight: 800 !important;
}
#${SCAN_BTN_ID},
#${PDA_SCAN_BTN_ID} {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
margin-left: 10px !important;
padding: 6px 10px !important;
border-radius: 6px !important;
border: 1px solid var(--fls-border-strong) !important;
background: linear-gradient(180deg, var(--fls-panel2), var(--fls-elevated)) !important;
color: var(--fls-text-soft) !important;
cursor: pointer !important;
font: 700 12px Arial, sans-serif !important;
transition: filter .15s ease, border-color .15s ease, color .15s ease !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 2px 8px rgba(0,0,0,.35) !important;
white-space: nowrap !important;
}
#${SCAN_BTN_ID}:hover,
#${PDA_SCAN_BTN_ID}:hover {
filter: brightness(1.15) !important;
border-color: var(--fls-accent) !important;
color: var(--fls-text) !important;
}
#${SCAN_BTN_ID}:disabled,
#${PDA_SCAN_BTN_ID}:disabled {
opacity: .65 !important;
cursor: not-allowed !important;
}
#${SCAN_BTN_ID} svg,
#${PDA_SCAN_BTN_ID} svg {
width: 14px !important;
height: 14px !important;
fill: currentColor !important;
flex-shrink: 0 !important;
}
#${PDA_SCAN_BTN_ID} {
float: right !important;
min-height: 28px !important;
}
.fortie-loan-country-slot {
display: inline-flex !important;
align-items: center !important;
margin-left: 6px !important;
vertical-align: middle !important;
position: relative !important;
z-index: 20 !important;
}
.fortie-loan-country-btn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
border-radius: 3px !important;
border: 1px solid rgba(255,255,255,.15) !important;
background: var(--fls-danger) !important;
color: #fff !important;
font: 700 9px/1 Arial, sans-serif !important;
cursor: pointer !important;
padding: 0 !important;
box-shadow: 0 1px 4px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.12) !important;
}
#${POPUP_ID} {
position: fixed;
inset: 0;
z-index: 2147483646;
background: rgba(5, 10, 18, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
}
.fls-card {
width: min(640px, 94vw);
max-height: 84vh;
display: flex;
flex-direction: column;
background: var(--fls-bg);
border: 1px solid var(--fls-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(78,160,255,.06), 0 32px 80px rgba(0,0,0,.72), 0 8px 24px rgba(0,0,0,.4);
font-family: Arial, sans-serif;
}
.fls-accent {
height: 3px;
flex-shrink: 0;
background: linear-gradient(90deg, var(--fls-weapons) 0%, var(--fls-armour) 50%, var(--fls-temporary) 100%);
}
.fls-header {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 15px;
background: var(--fls-bg-soft);
border-bottom: 1px solid var(--fls-border);
flex-shrink: 0;
}
.fls-header-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--fls-danger), color-mix(in srgb, var(--fls-danger) 55%, #000));
border: 1px solid rgba(255,255,255,.1);
box-shadow: 0 2px 8px rgba(0,0,0,.35);
display: flex;
align-items: center;
justify-content: center;
font: 700 11px/1 Arial, sans-serif;
color: #fff;
flex-shrink: 0;
}
.fls-header-text { flex: 1; min-width: 0; }
.fls-header-title {
font-size: 14px;
font-weight: 700;
color: var(--fls-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fls-header-sub {
margin-top: 2px;
font-size: 11px;
color: var(--fls-text-muted);
}
.fls-header-sub b {
color: var(--fls-text-soft);
font-weight: 600;
}
.fls-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--fls-text-muted);
cursor: pointer;
font-size: 14px;
}
.fls-close:hover {
color: var(--fls-text);
border-color: var(--fls-border);
background: var(--fls-elevated);
}
.fls-body {
flex: 1;
overflow-y: auto;
padding: 14px 13px 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.fls-empty {
padding: 36px 20px;
text-align: center;
border-radius: 8px;
border: 1px dashed var(--fls-border);
color: var(--fls-text-muted);
font-size: 13px;
line-height: 1.6;
}
.fls-section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 7px;
}
.fls-section-label {
font-size: 10px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.fls-section-label.weapons { color: var(--fls-weapons); }
.fls-section-label.armour { color: var(--fls-armour); }
.fls-section-label.temporary { color: var(--fls-temporary); }
.fls-section-rule {
flex: 1;
height: 1px;
background: var(--fls-border);
}
.fls-section-count {
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 20px;
background: var(--fls-elevated);
color: var(--fls-text-muted);
border: 1px solid var(--fls-border);
}
.fls-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.fls-item {
display: flex;
align-items: stretch;
border-radius: 10px;
overflow: hidden;
background: #24364b;
border: 1px solid #31465e;
}
.fls-item-accent { width: 4px; flex-shrink: 0; }
.fls-item.weapons .fls-item-accent { background: var(--fls-weapons); }
.fls-item.armour .fls-item-accent { background: var(--fls-armour); }
.fls-item.temporary .fls-item-accent { background: var(--fls-temporary); }
.fls-item-body {
flex: 1;
padding: 10px 12px;
min-width: 0;
}
.fls-item-name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fls-item-name {
font-size: 13px;
font-weight: 700;
color: var(--fls-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fls-item-stats {
margin-top: 4px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.fls-item-stat {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 11px;
color: var(--fls-warning);
}
.fls-item-stat-label {
font-size: 9px;
font-weight: 700;
color: var(--fls-text-muted);
text-transform: uppercase;
letter-spacing: .05em;
}
.fls-item-bonuses {
margin-top: 6px;
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.fls-bonus-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 600;
background: color-mix(in srgb, var(--fls-accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--fls-accent) 25%, transparent);
color: var(--fls-accent);
white-space: nowrap;
}
.fortie-rec-wrap {
margin-top: 10px !important;
padding: 10px !important;
border-radius: 10px !important;
border: 1px solid color-mix(in srgb, var(--fls-accent) 55%, transparent) !important;
background: linear-gradient(180deg, var(--fls-panel2), var(--fls-bg)) !important;
box-shadow: 0 8px 20px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.05) !important;
color: var(--fls-text) !important;
max-height: 430px !important;
overflow: hidden !important;
}
.fortie-rec-head {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
margin-bottom: 8px !important;
padding-bottom: 7px !important;
border-bottom: 1px solid rgba(255,255,255,.08) !important;
}
.fortie-rec-title {
font: 800 12px Arial, sans-serif !important;
letter-spacing: .10em !important;
color: var(--fls-text) !important;
text-transform: uppercase !important;
}
.fortie-rec-subtitle {
font: 900 11px Arial, sans-serif !important;
color: var(--fls-accent) !important;
text-shadow: 0 0 8px color-mix(in srgb, var(--fls-accent) 35%, transparent) !important;
}
.fortie-rec-list {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
max-height: 350px !important;
overflow-y: auto !important;
padding-right: 3px !important;
scroll-behavior: smooth !important;
}
.fortie-rec-list::-webkit-scrollbar {
width: 4px !important;
}
.fortie-rec-list::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--fls-accent) 55%, transparent) !important;
border-radius: 999px !important;
}
.fortie-rec-row {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 10px !important;
padding: 10px !important;
border-radius: 8px !important;
background: rgba(255,255,255,.045) !important;
border: 1px solid rgba(255,255,255,.075) !important;
}
.fortie-rec-row:first-child {
border-color: color-mix(in srgb, var(--fls-accent) 55%, transparent) !important;
background:
linear-gradient(180deg,
color-mix(in srgb, var(--fls-accent) 13%, var(--fls-panel2)),
var(--fls-panel2)
) !important;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--fls-accent) 25%, transparent),
0 8px 22px rgba(0,0,0,.45) !important;
}
.fortie-rec-left {
display: flex !important;
align-items: center !important;
gap: 8px !important;
min-width: 0 !important;
}
.fortie-rank {
width: 24px !important;
min-width: 24px !important;
height: 24px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 999px !important;
background: rgba(255,255,255,.08) !important;
color: #cbd7e8 !important;
font: 900 11px Arial, sans-serif !important;
}
.fortie-rank.gold {
color: var(--fls-warning) !important;
background: color-mix(in srgb, var(--fls-warning) 15%, transparent) !important;
border: 1px solid color-mix(in srgb, var(--fls-warning) 35%, transparent) !important;
}
.fortie-rank.silver {
color: var(--fls-text-soft) !important;
background: rgba(255,255,255,.10) !important;
border: 1px solid rgba(255,255,255,.22) !important;
}
.fortie-rank.bronze {
color: #ff9f43 !important;
background: rgba(255,159,67,.12) !important;
border: 1px solid rgba(255,159,67,.28) !important;
}
.fortie-rec-main {
min-width: 0 !important;
}
.fortie-rec-name {
font: 800 12px Arial, sans-serif !important;
color: var(--fls-text) !important;
line-height: 1.2 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
max-width: 170px !important;
}
.fortie-rec-reason {
margin-top: 2px !important;
font: 700 10px Arial, sans-serif !important;
color: var(--fls-text-soft) !important;
line-height: 1.2 !important;
}
.fortie-rec-current {
margin-top: 3px !important;
font: 600 9px Arial, sans-serif !important;
color: var(--fls-text-muted) !important;
line-height: 1.25 !important;
max-width: 210px !important;
}
.fortie-rec-current.no-match {
color: var(--fls-warning) !important;
font-weight: 800 !important;
}
.fortie-tag {
flex-shrink: 0 !important;
padding: 4px 7px !important;
border-radius: 999px !important;
font: 900 9px Arial, sans-serif !important;
letter-spacing: .04em !important;
text-transform: uppercase !important;
min-width: 58px !important;
text-align: center !important;
}
.fortie-tag.best {
background: color-mix(in srgb, var(--fls-accent) 18%, transparent) !important;
color: var(--fls-accent) !important;
border: 1px solid color-mix(in srgb, var(--fls-accent) 45%, transparent) !important;
}
.fortie-tag.strong {
background: color-mix(in srgb, var(--fls-success) 18%, transparent) !important;
color: var(--fls-success) !important;
border: 1px solid color-mix(in srgb, var(--fls-success) 45%, transparent) !important;
}
.fortie-tag.good {
background: color-mix(in srgb, var(--fls-warning) 18%, transparent) !important;
color: var(--fls-warning) !important;
border: 1px solid color-mix(in srgb, var(--fls-warning) 45%, transparent) !important;
}
.fortie-rec-empty {
text-align: center !important;
padding: 12px !important;
font: 700 11px Arial, sans-serif !important;
color: var(--fls-text-muted) !important;
}
`;
document.head.appendChild(style);
}
function getItemBlock(row) {
const parts = [row];
let next = row.nextElementSibling;
while (next && !next.querySelector?.(".img-wrap")) {
parts.push(next);
next = next.nextElementSibling;
}
return parts;
}
function getItemBlockText(row) {
return getItemBlock(row)
.map(el => cleanText(el.textContent || ""))
.join(" ");
}
function isPdaExpanded(row) {
return /Loaned:\s*/i.test(getItemBlockText(row));
}
function getPdaExpandButton(row) {
const candidates = [
row.querySelector('[class*="settings"]'),
row.querySelector('[class*="gear"]'),
row.querySelector('[class*="manage"]'),
row.querySelector('button[aria-label*="manage" i]'),
row.querySelector('button[aria-label*="settings" i]'),
row.querySelector('i[class*="settings"]'),
row.querySelector('i[class*="gear"]')
].filter(Boolean);
if (candidates.length) return candidates[0];
return Array.from(row.querySelectorAll("button, a, i, span, div"))
.filter(el => {
if (!(el instanceof HTMLElement)) return false;
const r = el.getBoundingClientRect();
if (r.width < 18 || r.height < 18) return false;
const txt = cleanText(el.textContent).toLowerCase();
const cls = String(el.className || "").toLowerCase();
return cls.includes("settings") || cls.includes("gear") || txt === "⚙" || txt === "";
})
.sort((a, b) => b.getBoundingClientRect().left - a.getBoundingClientRect().left)[0] || null;
}
async function autoExpandPdaRows() {
if (!isSupportedArmoryPage()) return;
const rows = getAllCandidateRows().filter(row => row.offsetParent !== null);
let expanded = 0;
for (const row of rows) {
if (isPdaExpanded(row)) continue;
const btn = getPdaExpandButton(row);
if (!btn) continue;
try {
btn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
btn.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
btn.click();
expanded++;
await sleep(140);
} catch {}
}
if (expanded) await sleep(350);
}
function getAllCandidateRows() {
return Array.from(document.querySelectorAll("li"))
.filter(li => {
const hasImg = li.querySelector(".img-wrap[data-armoryid][data-itemid], .img-wrap img, .img-wrap");
const hasName = li.querySelector(".name") || cleanText(li.textContent).length > 2;
return hasImg && hasName;
});
}
function getBonusSpans(row) {
return Array.from(row.querySelectorAll("ul.bonuses > li > span"));
}
function detectWeaponSlot(row) {
const txt = ` ${getItemBlockText(row).toLowerCase()} `;
const typeText = ` ${cleanText(row.querySelector(".type")?.textContent || "").toLowerCase()} `;
if (txt.includes(" primary ") || typeText.includes(" primary ")) return "Primary";
if (txt.includes(" secondary ") || typeText.includes(" secondary ")) return "Secondary";
if (txt.includes(" melee ") || typeText.includes(" melee ")) return "Melee";
return null;
}
function detectWeaponClass(row) {
const hay = ` ${getItemBlockText(row).toLowerCase()} ${cleanText(row.querySelector(".type")?.textContent || "").toLowerCase()} `;
const classMap = [
["machine gun", "Machine Gun"],
["heavy artillery", "Heavy Artillery"],
["sub machine gun", "SMG"],
["smg", "SMG"],
["shotgun", "Shotgun"],
["rifle", "Rifle"],
["pistol", "Pistol"],
["revolver", "Pistol"],
["launcher", "Launcher"],
["flamethrower", "Flamethrower"],
["bow", "Bow"],
["crossbow", "Crossbow"],
["clubbing", "Clubbing"],
["slashing", "Slashing"],
["piercing", "Piercing"],
["mechanical", "Mechanical"]
];
for (const [needle, label] of classMap) {
if (hay.includes(` ${needle} `)) return label;
}
return null;
}
function inferRowCategory(row) {
const typeText = cleanText(row.querySelector(".type")?.textContent || "").toLowerCase();
const bonusSpans = getBonusSpans(row);
if (typeText === "temporary") return "temporary";
if (typeText === "defensive") return "armour";
if (detectWeaponSlot(row)) return "weapons";
if (bonusSpans.length >= 2) return "weapons";
if (typeText.includes("primary") || typeText.includes("secondary") || typeText.includes("melee")) return "weapons";
return getArmorySubTab() || "unknown";
}
function getRowsForSubTab(subTab) {
const currentSubTab = getArmorySubTab();
const visibleRows = getAllCandidateRows().filter(row => row.offsetParent !== null);
if (currentSubTab === subTab && visibleRows.length) return visibleRows;
return getAllCandidateRows().filter(row => inferRowCategory(row) === subTab);
}
function extractBonusLabel(icon) {
if (!icon) return null;
const classes = Array.from(icon.classList || []);
if (classes.some(c => c.includes("blank"))) return null;
if (classes.some(c => /damage|accuracy|attack|defence|defense|armour|armor/i.test(c))) return null;
const title = icon.getAttribute("title");
if (!title) return null;
const boldMatch = title.match(/<b>(.*?)<\/b>/i);
if (boldMatch?.[1]) {
const label = cleanText(boldMatch[1]);
if (!/^(damage|accuracy|attack|defence|defense|armour|armor)$/i.test(label)) return label;
}
const stripped = title
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!stripped) return null;
if (/^(damage|accuracy|attack|defence|defense|armour|armor)$/i.test(stripped)) return null;
return stripped;
}
function extractBonusLabels(row) {
const seen = new Set();
return Array.from(row.querySelectorAll("ul.bonuses i[title]"))
.map(extractBonusLabel)
.filter(Boolean)
.filter(label => {
const key = label.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function detectWeaponTier(row) {
const img = row.querySelector(".img-wrap img");
const cls = String(img?.className || "").toLowerCase();
if (cls.includes("glow-red")) return "red";
if (cls.includes("glow-orange")) return "orange";
if (cls.includes("glow-yellow")) return "yellow";
return "grey";
}
function extractLoanedTo(row) {
const loaned = row.querySelector(".loaned");
if (loaned) {
const link = loaned.querySelector('a[href*="XID="]');
if (link) {
return {
status: "loaned",
name: cleanText(link.textContent),
xid: xidFromHref(link.getAttribute("href"))
};
}
const txt = cleanText(loaned.textContent).replace(/^Loaned:\s*/i, "");
if (/available/i.test(txt)) return { status: "available", name: null, xid: null };
return { status: txt ? "loaned" : "unknown", name: txt || null, xid: null };
}
const text = getItemBlockText(row);
const match = text.match(/Loaned:\s*(.*?)(?=\s+(Loan|Give|Buy:|Sell:|Value:|Circ:|Damage:|Accuracy:|Rate of Fire:|Stealth:|Caliber:|Ammo:|Bonus:|Quality:|$))/i);
if (!match) return { status: "unknown", name: null, xid: null };
const value = cleanText(match[1]);
if (!value || /available/i.test(value)) {
return { status: "available", name: null, xid: null };
}
const link = row.querySelector('a[href*="profiles.php?XID="], a[href*="XID="]');
const xid = xidFromHref(link?.getAttribute("href"));
return {
status: "loaned",
name: value,
xid: xid || null
};
}
function extractBaseRowData(row) {
const blockText = getItemBlockText(row);
const wrap = row.querySelector(".img-wrap[data-armoryid][data-itemid]");
const nameEl = row.querySelector(".name");
const typeEl = row.querySelector(".type");
const img = row.querySelector(".img-wrap img");
let itemName = cleanText(nameEl?.cloneNode(true)?.textContent || "");
if (!itemName) {
const possible = row.querySelector('[class*="name"]');
itemName = cleanText(possible?.textContent || "");
}
if (!itemName) {
const m = blockText.match(/^([A-Za-z0-9 .'\-]+?)\s+(Loaned:|The\s)/i);
itemName = cleanText(m?.[1] || "");
}
return {
armoryId: wrap?.dataset.armoryid || null,
itemId: wrap?.dataset.itemid || itemIdFromSrc(img?.getAttribute("src")) || null,
itemName,
type: cleanText(typeEl?.textContent || "")
};
}
function extractWeaponStats(row) {
const bonusSpans = getBonusSpans(row);
const bonuses = extractBonusLabels(row);
const text = getItemBlockText(row);
const dmg = cleanText(bonusSpans[0]?.textContent || "") || cleanText(text.match(/Damage:\s*([\d.]+)/i)?.[1] || "");
const acc = cleanText(bonusSpans[1]?.textContent || "") || cleanText(text.match(/Accuracy:\s*([\d.]+)/i)?.[1] || "");
const bonusText = cleanText(text.match(/Bonus:\s*([^|]+?)(?=\s+Quality:|$)/i)?.[1] || "");
return {
damage: dmg || null,
accuracy: acc || null,
bonus1: bonuses[0] || bonusText || null,
bonus2: bonuses[1] || null
};
}
function extractArmorStats(row) {
const bonusSpans = getBonusSpans(row);
const bonuses = extractBonusLabels(row);
const text = getItemBlockText(row);
return {
armorValue: cleanText(bonusSpans[0]?.textContent || "") || cleanText(text.match(/Armor:\s*([\d.]+)/i)?.[1] || "") || null,
bonus1: bonuses[0] || null,
bonus2: bonuses[1] || null
};
}
function extractTemporaryStats(row) {
const qtyEl = row.querySelector(".name .qty");
const qty = cleanText(qtyEl?.textContent || "");
let name = cleanText(row.querySelector(".name")?.textContent || "");
name = name.replace(/x\s*\d+\s*$/i, "").trim();
return {
itemName: name || null,
quantity: qty || null
};
}
function parseWeaponsPage() {
return getRowsForSubTab("weapons").map(row => {
const base = extractBaseRowData(row);
const loaned = extractLoanedTo(row);
const stats = extractWeaponStats(row);
const weaponType = detectWeaponSlot(row) || null;
const weaponClass = detectWeaponClass(row) || null;
return {
category: "weapons",
armoryId: base.armoryId,
itemId: base.itemId,
itemName: base.itemName,
weaponType,
weaponClass,
rawTypeText: base.type || null,
damage: stats.damage,
accuracy: stats.accuracy,
bonus1: stats.bonus1,
bonus2: stats.bonus2,
loanColor: detectWeaponTier(row),
loanedStatus: loaned.status,
loanedTo: loaned.name,
loanedToXid: loaned.xid,
scannedAt: Date.now()
};
});
}
function parseArmourPage() {
return getRowsForSubTab("armour").map(row => {
const base = extractBaseRowData(row);
const loaned = extractLoanedTo(row);
const stats = extractArmorStats(row);
return {
category: "armour",
armoryId: base.armoryId,
itemId: base.itemId,
itemName: base.itemName,
armorType: base.type || null,
armorValue: stats.armorValue,
bonus1: stats.bonus1,
bonus2: stats.bonus2,
loanColor: "grey",
loanedStatus: loaned.status,
loanedTo: loaned.name,
loanedToXid: loaned.xid,
scannedAt: Date.now()
};
});
}
function parseTemporaryPage() {
return getRowsForSubTab("temporary").map(row => {
const base = extractBaseRowData(row);
const loaned = extractLoanedTo(row);
const temp = extractTemporaryStats(row);
return {
category: "temporary",
armoryId: base.armoryId,
itemId: base.itemId,
itemName: temp.itemName || base.itemName,
quantity: temp.quantity,
loanColor: "grey",
loanedStatus: loaned.status,
loanedTo: loaned.name,
loanedToXid: loaned.xid,
scannedAt: Date.now()
};
});
}
function scanCurrentPage() {
const subTab = getArmorySubTab();
if (subTab === "weapons") return parseWeaponsPage();
if (subTab === "armour") return parseArmourPage();
if (subTab === "temporary") return parseTemporaryPage();
return [];
}
function refreshDerivedCaches() {
cachedStoredItems = null;
cachedMemberSummary = null;
recommendationCache.clear();
cachedStoredItems = flattenStoredItems();
cachedMemberSummary = buildMemberLoanSummary();
}
async function onScanClick(e) {
const btn = e.currentTarget;
const label = btn.querySelector("span");
const original = btn.innerHTML;
btn.disabled = true;
if (label) label.textContent = "Expanding...";
else btn.innerHTML = "Expanding...";
try {
const subTab = getArmorySubTab();
const start = String(getStartValue());
if (!subTab) throw new Error(`Could not determine Armory sub-tab from hash: ${location.hash}`);
await autoExpandPdaRows();
if (label) label.textContent = "Scanning...";
else btn.innerHTML = "Scanning...";
const items = scanCurrentPage();
log("Scan target:", {
href: location.href,
hash: location.hash,
subTab,
start,
candidateRows: getAllCandidateRows().length,
matchedRows: getRowsForSubTab(subTab).length,
count: items.length,
loaned: items.filter(i => i.loanedTo || i.loanedToXid).length
});
saveStoredData(subTab, start, items);
refreshDerivedCaches();
if (label) label.textContent = `Saved ${items.length}`;
else btn.innerHTML = `Saved ${items.length}`;
setTimeout(() => {
btn.innerHTML = original;
btn.disabled = false;
}, 2000);
} catch (err) {
console.error("[Fortie Armory Loan Scanner] Scan failed:", err);
if (label) label.textContent = "Failed";
else btn.innerHTML = "Failed";
setTimeout(() => {
btn.innerHTML = original;
btn.disabled = false;
}, 2000);
}
}
function getWeaponStrength(item) {
return toNum(item.damage) + toNum(item.accuracy);
}
function getArmourStrength(item) {
return toNum(item.armorValue);
}
function inferArmourSlot(itemName) {
const n = normalizeText(itemName);
if (n.includes("boot")) return "boots";
if (n.includes("helmet")) return "helmet";
if (n.includes("vest")) return "vest";
if (n.includes("glove")) return "gloves";
if (n.includes("pant") || n.includes("trouser")) return "pants";
return "defensive";
}
function formatWeaponShort(item) {
if (!item) return "";
const parts = [];
if (item.itemName) parts.push(item.itemName);
if (item.damage || item.accuracy) parts.push(`DMG ${item.damage || "?"} / ACC ${item.accuracy || "?"}`);
if (item.bonus1) parts.push(item.bonus1);
if (item.bonus2) parts.push(item.bonus2);
return parts.join(" · ");
}
function formatArmourShort(item) {
if (!item) return "";
const parts = [];
if (item.itemName) parts.push(item.itemName);
if (item.armorValue) parts.push(`ARM ${item.armorValue}`);
if (item.bonus1) parts.push(item.bonus1);
if (item.bonus2) parts.push(item.bonus2);
return parts.join(" · ");
}
function makeRecommendationCacheKey(item) {
return [
item.category || "",
item.itemName || "",
item.weaponType || "",
item.weaponClass || "",
item.damage || "",
item.accuracy || "",
item.armorType || "",
item.armorValue || "",
item.bonus1 || "",
item.bonus2 || "",
item.quantity || ""
].join("|");
}
function recommendWeaponRecipients(item, members) {
const wantedType = normalizeText(item.weaponType);
const newStrength = getWeaponStrength(item);
return members.map(member => {
const sameSlot = member.weapons.filter(w => normalizeText(w.weaponType) === wantedType);
const sameSlotWithBonus = sameSlot.filter(w => w.bonus1 || w.bonus2);
if (!sameSlot.length) {
return {
xid: member.xid,
key: member.key,
name: member.name,
score: 1400 + newStrength,
tag: "missing",
reason: `No ${item.weaponType || "weapon"} loaned`,
current: "No matching weapon"
};
}
const bestOwned = (sameSlotWithBonus.length ? sameSlotWithBonus : sameSlot)
.map(w => ({ item: w, strength: getWeaponStrength(w) }))
.sort((a, b) => b.strength - a.strength)[0];
const diff = newStrength - bestOwned.strength;
if (!sameSlotWithBonus.length) {
return {
xid: member.xid,
key: member.key,
name: member.name,
score: 1100 + newStrength,
tag: "missing",
reason: `No bonused ${item.weaponType || "weapon"} loaned`,
current: formatWeaponShort(bestOwned.item)
};
}
if (diff > 5) {
return {
xid: member.xid,
key: member.key,
name: member.name,
score: 500 + diff,
tag: "upgrade",
reason: `Upgrade over ${bestOwned.item.itemName}`,
current: formatWeaponShort(bestOwned.item)
};
}
if (diff >= -5) {
return {
xid: member.xid,
key: member.key,
name: member.name,
score: 100 + diff,
tag: "sidegrade",
reason: `Similar to ${bestOwned.item.itemName}`,
current: formatWeaponShort(bestOwned.item)
};
}
return {
xid: member.xid,
key: member.key,
name: member.name,
score: diff,
tag: "better",
reason: `Already has better ${item.weaponType || "weapon"}`,
current: formatWeaponShort(bestOwned.item)
};
}).sort((a, b) => b.score - a.score);
}
function recommendArmourRecipients(item, members) {
const wantedSlot = inferArmourSlot(item.itemName);
const newStrength = getArmourStrength(item);
return members.map(member => {
const sameSlot = member.armour.filter(a => inferArmourSlot(a.itemName) === wantedSlot);
if (!sameSlot.length) {
return {
xid: member.xid,
key: member.key,
name: member.name,
score: 1000 + newStrength,
tag: "missing",
reason: `Missing ${wantedSlot}`,
current: "No matching armour"
};
}
const bestOwned = sameSlot
.map(a => ({ item: a, strength: getArmourStrength(a) }))
.sort((a, b) => b.strength - a.strength)[0];
const diff = newStrength - bestOwned.strength;
return {
xid: member.xid,
key: member.key,
name: member.name,
score: diff > 3 ? 500 + diff : diff >= -3 ? 100 + diff : diff,
tag: diff > 3 ? "upgrade" : diff >= -3 ? "sidegrade" : "better",
reason: diff > 3 ? `Upgrade over ${bestOwned.item.itemName}` : diff >= -3 ? `Similar to ${bestOwned.item.itemName}` : `Already has better ${wantedSlot}`,
current: formatArmourShort(bestOwned.item)
};
}).sort((a, b) => b.score - a.score);
}
function getRecommendationsForTooltipItem(item) {
if (!item) return [];
const key = makeRecommendationCacheKey(item);
if (recommendationCache.has(key)) return recommendationCache.get(key);
const members = getMemberLoanSummary();
if (!members.length) {
recommendationCache.set(key, []);
return [];
}
let recs = [];
if (item.category === "weapons") recs = recommendWeaponRecipients(item, members);
if (item.category === "armour") recs = recommendArmourRecipients(item, members);
recommendationCache.set(key, recs);
return recs;
}
function renderRecommendationPanel(item, recs) {
const top = recs.slice(0, 10);
if (!top.length) {
return `
<div class="fortie-rec-wrap" data-fortie-rec="1">
<div class="fortie-rec-head">
<div class="fortie-rec-title">Suggested Members</div>
</div>
<div class="fortie-rec-empty">Scan the armoury to generate suggestions.</div>
</div>
`;
}
return `
<div class="fortie-rec-wrap" data-fortie-rec="1">
<div class="fortie-rec-head">
<div class="fortie-rec-title">Suggested Members</div>
<div class="fortie-rec-subtitle">${escapeHtml(item.itemName || "")}</div>
</div>
<div class="fortie-rec-list">
${top.map((rec, i) => {
const rankClass = i === 0 ? "gold" : i === 1 ? "silver" : i === 2 ? "bronze" : "";
const rankText = i === 0 ? "👑" : String(i + 1);
const tagClass = i === 0 ? "best" : i === 1 ? "strong" : i === 2 ? "good" : "";
const tagLabel = i === 0 ? "Best" : i === 1 ? "Strong" : i === 2 ? "Good" : "";
const currentClass = /no matching/i.test(rec.current || "") ? "no-match" : "";
const currentText = /no matching/i.test(rec.current || "") ? `⚠ ${rec.current}` : rec.current;
return `
<div class="fortie-rec-row">
<div class="fortie-rec-left">
<div class="fortie-rank ${rankClass}">${rankText}</div>
<div class="fortie-rec-main">
<div class="fortie-rec-name">${escapeHtml(rec.name)}</div>
<div class="fortie-rec-reason">${escapeHtml(rec.reason)}</div>
${rec.current ? `<div class="fortie-rec-current ${currentClass}">${escapeHtml(currentText)}</div>` : ""}
</div>
</div>
${tagClass ? `<div class="fortie-tag ${tagClass}">${tagLabel}</div>` : ""}
</div>
`;
}).join("")}
</div>
</div>
`;
}
function getOpenTooltipRoot() {
const candidates = Array.from(document.querySelectorAll(".view-item-info, .tooltip4, .item-info-content, .ui-tooltip"))
.filter(el => {
const txt = cleanText(el.textContent || "");
return txt.length > 20 && el.offsetParent !== null;
});
return candidates.sort((a, b) => (b.textContent || "").length - (a.textContent || "").length)[0] || null;
}
function findTooltipSourceRow(tooltipRoot) {
if (!tooltipRoot) return null;
const row = tooltipRoot.closest("li");
if (row?.querySelector(".img-wrap")) return row;
const action = tooltipRoot.closest("[data-armoryid]");
if (action) {
const armoryId = action.getAttribute("data-armoryid");
if (armoryId) {
const wrap = document.querySelector(`.img-wrap[data-armoryid="${armoryId}"]`);
return wrap?.closest("li") || null;
}
}
const hovered = document.querySelector("li:hover");
if (hovered?.querySelector(".img-wrap")) return hovered;
return null;
}
function getTooltipItemContext() {
const tooltipRoot = getOpenTooltipRoot();
const row = findTooltipSourceRow(tooltipRoot);
if (!row) return null;
const category = inferRowCategory(row);
const base = extractBaseRowData(row);
if (category === "weapons") {
const stats = extractWeaponStats(row);
return {
category,
itemName: base.itemName,
weaponType: detectWeaponSlot(row) || "Primary",
weaponClass: detectWeaponClass(row) || null,
damage: stats.damage,
accuracy: stats.accuracy,
bonus1: stats.bonus1,
bonus2: stats.bonus2
};
}
if (category === "armour") {
const stats = extractArmorStats(row);
return {
category,
itemName: base.itemName,
armorType: base.type || "Defensive",
armorValue: stats.armorValue,
bonus1: stats.bonus1,
bonus2: stats.bonus2
};
}
if (category === "temporary") {
const temp = extractTemporaryStats(row);
return {
category,
itemName: temp.itemName || base.itemName,
quantity: temp.quantity
};
}
return null;
}
function injectTooltipRecommendations() {
const tooltipRoot = getOpenTooltipRoot();
if (!tooltipRoot) return;
if (tooltipRoot.querySelector('[data-fortie-rec="1"]')) return;
const item = getTooltipItemContext();
if (!item) return;
if (item.category === "temporary") return;
const recs = getRecommendationsForTooltipItem(item);
tooltipRoot.insertAdjacentHTML("beforeend", renderRecommendationPanel(item, recs));
}
function scheduleTooltipInjection(delay = 180) {
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
try {
injectTooltipRecommendations();
} catch (err) {
console.error("[Fortie Armory Loan Scanner] Tooltip inject failed:", err);
}
}, delay);
}
function groupItems(items) {
return {
weapons: items.filter(i => i.category === "weapons"),
armour: items.filter(i => i.category === "armour"),
temporary: items.filter(i => i.category === "temporary")
};
}
function renderBonusPills(item) {
const bonuses = [item.bonus1, item.bonus2].filter(Boolean);
if (!bonuses.length) return "";
return `<div class="fls-item-bonuses">${bonuses.map(b => `<span class="fls-bonus-pill">${escapeHtml(b)}</span>`).join("")}</div>`;
}
function renderWeaponRow(item) {
const stats = [
item.weaponType ? `<span class="fls-item-stat"><span class="fls-item-stat-label">Type</span>${escapeHtml(item.weaponType)}</span>` : "",
item.weaponClass ? `<span class="fls-item-stat"><span class="fls-item-stat-label">Class</span>${escapeHtml(item.weaponClass)}</span>` : "",
item.damage ? `<span class="fls-item-stat"><span class="fls-item-stat-label">DMG</span>${escapeHtml(item.damage)}</span>` : "",
item.accuracy ? `<span class="fls-item-stat"><span class="fls-item-stat-label">ACC</span>${escapeHtml(item.accuracy)}</span>` : ""
].filter(Boolean).join("");
return `
<div class="fls-item weapons">
<div class="fls-item-accent"></div>
<div class="fls-item-body">
<div class="fls-item-name-row">
<div class="fls-item-name">${escapeHtml(item.itemName || "Unknown weapon")}</div>
</div>
${stats ? `<div class="fls-item-stats">${stats}</div>` : ""}
${renderBonusPills(item)}
</div>
</div>
`;
}
function renderArmourRow(item) {
const stats = [
item.armorType ? `<span class="fls-item-stat"><span class="fls-item-stat-label">Type</span>${escapeHtml(item.armorType)}</span>` : "",
item.armorValue ? `<span class="fls-item-stat"><span class="fls-item-stat-label">ARM</span>${escapeHtml(item.armorValue)}</span>` : ""
].filter(Boolean).join("");
return `
<div class="fls-item armour">
<div class="fls-item-accent"></div>
<div class="fls-item-body">
<div class="fls-item-name-row">
<div class="fls-item-name">${escapeHtml(item.itemName || "Unknown armour")}</div>
</div>
${stats ? `<div class="fls-item-stats">${stats}</div>` : ""}
${renderBonusPills(item)}
</div>
</div>
`;
}
function renderTemporaryRow(item) {
const stats = item.quantity
? `<div class="fls-item-stats"><span class="fls-item-stat"><span class="fls-item-stat-label">QTY</span>${escapeHtml(item.quantity)}</span></div>`
: "";
return `
<div class="fls-item temporary">
<div class="fls-item-accent"></div>
<div class="fls-item-body">
<div class="fls-item-name-row">
<div class="fls-item-name">${escapeHtml(item.itemName || "Unknown item")}</div>
</div>
${stats}
</div>
</div>
`;
}
const SECTION_META = {
weapons: { label: "Weapons", icon: "🔫" },
armour: { label: "Armour", icon: "🦺" },
temporary: { label: "Temporary", icon: "💣" }
};
function renderSection(key, items, renderer) {
if (!items.length) return "";
const { label, icon } = SECTION_META[key];
return `
<div class="fls-section">
<div class="fls-section-header">
<div class="fls-section-label ${key}">${icon} ${escapeHtml(label)}</div>
<div class="fls-section-rule"></div>
<div class="fls-section-count">${items.length}</div>
</div>
<div class="fls-items">${items.map(renderer).join("")}</div>
</div>
`;
}
function closeLoanedPopup() {
document.getElementById(POPUP_ID)?.remove();
document.removeEventListener("keydown", onPopupEsc, true);
}
function onPopupEsc(e) {
if (e.key === "Escape") closeLoanedPopup();
}
function showLoanedPopup(memberName, memberKey) {
closeLoanedPopup();
const items = getItemsForMember(memberKey);
const grouped = groupItems(items);
const total = items.length;
const bodyContent = total
? `${renderSection("weapons", grouped.weapons, renderWeaponRow)}
${renderSection("armour", grouped.armour, renderArmourRow)}
${renderSection("temporary", grouped.temporary, renderTemporaryRow)}`
: `<div class="fls-empty">
<div class="fls-empty-icon">📦</div>
No saved loaned items found for this member.<br>Scan the Armory pages first.
</div>`;
const popup = document.createElement("div");
popup.id = POPUP_ID;
popup.innerHTML = `
<div class="fls-card">
<div class="fls-accent"></div>
<div class="fls-header">
<div class="fls-header-icon">AR</div>
<div class="fls-header-text">
<div class="fls-header-title">${escapeHtml(memberName)}</div>
<div class="fls-header-sub"><b>${escapeHtml(memberKey)}</b> · ${total} loaned item${total !== 1 ? "s" : ""}</div>
</div>
<button type="button" class="fls-close" aria-label="Close">✕</button>
</div>
<div class="fls-body">${bodyContent}</div>
</div>
`;
document.body.appendChild(popup);
popup.addEventListener("click", e => {
if (e.target === popup) closeLoanedPopup();
});
popup.querySelector(".fls-close")?.addEventListener("click", closeLoanedPopup);
document.addEventListener("keydown", onPopupEsc, true);
}
function getMemberRows() {
return Array.from(document.querySelectorAll(`
li.table-row,
ul.members-list > li,
.members-list > li,
.f-war-list > li,
.user-info-list > li,
[class*="member"] li.table-row
`)).filter(row => row.querySelector('a[href*="profiles.php?XID="]'));
}
function removeLoanedItemButtons() {
document.querySelectorAll('[data-fortie-loan-button="1"]').forEach(el => el.remove());
}
function ensureLoanedItemButtons() {
if (!isFactionInfoPage()) {
removeLoanedItemButtons();
return;
}
for (const row of getMemberRows()) {
if (!(row instanceof HTMLElement)) continue;
if (row.querySelector('[data-fortie-loan-button="1"]')) continue;
const profileLink = row.querySelector('a[href*="profiles.php?XID="]');
const memberXid = xidFromHref(profileLink?.getAttribute("href"));
if (!memberXid) continue;
const memberName = cleanText(
row.querySelector(".honor-text")?.textContent ||
row.querySelector(".name")?.textContent ||
row.querySelector('[class*="userName"]')?.textContent ||
row.querySelector('[class*="name"]')?.textContent ||
profileLink?.textContent ||
`Member ${memberXid}`
);
const target =
row.querySelector("li.table-cell.member-icons") ||
row.querySelector(".table-cell.member-icons") ||
profileLink?.parentElement;
if (!target) continue;
const slot = document.createElement("span");
slot.dataset.fortieLoanButton = "1";
slot.className = "fortie-loan-country-slot";
slot.title = `Loaned Items — ${memberName}`;
slot.innerHTML = `<button type="button" class="fortie-loan-country-btn" aria-label="Loaned items for ${escapeHtml(memberName)}">AR</button>`;
slot.querySelector("button")?.addEventListener("click", ev => {
ev.preventDefault();
ev.stopPropagation();
showLoanedPopup(memberName, memberXid);
});
target.appendChild(slot);
}
}
function scanButtonHtml() {
return `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10.5 4a6.5 6.5 0 1 0 4.06 11.58l3.43 3.43 1.41-1.41-3.43-3.43A6.5 6.5 0 0 0 10.5 4Zm0 2a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Z"/>
</svg>
<span>Scan</span>
`;
}
function getItemTitleHeader() {
return Array.from(document.querySelectorAll("li.item-title"))
.find(el => cleanText(el.textContent).toLowerCase() === "item") || null;
}
function getPdaArmoryHeader() {
const exact = "manage armories and deposit to faction";
return Array.from(document.querySelectorAll("h1, h2, h3, h4, div, span, li"))
.filter(el => {
if (!(el instanceof HTMLElement)) return false;
if (!el.offsetParent) return false;
if (el.querySelector(`#${PDA_SCAN_BTN_ID}`)) return false;
const directText = cleanText(
Array.from(el.childNodes)
.filter(n => n.nodeType === Node.TEXT_NODE)
.map(n => n.textContent)
.join(" ")
).toLowerCase();
return directText === exact;
})
.sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return (ar.width * ar.height) - (br.width * br.height);
})[0] || null;
}
function removeOrphanScanButtons() {
document.querySelectorAll(`#${SCAN_BTN_ID}`).forEach(btn => {
const parent = btn.parentElement;
if (!parent || !parent.matches("li.item-title")) btn.remove();
});
if (!isSupportedArmoryPage()) {
document.querySelectorAll(`#${PDA_SCAN_BTN_ID}`).forEach(btn => btn.remove());
}
}
function ensureScanButton() {
removeOrphanScanButtons();
if (!isSupportedArmoryPage()) return;
const itemTitle = getItemTitleHeader();
if (itemTitle && !itemTitle.querySelector(`#${SCAN_BTN_ID}`)) {
itemTitle.style.display = "flex";
itemTitle.style.alignItems = "center";
itemTitle.style.gap = "10px";
const btn = document.createElement("button");
btn.id = SCAN_BTN_ID;
btn.type = "button";
btn.title = `Scan this ${getArmorySubTab()} page`;
btn.innerHTML = scanButtonHtml();
btn.addEventListener("click", onScanClick);
itemTitle.appendChild(btn);
}
ensurePdaScanButton();
}
function ensurePdaScanButton() {
if (!isSupportedArmoryPage()) return;
if (document.getElementById(PDA_SCAN_BTN_ID)) return;
const header = getPdaArmoryHeader();
if (!header) return;
header.style.display = "flex";
header.style.alignItems = "center";
header.style.justifyContent = "space-between";
header.style.gap = "10px";
const btn = document.createElement("button");
btn.id = PDA_SCAN_BTN_ID;
btn.type = "button";
btn.title = `Scan this ${getArmorySubTab()} page`;
btn.innerHTML = scanButtonHtml();
btn.addEventListener("click", onScanClick);
header.appendChild(btn);
}
function getPageSignature() {
return [
location.pathname,
location.search,
location.hash,
isFactionInfoPage() ? "info" : "",
getArmorySubTab() || ""
].join("|");
}
function bindLightTooltipTriggers() {
if (tooltipBindDone) return;
tooltipBindDone = true;
const handler = e => {
const target = e.target;
if (!(target instanceof Element)) return;
const row = target.closest("li");
if (!row) return;
if (!row.querySelector(".img-wrap")) return;
if (!isSupportedArmoryPage()) return;
scheduleTooltipInjection(220);
scheduleTooltipInjection(420);
};
document.addEventListener("mouseover", handler, true);
document.addEventListener("focusin", handler, true);
document.addEventListener("click", handler, true);
}
function onPageUpdate(force = false) {
applyFortieThemeBridge();
addStyles();
bindLightTooltipTriggers();
const sig = getPageSignature();
const pageChanged = sig !== lastPageSignature;
if (pageChanged || force) lastPageSignature = sig;
if (isSupportedArmoryPage()) ensureScanButton();
else removeOrphanScanButtons();
ensureLoanedItemButtons();
}
function startObserver() {
if (observer) observer.disconnect();
observer = new MutationObserver(mutations => {
const onlyTooltipChanges = mutations.every(m => {
const target = m.target instanceof Element ? m.target : null;
return target?.closest?.(".view-item-info, .tooltip4, .item-info-content, .ui-tooltip");
});
if (onlyTooltipChanges) return;
clearTimeout(updateTimer);
updateTimer = setTimeout(() => onPageUpdate(false), 180);
});
observer.observe(document.body, { childList: true, subtree: true });
window.addEventListener("hashchange", () => setTimeout(() => onPageUpdate(true), 300));
window.addEventListener("popstate", () => setTimeout(() => onPageUpdate(true), 300));
document.addEventListener("visibilitychange", () => {
if (!document.hidden) onPageUpdate(true);
});
if (uiHeartbeat) clearInterval(uiHeartbeat);
uiHeartbeat = setInterval(() => {
try {
applyFortieThemeBridge();
if (!isFactionPage()) return;
if (isSupportedArmoryPage()) {
ensureScanButton();
ensurePdaScanButton();
} else {
removeOrphanScanButtons();
}
ensureLoanedItemButtons();
} catch (err) {
console.error("[Fortie Armory Loan Scanner] UI heartbeat failed:", err);
}
}, 1200);
onPageUpdate(true);
}
function init() {
if (!document.body) {
requestAnimationFrame(init);
return;
}
applyFortieThemeBridge();
refreshDerivedCaches();
startObserver();
log("Loaded");
}
init();
})();