Crime sort

Sort crime cards by completion progress

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();