Torn Gear Quality Visualizer

This script gives players a quick visual guide to how good a weapon or armor roll is by colour-coding the quality directly onto the item image with a heat map based font colouring.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn Gear Quality Visualizer
// @namespace    http://tampermonkey.net/
// @version      2.2.3
// @description  This script gives players a quick visual guide to how good a weapon or armor roll is by colour-coding the quality directly onto the item image with a heat map based font colouring.
// @author       Insomnis [3815182]
// @license      MIT
// @match        https://www.torn.com/page.php?sid=ItemMarket*
// @match        https://www.torn.com/bazaar.php*
// @match        https://www.torn.com/item.php*
// @match        https://www.torn.com/dump.php*
// @match        https://www.torn.com/factions.php*
// @match        https://www.torn.com/amarket.php*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  if (window.__TORN_HEAT_RUNNING__) return;
  window.__TORN_HEAT_RUNNING__ = true;

  /** ==================== EMBEDDED LIBRARIES ==================== **/
  // Weapons: native stat ranges (damage + accuracy)
  const WEAPON_STAT_RANGES = {
    // ===== PRIMARY =====
    "9mm Uzi": { dMin:65, dMax:70, aMin:43, aMax:48 },
    "AK-47": { dMin:56, dMax:61, aMin:52, aMax:57 },
    "AK74U": { dMin:46, dMax:51, aMin:41, aMax:46 },
    "ArmaLite M-15A4": { dMin:68, dMax:73, aMin:57, aMax:62 },
    "Benelli M1 Tactical": { dMin:39, dMax:44, aMin:65, aMax:70 },
    "Benelli M4 Super": { dMin:59, dMax:64, aMin:55, aMax:60 },
    "Bushmaster Carbon 15": { dMin:50, dMax:55, aMin:57, aMax:62 },
    "Dual Bushmasters": { dMin:76, dMax:81, aMin:47, aMax:52 },
    "Dual MP5s": { dMin:78, dMax:83, aMin:46, aMax:51 },
    "Dual P90s": { dMin:77, dMax:82, aMin:45, aMax:50 },
    "Dual TMPs": { dMin:79, dMax:84, aMin:40, aMax:45 },
    "Dual Uzis": { dMin:80, dMax:85, aMin:36, aMax:41 },
    "Egg Propelled Launcher": { dMin:64, dMax:69, aMin:24, aMax:29 },
    "Enfield SA-80": { dMin:63, dMax:68, aMin:55, aMax:60 },
    "Gold Plated AK-47": { dMin:75, dMax:80, aMin:62, aMax:67 },
    "Heckler & Koch SL8": { dMin:60, dMax:65, aMin:46, aMax:51 },
    "Ithaca 37": { dMin:49, dMax:54, aMin:62, aMax:67 },
    "Jackhammer": { dMin:69, dMax:74, aMin:52, aMax:57 },
    "M16 A2 Rifle": { dMin:61, dMax:66, aMin:47, aMax:52 },
    "M249 SAW": { dMin:67, dMax:72, aMin:41, aMax:46 },
    "M4A1 Colt Carbine": { dMin:55, dMax:60, aMin:47, aMax:52 },
    "Mag 7": { dMin:56, dMax:61, aMin:62, aMax:67 },
    "Minigun": { dMin:72, dMax:77, aMin:28, aMax:33 },
    "MP 40": { dMin:37, dMax:42, aMin:41, aMax:46 },
    "MP5 Navy": { dMin:45, dMax:50, aMin:51, aMax:56 },
    "Negev NG-5": { dMin:69, dMax:74, aMin:35, aMax:40 },
    "Nock Gun": { dMin:95, dMax:100, aMin:45, aMax:50 },
    "P90": { dMin:48, dMax:53, aMin:51, aMax:56 },
    "PKM": { dMin:76, dMax:79, aMin:49, aMax:51 },
    "Prototype": { dMin:68, dMax:73, aMin:36, aMax:41 },
    "Rheinmetall MG 3": { dMin:66, dMax:71, aMin:36, aMax:41 },
    "Sawed-Off Shotgun": { dMin:41, dMax:46, aMin:63, aMax:68 },
    "SIG 552": { dMin:69, dMax:74, aMin:50, aMax:55 },
    "SKS Carbine": { dMin:46, dMax:51, aMin:47, aMax:52 },
    "Snow Cannon": { dMin:52, dMax:57, aMin:24, aMax:29 },
    "Steyr AUG": { dMin:64, dMax:69, aMin:45, aMax:50 },
    "Stoner 96": { dMin:69, dMax:74, aMin:49, aMax:54 },
    "Tavor TAR-21": { dMin:65, dMax:70, aMin:52, aMax:57 },
    "Thompson": { dMin:39, dMax:44, aMin:43, aMax:48 },
    "Vektor CR-21": { dMin:50, dMax:55, aMin:48, aMax:53 },
    "XM8 Rifle": { dMin:50, dMax:55, aMin:56, aMax:61 },

    // ===== SECONDARY =====
    "Type 98 Anti Tank": { dMin:78, dMax:83, aMin:25, aMax:30 },
    "Beretta 92FS": { dMin:48, dMax:53, aMin:51, aMax:56 },
    "Beretta M9": { dMin:36, dMax:41, aMin:54, aMax:59 },
    "Beretta Pico": { dMin:54, dMax:59, aMin:53, aMax:58 },
    "Blowgun": { dMin:15, dMax:20, aMin:39, aMax:44 },
    "Blunderbuss": { dMin:46, dMax:51, aMin:24, aMax:29 },
    "BT MP9": { dMin:61, dMax:66, aMin:55, aMax:60 },
    "China Lake": { dMin:79, dMax:87, aMin:47, aMax:53 },
    "Cobra Derringer": { dMin:61, dMax:66, aMin:53, aMax:58 },
    "Crossbow": { dMin:35, dMax:40, aMin:63, aMax:68 },
    "Desert Eagle": { dMin:59, dMax:64, aMin:36, aMax:41 },
    "Dual 92G Berettas": { dMin:64, dMax:69, aMin:30, aMax:35 },
    "Fiveseven": { dMin:52, dMax:57, aMin:49, aMax:54 },
    "Flare Gun": { dMin:18, dMax:23, aMin:22, aMax:27 },
    "Glock 17": { dMin:28, dMax:33, aMin:53, aMax:58 },
    "Lorcin 380": { dMin:27, dMax:32, aMin:41, aMax:46 },
    "Luger": { dMin:35, dMax:40, aMin:48, aMax:53 },
    "Magnum": { dMin:55, dMax:60, aMin:38, aMax:43 },
    "MP5k": { dMin:42, dMax:47, aMin:52, aMax:57 },
    "Pink Mac-10": { dMin:74, dMax:79, aMin:45, aMax:50 },
    "Qsz-92": { dMin:62, dMax:67, aMin:53, aMax:58 },
    "Raven MP25": { dMin:29, dMax:34, aMin:52, aMax:57 },
    "Ruger 57": { dMin:32, dMax:37, aMin:56, aMax:61 },
    "S&W M29": { dMin:47, dMax:52, aMin:52, aMax:57 },
    "S&W Revolver": { dMin:42, dMax:47, aMin:54, aMax:59 },
    "Skorpion": { dMin:40, dMax:45, aMin:54, aMax:59 },
    "Springfield 1911": { dMin:33, dMax:38, aMin:57, aMax:62 },
    "Taurus": { dMin:30, dMax:35, aMin:57, aMax:62 },
    "TMP": { dMin:38, dMax:43, aMin:45, aMax:50 },
    "USP": { dMin:44, dMax:49, aMin:58, aMax:63 },

    // ===== SECONDARY (ADDITIONS) =====
    "Flamethrower": { dMin:67, dMax:72, aMin:39, aMax:44 },
    "Harpoon": { dMin:47, dMax:52, aMin:63, aMax:68 },
    "Homemade Pocket Shotgun": { dMin:63, dMax:68, aMin:60, aMax:65 },
    "Milkor MGL": { dMin:74, dMax:79, aMin:39, aMax:44 },
    "RPG Launcher": { dMin:77, dMax:82, aMin:39, aMax:44 },
    "Slingshot": { dMin:14, dMax:18, aMin:54, aMax:59 },
    "Taser": { dMin:1, dMax:5, aMin:54, aMax:59 },
    "Tranquilizer Gun": { dMin:15, dMax:20, aMin:45, aMax:50 },

    // ===== MELEE =====
    "Axe": { dMin: 34, dMax: 39, aMin: 52, aMax: 57 },
    "Baseball Bat": { dMin: 16, dMax: 21, aMin: 57, aMax: 62 },
    "Blood Spattered Sickle": { dMin: 36, dMax: 41, aMin: 55, aMax: 60 },
    "Bone Saw": { dMin: 54, dMax: 59, aMin: 52, aMax: 57 },
    "Bo Staff": { dMin: 13, dMax: 18, aMin: 55, aMax: 60 },
    "Bread Knife": { dMin: 41, dMax: 43, aMin: 65, aMax: 70 },
    "Bug Swatter": { dMin: 5, dMax: 10, aMin: 59, aMax: 64 },
    "Butterfly Knife": { dMin: 24, dMax: 29, aMin: 55, aMax: 60 },
    "Cattle Prod": { dMin: 1, dMax: 6, aMin: 59, aMax: 64 },
    "Chain Whip": { dMin: 31, dMax: 36, aMin: 52, aMax: 57 },
    "Chainsaw": { dMin: 61, dMax: 66, aMin: 23, aMax: 28 },
    "Claymore Sword": { dMin: 57, dMax: 62, aMin: 49, aMax: 54 },
    "Cleaver": { dMin: 51, dMax: 56, aMin: 56, aMax: 61 },
    "Cricket Bat": { dMin: 18, dMax: 23, aMin: 42, aMax: 47 },
    "Crowbar": { dMin: 20, dMax: 25, aMin: 52, aMax: 57 },
    "Dagger": { dMin: 28, dMax: 33, aMin: 60, aMax: 65 },
    "Devil's Pitchfork": { dMin: 61, dMax: 66, aMin: 41, aMax: 46 },
    "Diamond Bladed Knife": { dMin: 60, dMax: 65, aMin: 62, aMax: 67 },
    "Diamond Icicle": { dMin: 45, dMax: 50, aMin: 48, aMax: 53 },
    "Dual Axes": { dMin: 70, dMax: 75, aMin: 54, aMax: 59 },
    "Dual Hammers": { dMin: 70, dMax: 75, aMin: 54, aMax: 59 },
    "Dual Samurai Swords": { dMin: 70, dMax: 75, aMin: 54, aMax: 59 },
    "Dual Scimitars": { dMin: 70, dMax: 75, aMin: 54, aMax: 59 },
    "Duke's Hammer": { dMin: 18, dMax: 18, aMin: 55, aMax: 55 },
    "Fine Chisel": { dMin: 16, dMax: 21, aMin: 50, aMax: 55 },
    "Flail": { dMin: 71, dMax: 76, aMin: 28, aMax: 33 },
    "Frying Pan": { dMin: 19, dMax: 24, aMin: 43, aMax: 48 },
    "Golden Broomstick": { dMin: 60, dMax: 65, aMin: 48, aMax: 53 },
    "Golf Club": { dMin: 29, dMax: 34, aMin: 59, aMax: 64 },
    "Guandao": { dMin: 63, dMax: 68, aMin: 35, aMax: 40 },
    "Hammer": { dMin: 17, dMax: 22, aMin: 55, aMax: 60 },
    "Handbag": { dMin: 67, dMax: 72, aMin: 63, aMax: 68 },
    "Ice Pick": { dMin: 51, dMax: 56, aMin: 60, aMax: 65 },
    "Ivory Walking Cane": { dMin: 53, dMax: 58, aMin: 57, aMax: 62 },
    "Kama": { dMin: 35, dMax: 40, aMin: 55, aMax: 60 },
    "Katana": { dMin: 52, dMax: 57, aMin: 55, aMax: 60 },
    "Kitchen Knife": { dMin: 25, dMax: 30, aMin: 55, aMax: 60 },
    "Knuckle Dusters": { dMin: 11, dMax: 16, aMin: 62, aMax: 67 },
    "Kodachi": { dMin: 62, dMax: 67, aMin: 56, aMax: 61 },
    "Lead Pipe": { dMin: 26, dMax: 31, aMin: 33, aMax: 38 },
    "Leather Bullwhip": { dMin: 27, dMax: 32, aMin: 52, aMax: 57 },
    "Macana": { dMin: 57, dMax: 62, aMin: 65, aMax: 70 },
    "Madball": { dMin: 60, dMax: 65, aMin: 45, aMax: 50 },
    "Meat Hook": { dMin: 62, dMax: 67, aMin: 39, aMax: 44 },
    "Metal Nunchaku": { dMin: 61, dMax: 66, aMin: 60, aMax: 65 },
    "Naval Cutlass": { dMin: 64, dMax: 69, aMin: 52, aMax: 57 },
    "Ninja Claws": { dMin: 39, dMax: 44, aMin: 51, aMax: 56 },
    "Pair of High Heels": { dMin: 40, dMax: 45, aMin: 63, aMax: 68 },
    "Pair of Ice Skates": { dMin: 43, dMax: 48, aMin: 45, aMax: 50 },
    "Pen Knife": { dMin: 21, dMax: 26, aMin: 45, aMax: 50 },
    "Penelope": { dMin: 17, dMax: 17, aMin: 57, aMax: 57 },
    "Petrified Humerus": { dMin: 48, dMax: 53, aMin: 48, aMax: 53 },
    "Pillow": { dMin: 1, dMax: 5.3, aMin: 63, aMax: 68 },
    "Plastic Sword": { dMin: 5, dMax: 10, aMin: 29, aMax: 34 },
    "Poison Umbrella": { dMin: 35, dMax: 40, aMin: 49, aMax: 54 },
    "Riding Crop": { dMin: 21, dMax: 26, aMin: 54, aMax: 59 },
    "Rusty Sword": { dMin: 22, dMax: 27, aMin: 15, aMax: 20 },
    "Sai": { dMin: 29, dMax: 34, aMin: 52, aMax: 57 },
    "Samurai Sword": { dMin: 58, dMax: 63, aMin: 52, aMax: 57 },
    "Scalpel": { dMin: 56, dMax: 61, aMin: 47, aMax: 52 },
    "Scimitar": { dMin: 40, dMax: 45, aMin: 58, aMax: 63 },
    "Sledgehammer": { dMin: 58, dMax: 63, aMin: 50, aMax: 55 },
    "Spear": { dMin: 38, dMax: 43, aMin: 48, aMax: 53 },
    "Swiss Army Knife": { dMin: 23, dMax: 28, aMin: 52, aMax: 57 },
    "Twin Tiger Hooks": { dMin: 50, dMax: 55, aMin: 53, aMax: 58 },
    "Wand of Destruction": { dMin: 60, dMax: 65, aMin: 26, aMax: 31 },
    "Wooden Nunchaku": { dMin: 22, dMax: 27, aMin: 59, aMax: 64 },
    "Wushu Double Axes": { dMin: 53, dMax: 58, aMin: 51, aMax: 56 },
    "Yasukuni Sword": { dMin: 65, dMax: 70, aMin: 49, aMax: 54 },
  };

  // Armor: armor rating min/max (bazaar label is "defence")
  const ARMOR_STAT_RANGES = {
    "Assault Boots": { min:46.00, max:51.00 },
    "Assault Gloves": { min:46.00, max:51.00 },
    "Assault Helmet": { min:46.00, max:51.00 },
    "Assault Pants": { min:46.00, max:51.00 },
    "Assault Body": { min:46.00, max:51.00 },

    "Bulletproof Vest": { min:34.00, max:39.00 },
    "Chain Mail": { min:23.00, max:28.00 },

    "Combat Boots": { min:38.00, max:43.00 },
    "Combat Gloves": { min:38.00, max:43.00 },
    "Combat Helmet": { min:38.00, max:43.00 },
    "Combat Pants": { min:38.00, max:43.00 },
    "Combat Vest": { min:38.00, max:43.00 },

    "Delta Boots": { min:49.00, max:54.00 },
    "Delta Gloves": { min:49.00, max:54.00 },
    "Delta Gas Mask": { min:49.00, max:54.00 },
    "Delta Pants": { min:49.00, max:54.00 },
    "Delta Body": { min:49.00, max:54.00 },

    "Dune Boots": { min:44.00, max:49.00 },
    "Dune Gloves": { min:44.00, max:49.00 },
    "Dune Helmet": { min:44.00, max:49.00 },
    "Dune Pants": { min:44.00, max:49.00 },
    "Dune Vest": { min:44.00, max:49.00 },

    "Construction Helmet": { min:30.00, max:35.00 },

    "EOD Boots": { min:55.00, max:60.00 },
    "EOD Gloves": { min:55.00, max:60.00 },
    "EOD Helmet": { min:55.00, max:60.00 },
    "EOD Pants": { min:55.00, max:60.00 },
    "EOD Apron": { min:55.00, max:60.00 },

    "Flak Jacket": { min:30.00, max:35.00 },
    "Flexible Body Armor": { min:42.00, max:47.00 },
    "Full Body Armor": { min:31.00, max:36.00 },
    "Hazmat Suit": { min:10.00, max:15.00 },
    "Hiking Boots": { min:24.00, max:29.00 },
    "Kevlar Gloves": { min:32.00, max:37.00 },

    "Leather Boots": { min:20.00, max:25.00 },
    "Leather Gloves": { min:20.00, max:25.00 },
    "Leather Helmet": { min:20.00, max:25.00 },
    "Leather Pants": { min:20.00, max:25.00 },
    "Leather Vest": { min:20.00, max:25.00 },

    "Motorcycle Helmet": { min:30.00, max:35.00 },
    "Police Vest": { min:32.00, max:37.00 },

    "Riot Boots": { min:45.00, max:50.00 },
    "Riot Gloves": { min:45.00, max:50.00 },
    "Riot Helmet": { min:35.00, max:40.00 },
    "Riot Pants": { min:45.00, max:50.00 },
    "Riot Body": { min:45.00, max:50.00 },

    "Safety Boots": { min:30.00, max:35.00 },

    "Sentinel Helmet": { min:53.00, max:58.00 },
    "Sentinel Apron": { min:53.00, max:58.00 },
    "Sentinel Pants": { min:53.00, max:58.00 },
    "Sentinel Gloves": { min:53.00, max:58.00 },
    "Sentinel Boots": { min:53.00, max:58.00 },

    "Vanguard Respirator": { min:48.00, max:53.00 },
    "Vanguard Body": { min:48.00, max:53.00 },
    "Vanguard Pants": { min:48.00, max:53.00 },
    "Vanguard Gloves": { min:48.00, max:53.00 },
    "Vanguard Boots": { min:48.00, max:53.00 },

    "Welding Helmet": { min:34.00, max:39.00 },
    "WWII Helmet": { min:34.00, max:39.00 },

    "Liquid Body Armor": { min:40.00, max:45.00 },
    "Marauder Face Mask": { min:40.00, max:45.00 },
    "Marauder Boots": { min:52.00, max:57.00 },
    "Marauder Gloves": { min:52.00, max:57.00 },
    "Marauder Pants": { min:52.00, max:57.00 },
    "Marauder Body": { min:52.00, max:57.00 },
    "Medieval Helmet": { min:25.00, max:30.00 },
    "Outer Tactical Vest": { min:36.00, max:41.00 },
  };


  /** ==================== SCRIPT CONFIG ==================== **/
  const STEP = 0.01;          // 1% increments
  const HEAT_MAX = 2.00;      // anything beyond is clamped to red

  /** ==================== SELECTORS ==================== **/
  // Item Market
  const IM_TILE_SELECTOR = "li.tt-highlight-modified";
  const IM_NAME_SELECTOR = ".name___ukdHN";

  // Bazaar
  const BZ_ITEM_SELECTOR = 'div[data-testid="item"]';
  const BZ_NAME_SELECTOR = 'p[data-testid="name"]';
  const BZ_STATS_SELECTOR = ".infoBonuses___g8QdG";

  // item.php
  const IP_TILE_SELECTOR = "ul.itemsList > li";
  const IP_NAME_SELECTOR = ".title-wrap .name";
  const IP_NAME_SELECTOR_2 = ".title-wrap .name-wrap .t-overflow";
  const IP_CONT_WRAP_SELECTOR = ".cont-wrap";

  // dump.php#/trash
  const DP_TILE_SELECTOR = "ul.items-cont > li";
  const DP_NAME_SELECTOR = ".title-wrap .name-wrap .t-overflow";

  // factions.php armory
  const FA_TILE_SELECTOR = ".armoury-tabs ul.item-list > li";

  // amarket.php
  const AM_TILE_SELECTOR = "div.item-cont-wrap";
  const AM_NAME_SELECTOR = ".item-name";
  const AM_STATS_WRAP_SELECTOR = ".item-bonuses"; // ✅ important

  /** ==================== NORMALIZATION + MAPS ==================== **/
  function normName(name) {
    if (!name) return "";
    return name
      .normalize("NFKC")
      .toLowerCase()
      .replace(/[\u2010-\u2015\u2212]/g, "-")
      .replace(/[\u2018\u2019\u2032]/g, "'")
      .replace(/\s+/g, " ")
      .trim();
  }

  const WEAPON_MAP = (() => {
    const m = Object.create(null);
    for (const [k, v] of Object.entries(WEAPON_STAT_RANGES)) m[normName(k)] = v;
    return m;
  })();

  const ARMOR_MAP = (() => {
    const m = Object.create(null);
    for (const [k, v] of Object.entries(ARMOR_STAT_RANGES)) m[normName(k)] = v;
    return m;
  })();

  /** ==================== UTILS ==================== **/
  function safeText(el) { return (el?.textContent || "").trim(); }

  function clamp(x, lo, hi) {
    if (!Number.isFinite(x)) return lo;
    return Math.max(lo, Math.min(hi, x));
  }

  function quantizeHeat(p) {
    const q = Math.round(p / STEP) * STEP;
    return clamp(q, 0, HEAT_MAX);
  }

  function lerp(a, b, t) { return a + (b - a) * t; }
  function rgb(r, g, b) { return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; }

  function colorForHeat(pRaw) {
    const p = clamp(pRaw, 0, HEAT_MAX);

    const gray   = { r:120, g:120, b:120 };
    const blue   = { r: 60, g:140, b:240 };
    const green  = { r: 60, g:200, b: 90 };
    const yellow = { r:244, g:200, b: 42 };
    const orange = { r:245, g:130, b: 35 };
    const red    = { r:220, g: 40, b: 40 };

    if (p <= 0.77) {
      const t = p / 0.77;
      return rgb(lerp(gray.r, blue.r, t), lerp(gray.g, blue.g, t), lerp(gray.b, blue.b, t));
    }
    if (p <= 0.88) {
      const t = (p - 0.77) / (0.88 - 0.77);
      return rgb(lerp(blue.r, green.r, t), lerp(blue.g, green.g, t), lerp(blue.b, green.b, t));
    }
    if (p <= 0.99) {
      const t = (p - 0.88) / (0.99 - 0.88);
      return rgb(lerp(green.r, yellow.r, t), lerp(green.g, yellow.g, t), lerp(green.b, yellow.b, t));
    }
    if (p <= 1.11) {
      const t = (p - 0.99) / (1.11 - 0.99);
      return rgb(lerp(yellow.r, orange.r, t), lerp(yellow.g, orange.g, t), lerp(yellow.b, orange.b, t));
    }
    const t = (p - 1.11) / (2.00 - 1.11);
    return rgb(lerp(orange.r, red.r, t), lerp(orange.g, red.g, t), lerp(orange.b, red.b, t));
  }

  function intrinsicPercent(roll, min, max) {
    const denom = max - min;
    if (!Number.isFinite(roll) || !Number.isFinite(min) || !Number.isFinite(max) || denom <= 0) return null;
    return (roll - min) / denom;
  }

  function toHeat(rawP) {
    if (!Number.isFinite(rawP)) return 0;
    return clamp(rawP, 0, HEAT_MAX);
  }

  /** ==================== BADGE (no borders + removes tooltips) ==================== **/
  const BADGE_CLASS = "torn-heat-quality-badge";
  let cssInjected = false;

  function injectCSSOnce() {
    if (cssInjected) return;
    cssInjected = true;

    const style = document.createElement("style");
    style.textContent = `
      .${BADGE_CLASS} {
        position: absolute;
        top: 4px;
        left: 4px;
        z-index: 10;
        font-size: 12px;
        font-weight: 900;
        line-height: 1;
        padding: 3px 6px;
        border-radius: 6px;
        background: rgba(0, 0, 0, 0.65);
        text-shadow: 0 1px 1px rgba(0,0,0,0.85);
        pointer-events: none;
        user-select: none;
        white-space: nowrap;
      }
    `;
    document.head.appendChild(style);
  }

  function removeQualityTitles(root) {
    if (!root) return;

    const rt = root.getAttribute?.("title") || "";
    if (/^\s*Quality:\s*/i.test(rt)) root.removeAttribute("title");

    const titled = root.querySelectorAll?.("[title]") || [];
    for (const el of titled) {
      const t = el.getAttribute("title") || "";
      if (/^\s*Quality:\s*/i.test(t)) el.removeAttribute("title");
    }
  }

  function formatBadgeTextFromRaw(pRaw) {
    const pct = pRaw * 100;
    return `${Math.round(pct)}%`;
  }

  function ensurePositioned(anchor) {
    const cs = window.getComputedStyle(anchor);
    if (!cs || cs.position === "static") {
      anchor.style.position = "relative";
    }
  }

  function upsertBadge(anchor, pRaw, color) {
    if (!anchor) return;

    injectCSSOnce();
    ensurePositioned(anchor);
    removeQualityTitles(anchor);

    let badge = anchor.querySelector(`.${BADGE_CLASS}`);
    if (!badge) {
      badge = document.createElement("span");
      badge.className = BADGE_CLASS;
      anchor.appendChild(badge);
    }

    badge.textContent = formatBadgeTextFromRaw(pRaw);
    badge.style.color = color;
  }

  function findImageAnchor(root) {
    if (!root) return null;

    const direct =
      root.querySelector('div.imageWrapper___RqvUg') ||
      root.querySelector('div[class*="imageWrapper"]') ||
      root.querySelector('div.imgContainer___Ec4I5[data-testid="img-container"]') ||
      root.querySelector('div[data-testid="img-container"]') ||
      root.querySelector('span.image-wrap') ||
      root.querySelector('div.image-wrap') ||
      root.querySelector('div.img-wrap[data-armoryid]') ||
      root.querySelector('span.item-plate') ||
      root.querySelector('span.img-wrap') ||
      null;

    if (direct) return direct;

    const img = root.querySelector("img");
    if (!img) return null;
    return img.closest("div, span") || img.parentElement || null;
  }

  /** ==================== FIXED STAT READER (from your v2.1.1) ==================== **/
  // ✅ Critical for Auction House: only read the number from the stat row (.bonus-attachment)
  // instead of accidentally picking up other numeric spans in the tile.
  function readStatFromTile(root, iconClass) {
    const icon = root.querySelector(`i.${iconClass}`);
    if (!icon) return null;

    // Best case: icon + value inside the same .bonus-attachment
    const attachment = icon.closest(".bonus-attachment");
    if (attachment) {
      const valEl = attachment.querySelector("span.label-value, span.t-overflow, span");
      const txt = safeText(valEl);
      const v = parseFloat(txt);
      return Number.isFinite(v) ? v : null;
    }

    // Fallback: scan the closest <li> row that contains the icon
    const li = icon.closest("li");
    if (li) {
      const candidates = li.querySelectorAll("span.label-value, span.t-overflow, span");
      for (const sp of candidates) {
        const txt = safeText(sp);
        if (/^\d+(\.\d+)?$/.test(txt)) {
          const v = parseFloat(txt);
          if (Number.isFinite(v)) return v;
        }
      }
    }

    // Last resort: immediate siblings / parent scan
    const near = icon.parentElement?.querySelector("span.label-value, span.t-overflow, span");
    if (near) {
      const txt = safeText(near);
      if (/^\d+(\.\d+)?$/.test(txt)) {
        const v = parseFloat(txt);
        if (Number.isFinite(v)) return v;
      }
    }

    const sib = icon.nextElementSibling;
    if (sib) {
      const txt = safeText(sib);
      if (/^\d+(\.\d+)?$/.test(txt)) {
        const v = parseFloat(txt);
        if (Number.isFinite(v)) return v;
      }
    }

    return null;
  }

  /** ==================== HEAT CALC ==================== **/
  function weaponHeatRaw(d, a, range) {
    const minScore = range.dMin + range.aMin;
    const maxScore = range.dMax + range.aMax;
    return intrinsicPercent(d + a, minScore, maxScore);
  }

  function armorHeatRaw(r, range) {
    return intrinsicPercent(r, range.min, range.max);
  }

  /** ==================== ITEM MARKET PARSING ==================== **/
  function imGetName(tile) {
    const name = safeText(tile.querySelector(IM_NAME_SELECTOR));
    if (name) return name;
    const alt = tile.querySelector("img[alt]")?.getAttribute("alt");
    return (alt || "").trim() || null;
  }

  function imParsePointsFromAria(tile, containsText) {
    const el = tile.querySelector(`[aria-label*="${containsText}"]`);
    if (!el) return null;
    const aria = el.getAttribute("aria-label") || "";
    const val = parseFloat(aria);
    return Number.isFinite(val) ? val : null;
  }

  function imGetRollStats(tile) {
    const damage = imParsePointsFromAria(tile, "damage points");
    const accuracy = imParsePointsFromAria(tile, "accuracy points");
    const armor = imParsePointsFromAria(tile, "armor points");

    const isWeapon = damage !== null && accuracy !== null;
    const isArmor = armor !== null && !isWeapon;

    return { isWeapon, isArmor, damage, accuracy, armor };
  }

  /** ==================== BAZAAR PARSING ==================== **/
  function bzGetName(itemEl) {
    const name = safeText(itemEl.querySelector(BZ_NAME_SELECTOR));
    if (name) return name;

    const imgAlt = itemEl.querySelector("img[alt]")?.getAttribute("alt");
    if (imgAlt) return imgAlt.trim();

    return null;
  }

  function bzFindValue(statsBlock, label) {
    const icon = statsBlock.querySelector(`i[area-label="${label}"], i[aria-label="${label}"]`);
    if (!icon) return null;
    const container = icon.closest("div") || icon.parentElement;
    const valEl = container?.querySelector("span.t-overflow") || icon.nextElementSibling;
    const val = parseFloat(safeText(valEl));
    return Number.isFinite(val) ? val : null;
  }

  function bzGetRollStats(itemEl) {
    const statsBlock = itemEl.querySelector(BZ_STATS_SELECTOR);
    if (!statsBlock) return { isWeapon:false, isArmor:false };

    const damage = bzFindValue(statsBlock, "damage");
    const accuracy = bzFindValue(statsBlock, "accuracy");
    const defence = bzFindValue(statsBlock, "defence");

    const isWeapon = damage !== null && accuracy !== null;
    const isArmor = defence !== null && !isWeapon;

    return { isWeapon, isArmor, damage, accuracy, armor: defence };
  }

  /** ==================== item.php PARSING ==================== **/
  function ipGetName(tileLi) {
    const n2 = safeText(tileLi.querySelector(IP_NAME_SELECTOR_2));
    if (n2) return n2;

    const n = safeText(tileLi.querySelector(IP_NAME_SELECTOR));
    if (n) return n;

    const alt = tileLi.querySelector("img[alt]")?.getAttribute("alt");
    if (alt) return alt.trim();

    return null;
  }

  function ipGetRollStats(tileLi) {
    const cont = tileLi.querySelector(IP_CONT_WRAP_SELECTOR) || tileLi;
    const damage = readStatFromTile(cont, "bonus-attachment-item-damage-bonus");
    const accuracy = readStatFromTile(cont, "bonus-attachment-item-accuracy-bonus");
    const defence = readStatFromTile(cont, "bonus-attachment-item-defence-bonus");

    const isWeapon = damage !== null && accuracy !== null;
    const isArmor = defence !== null && !isWeapon;

    return { isWeapon, isArmor, damage, accuracy, armor: defence };
  }

  /** ==================== dump.php PARSING ==================== **/
  function dpGetName(tileLi) {
    const n = safeText(tileLi.querySelector(DP_NAME_SELECTOR));
    if (n) return n;

    const alt = tileLi.querySelector("img[alt]")?.getAttribute("alt");
    if (alt) return alt.trim();

    return null;
  }

  function dpGetRollStats(tileLi) {
    const damage = readStatFromTile(tileLi, "bonus-attachment-item-damage-bonus");
    const accuracy = readStatFromTile(tileLi, "bonus-attachment-item-accuracy-bonus");
    const defence = readStatFromTile(tileLi, "bonus-attachment-item-defence-bonus");

    const isWeapon = damage !== null && accuracy !== null;
    const isArmor = defence !== null && !isWeapon;

    return { isWeapon, isArmor, damage, accuracy, armor: defence };
  }

  /** ==================== factions.php PARSING ==================== **/
  function faGetName(li) {
    const raw = safeText(li.querySelector(".name.bold, .name"));
    if (!raw) return null;
    return raw.replace(/\s+x\s*\d+.*$/i, "").trim();
  }

  function faGetRollStats(li) {
    const damage = readStatFromTile(li, "bonus-attachment-item-damage-bonus");
    const accuracy = readStatFromTile(li, "bonus-attachment-item-accuracy-bonus");
    const defence = readStatFromTile(li, "bonus-attachment-item-defence-bonus");

    const isWeapon = damage !== null && accuracy !== null;
    const isArmor = defence !== null && !isWeapon;

    return { isWeapon, isArmor, damage, accuracy, armor: defence };
  }

  /** ==================== amarket.php PARSING ==================== **/
  function amGetName(tile) {
    const n = safeText(tile.querySelector(AM_NAME_SELECTOR));
    if (n) return n;

    const alt = tile.querySelector("img[alt]")?.getAttribute("alt");
    if (alt) return alt.trim();

    return null;
  }

  function amGetRollStats(tile) {
    const wrap = tile.querySelector(AM_STATS_WRAP_SELECTOR) || tile;

    const damage = readStatFromTile(wrap, "bonus-attachment-item-damage-bonus");
    const accuracy = readStatFromTile(wrap, "bonus-attachment-item-accuracy-bonus");
    const defence = readStatFromTile(wrap, "bonus-attachment-item-defence-bonus");

    const isWeapon = damage !== null && accuracy !== null;
    const isArmor = defence !== null && !isWeapon;

    return { isWeapon, isArmor, damage, accuracy, armor: defence };
  }

  /** ==================== RECOLOR PASSES ==================== **/
  function recolorItemMarket() {
    const tiles = Array.from(document.querySelectorAll(IM_TILE_SELECTOR));
    for (const tile of tiles) {
      const name = imGetName(tile);
      if (!name) continue;

      const key = normName(name);
      const stats = imGetRollStats(tile);

      if (stats.isWeapon) {
        const range = WEAPON_MAP[key];
        if (!range) continue;

        const pRaw = weaponHeatRaw(stats.damage, stats.accuracy, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(tile);
        if (anchor) upsertBadge(anchor, pRaw, color);

      } else if (stats.isArmor) {
        const range = ARMOR_MAP[key];
        if (!range) continue;

        const pRaw = armorHeatRaw(stats.armor, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(tile);
        if (anchor) upsertBadge(anchor, pRaw, color);
      }
    }
  }

  function recolorBazaar() {
    const items = Array.from(document.querySelectorAll(BZ_ITEM_SELECTOR));
    for (const itemEl of items) {
      const name = bzGetName(itemEl);
      if (!name) continue;

      const key = normName(name);
      const stats = bzGetRollStats(itemEl);
      if (!stats.isWeapon && !stats.isArmor) continue;

      if (stats.isWeapon) {
        const range = WEAPON_MAP[key];
        if (!range) continue;

        const pRaw = weaponHeatRaw(stats.damage, stats.accuracy, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(itemEl);
        if (anchor) upsertBadge(anchor, pRaw, color);

      } else {
        const range = ARMOR_MAP[key];
        if (!range) continue;

        const pRaw = armorHeatRaw(stats.armor, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(itemEl);
        if (anchor) upsertBadge(anchor, pRaw, color);
      }
    }
  }

  function recolorItemPage() {
    const tiles = Array.from(document.querySelectorAll(IP_TILE_SELECTOR));
    for (const li of tiles) {
      const name = ipGetName(li);
      if (!name) continue;

      const key = normName(name);
      const stats = ipGetRollStats(li);
      if (!stats.isWeapon && !stats.isArmor) continue;

      if (stats.isWeapon) {
        const range = WEAPON_MAP[key];
        if (!range) continue;

        const pRaw = weaponHeatRaw(stats.damage, stats.accuracy, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(li);
        if (anchor) upsertBadge(anchor, pRaw, color);

      } else {
        const range = ARMOR_MAP[key];
        if (!range) continue;

        const pRaw = armorHeatRaw(stats.armor, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(li);
        if (anchor) upsertBadge(anchor, pRaw, color);
      }
    }
  }

  function recolorDumpTrash() {
    const tiles = Array.from(document.querySelectorAll(DP_TILE_SELECTOR));
    for (const li of tiles) {
      const name = dpGetName(li);
      if (!name) continue;

      const key = normName(name);
      const stats = dpGetRollStats(li);
      if (!stats.isWeapon && !stats.isArmor) continue;

      if (stats.isWeapon) {
        const range = WEAPON_MAP[key];
        if (!range) continue;

        const pRaw = weaponHeatRaw(stats.damage, stats.accuracy, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(li);
        if (anchor) upsertBadge(anchor, pRaw, color);

      } else {
        const range = ARMOR_MAP[key];
        if (!range) continue;

        const pRaw = armorHeatRaw(stats.armor, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(li);
        if (anchor) upsertBadge(anchor, pRaw, color);
      }
    }
  }

  function recolorFactionArmory() {
    const rows = Array.from(document.querySelectorAll(FA_TILE_SELECTOR));
    for (const li of rows) {
      const name = faGetName(li);
      if (!name) continue;

      const key = normName(name);
      const stats = faGetRollStats(li);
      if (!stats.isWeapon && !stats.isArmor) continue;

      if (stats.isWeapon) {
        const range = WEAPON_MAP[key];
        if (!range) continue;

        const pRaw = weaponHeatRaw(stats.damage, stats.accuracy, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(li);
        if (anchor) upsertBadge(anchor, pRaw, color);

      } else {
        const range = ARMOR_MAP[key];
        if (!range) continue;

        const pRaw = armorHeatRaw(stats.armor, range);
        if (pRaw === null) continue;

        const heatStep = quantizeHeat(toHeat(pRaw));
        const color = colorForHeat(heatStep);

        const anchor = findImageAnchor(li);
        if (anchor) upsertBadge(anchor, pRaw, color);
      }
    }
  }

  function recolorAuctionMarket() {
    const wraps = Array.from(document.querySelectorAll(AM_TILE_SELECTOR));
    for (const wrap of wraps) {
      const name =
        safeText(wrap.querySelector(AM_NAME_SELECTOR)) ||
        wrap.querySelector("img[alt]")?.getAttribute("alt")?.trim() ||
        null;
      if (!name) continue;

      const key = normName(name);

      // ✅ IMPORTANT: only parse stats from the stats wrapper, not the whole tile
      const statsRoot = wrap.querySelector(AM_STATS_WRAP_SELECTOR) || wrap;

      const damage  = readStatFromTile(statsRoot, "bonus-attachment-item-damage-bonus");
      const accuracy= readStatFromTile(statsRoot, "bonus-attachment-item-accuracy-bonus");
      const defence = readStatFromTile(statsRoot, "bonus-attachment-item-defence-bonus");

      const isWeapon = damage !== null && accuracy !== null;
      const isArmor = defence !== null && !isWeapon;
      if (!isWeapon && !isArmor) continue;

      let pRaw = null;
      if (isWeapon) {
        const range = WEAPON_MAP[key];
        if (!range) continue;
        pRaw = weaponHeatRaw(damage, accuracy, range);
      } else {
        const range = ARMOR_MAP[key];
        if (!range) continue;
        pRaw = armorHeatRaw(defence, range);
      }
      if (pRaw === null) continue;

      const heatStep = quantizeHeat(toHeat(pRaw));
      const color = colorForHeat(heatStep);

      const anchor = findImageAnchor(wrap);
      if (anchor) upsertBadge(anchor, pRaw, color);

      // Ensure no lingering Quality: tooltips anywhere in the tile
      removeQualityTitles(wrap);
    }
  }

  /** ==================== MAIN ==================== **/
  function recolorAll() {
    try {
      recolorItemMarket();
      recolorBazaar();
      recolorItemPage();
      recolorDumpTrash();
      recolorFactionArmory();
      recolorAuctionMarket();
    } catch (e) {
      console.error("[Torn Heat] Script error:", e);
    }
  }

  /** ==================== AUTO-UPDATE ==================== **/
  let scheduled = false;
  function schedule() {
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      recolorAll();
    });
  }

  const observer = new MutationObserver(schedule);
  observer.observe(document.body, { childList:true, subtree:true });

  recolorAll();
})();