Sort crime cards by completion progress
// ==UserScript==
// @name Crime sort
// @namespace torn-suite
// @version 1.0.0
// @description Sort crime cards by completion progress
// @author Antonio_Balloni[3853029]
// @match https://www.torn.com/*
// @grant none
// @connect api.torn.com
// @run-at document-idle
// @license MIT
// ==/UserScript==
/* jshint esversion: 11 */
(function () {
"use strict";
const LS_KEY = "tornSuite-crimes-sort-apikey";
const CRIMES_PAGE = "sid=crimes";
const API_PREFS_URL =
"https://www.torn.com/preferences.php#tab=api?step=addNewKey&title=Crime%20Sort&user=skills";
function hrefPathToSkillKey(path) {
if (path === "searchforcash") return "search_for_cash";
if (path === "cardskimming") return "card_skimming";
return path;
}
async function fetchSkills(key) {
const url = `https://api.torn.com/user/?selections=skills&key=${encodeURIComponent(key)}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
console.warn("[Torn Suite crimes-sort] API error:", data.error);
return { valid: false, skills: null };
}
const skills =
data.skills && typeof data.skills === "object" ? data.skills : data;
return { valid: true, skills };
}
function isCrimesHubPage() {
const hash = window.location.hash;
return (
window.location.href.includes(CRIMES_PAGE) &&
(hash === "" || hash === "#/" || hash === "#")
);
}
function getCrimesContainer() {
return document.querySelector('[class*="crimeTypes"]');
}
function getCrimeCards() {
const container = getCrimesContainer();
if (!container) return [];
return Array.from(container.querySelectorAll('a[href^="#/"]')).filter(
(card) =>
card.querySelector('[class*="barFill"]') &&
card.querySelector('[class*="crimeTitle"]'),
);
}
function isMaxLevel(card) {
return card.classList.contains("max-level");
}
function getGroupLevel(card) {
const levelStar = card.querySelector('[class*="levelStar"]');
if (!levelStar) return 0;
const m = levelStar.className.match(/group(\d)/);
return m ? parseInt(m[1], 10) : 0;
}
function getRowLevel(card) {
const levelStar = card.querySelector('[class*="levelStar"]');
if (!levelStar) return 0;
const numberEl = levelStar.querySelector('[class*="number___"]');
if (!numberEl) return 0;
const m = numberEl.className.match(/row(\d+)/);
return m ? parseInt(m[1], 10) : 0;
}
function getBarFillPercent(card) {
const barFill = card.querySelector('[class*="barFill"]');
if (!barFill) return 0;
const m = (barFill.getAttribute("style") || "").match(
/width:\s*(\d+(?:\.\d+)?)/,
);
return m ? parseFloat(m[1]) : 0;
}
function getDomCompletionScore(card) {
return (
getGroupLevel(card) * 10000 +
getRowLevel(card) * 100 +
getBarFillPercent(card)
);
}
function getSkillScore(card, skills) {
if (!skills || typeof skills !== "object") return null;
const href = (card.getAttribute("href") || "").replace("#/", "");
const skillKey = hrefPathToSkillKey(href);
const raw = skills[skillKey];
if (raw === undefined || raw === null) return null;
const score = parseFloat(String(raw));
return Number.isFinite(score) ? score : null;
}
function sortCrimesByCompletion(skills) {
const container = getCrimesContainer();
if (!container) return;
const cards = getCrimeCards();
if (cards.length === 0) return;
const lockedElements = Array.from(container.children).filter(
(el) => el.tagName === "DIV" && el.className.includes("crimeTypeLocked"),
);
const incomplete = [];
const completed = [];
for (const card of cards) {
const skillScore = getSkillScore(card, skills);
const score =
skillScore !== null ? skillScore : getDomCompletionScore(card);
const maxed =
isMaxLevel(card) || (skillScore !== null && skillScore >= 100);
(maxed ? completed : incomplete).push({ element: card, score });
}
incomplete.sort((a, b) => b.score - a.score);
const fragment = document.createDocumentFragment();
for (const { element } of [...incomplete, ...completed])
fragment.appendChild(element);
for (const el of lockedElements) fragment.appendChild(el);
container.replaceChildren(fragment);
}
async function hideFetchSortShow(apiKey) {
const container = getCrimesContainer();
if (!container) return;
Object.assign(container.style, {
visibility: "hidden",
opacity: "0",
position: "absolute",
pointerEvents: "none",
});
try {
const { skills } = await fetchSkills(apiKey);
sortCrimesByCompletion(skills ?? null);
} finally {
Object.assign(container.style, {
visibility: "",
opacity: "",
position: "",
pointerEvents: "",
});
}
}
function waitForCrimesAndSort(apiKey) {
return new Promise((resolve) => {
const MAX_ATTEMPTS = 100;
const STABLE_THRESHOLD = 5;
let attempts = 0;
let lastCardCount = 0;
let stableCount = 0;
const tick = () => {
if (!isCrimesHubPage()) return resolve();
const cards = getCrimeCards();
if (cards.length > 0) {
if (cards.length === lastCardCount) stableCount++;
else {
stableCount = 0;
lastCardCount = cards.length;
}
if (stableCount >= STABLE_THRESHOLD) {
hideFetchSortShow(apiKey).finally(resolve);
return;
}
}
if (attempts++ < MAX_ATTEMPTS) setTimeout(tick, 100);
else resolve();
};
tick();
});
}
function startCrimesSorting(apiKey) {
if (!window.location.href.includes(CRIMES_PAGE)) return;
let sortPromise = null;
let debounceTimer = null;
const onHashChange = () => {
if (!isCrimesHubPage()) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (sortPromise) return;
sortPromise = waitForCrimesAndSort(apiKey).finally(() => {
sortPromise = null;
});
}, 200);
};
window.addEventListener("hashchange", onHashChange);
if (isCrimesHubPage()) {
sortPromise = waitForCrimesAndSort(apiKey).finally(() => {
sortPromise = null;
});
}
}
function showKeyToast(onVerified) {
if (document.getElementById("tornsuite-crimes-sort-toast")) return;
const mount = () => {
if (!document.body) {
requestAnimationFrame(mount);
return;
}
const wrap = document.createElement("div");
wrap.id = "tornsuite-crimes-sort-toast";
wrap.style.cssText =
"width:100%;box-sizing:border-box;padding:14px 16px;background:#0f172a;color:#e2e8f0;" +
"border-bottom:2px solid #6366f1;font:14px/1.4 system-ui,sans-serif;";
wrap.innerHTML =
'<div style="max-width:720px;margin:0 auto;">' +
'<div style="margin-bottom:10px;">' +
'<div style="font-size:18px;font-weight:700;color:#f8fafc;">Crime Sort</div>' +
'<div style="font-size:12px;margin-top:2px;">' +
'<a id="tornsuite-crimes-sort-author" href="https://www.torn.com/profiles.php?XID=3853029" ' +
'target="_blank" rel="noopener noreferrer" style="color:#818cf8;text-decoration:none;">' +
"by Antonio_Balloni</a>" +
"</div>" +
"</div>" +
'<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;">' +
'<label for="tornsuite-crimes-sort-key" style="font-weight:600;">API key</label>' +
'<input id="tornsuite-crimes-sort-key" type="password" autocomplete="off" placeholder="Paste key here" ' +
'style="flex:1;min-width:200px;padding:8px 10px;border-radius:6px;border:1px solid #334155;background:#020617;color:#f8fafc;" />' +
'<button type="button" id="tornsuite-crimes-sort-action" ' +
'style="padding:8px 14px;border-radius:6px;border:none;background:#6366f1;color:#fff;font-weight:600;cursor:pointer;">' +
"Get API Key</button>" +
"</div>" +
'<span id="tornsuite-crimes-sort-msg" style="display:block;margin-top:8px;font-size:12px;color:#94a3b8;"></span>' +
"</div>";
document.body.insertBefore(wrap, document.body.firstChild);
const input = wrap.querySelector("#tornsuite-crimes-sort-key");
const btn = wrap.querySelector("#tornsuite-crimes-sort-action");
const msg = wrap.querySelector("#tornsuite-crimes-sort-msg");
const syncButton = () => {
btn.textContent =
(input.value || "").trim().length > 0
? "Save API Key"
: "Get API Key";
};
input.addEventListener("input", syncButton);
syncButton();
btn.addEventListener("click", async () => {
const key = (input.value || "").trim();
if (!key) {
window.open(API_PREFS_URL, "_blank", "noopener,noreferrer");
return;
}
msg.textContent = "Verifying…";
try {
const { valid } = await fetchSkills(key);
if (!valid) {
msg.textContent = "Invalid key or API error.";
return;
}
localStorage.setItem(LS_KEY, key);
wrap.remove();
onVerified(key);
} catch {
msg.textContent = "Network error. Please try again.";
}
});
};
mount();
}
async function main() {
const stored = (localStorage.getItem(LS_KEY) || "").trim();
if (!stored) {
showKeyToast(startCrimesSorting);
return;
}
try {
const { valid } = await fetchSkills(stored);
if (!valid) {
localStorage.removeItem(LS_KEY);
showKeyToast(startCrimesSorting);
return;
}
startCrimesSorting(stored);
} catch (e) {
console.error("[Torn Suite crimes-sort]", e);
}
}
main();
})();