Pickpocket J.A.R.V.I.S.

color pick-pocket targets based on difficulty

// ==UserScript==
// @name        Pickpocket J.A.R.V.I.S.
// @namespace   http://tampermonkey.net/
// @version     1.51
// @description color pick-pocket targets based on difficulty
// @author      Terekhov
// @match       https://www.torn.com/loader.php?sid=crimes*
// @icon        https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant       none
// ==/UserScript==

(function () {
  'use strict';

  const colors = {
    ideal: '#40AB24',
    easy: '#82C370',
    tooEasy: '#A4D497',
    tooHard: '#fa8e8e',
    uncategorized: '#DA85FF'
  };
  const ALL_MARK_TYPES = ['Businessman', 'Businesswoman', 'Classy lady', 'Cyclist', 'Drunk man', 'Drunk woman', 'Elderly man', 'Elderly woman', 'Gang member', 'Homeless person', 'Jogger', 'Junkie', 'Laborer', 'Mobster', 'Police officer', 'Postal worker', 'Rich kid', 'Sex worker', 'Student', 'Thug', 'Young man', 'Young woman'];
  const MARK_CS_LEVELS_MAP = {
    'Drunk man': '100',
    'Drunk woman': '100',
    'Elderly man': '100',
    'Elderly woman': '100',
    'Homeless person': '100',
    Junkie: '100',
    'Classy lady': '150',
    Laborer: '150',
    'Postal worker': '150',
    'Young man': '150',
    'Young woman': '150',
    Student: '150',
    'Rich kid': '200',
    'Sex worker': '200',
    Thug: '200',
    Businessman: '250',
    Businesswoman: '250',
    Jogger: '250',
    'Gang member': '250',
    Mobster: '250',
    Cyclist: '300',
    'Police officer': '350'
  };

//
// Based on guide here https://www.torn.com/forums.php#/p=threads&f=61&t=16358739&b=0&a=0
// Thanks Emforus [2535044]!
//
// This script is triggered down at the bottom; see formatCurrentCrimesContainer and startListeningToFormatNewCrimes
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Global functions - these are not specific to Torn, but provide utilities for us
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// function addGlobalStyle(css) {
//   var head, style;
//   head = document.getElementsByTagName('head')[0];
//   if (!head) { return; }
//   style = document.createElement('style');
//   style.type = 'text/css';
//   style.innerHTML = css;
//   head.appendChild(style);
// }

  const minLsKey = 'pickpocketMinDisplay';
  const maxLsKey = 'pickpocketMaxDisplay';
  const pickpocketLimitsDivId = 'pickpocketLimitsDiv';
  let levelLimitsAdded = false;
  function addAndListenToLevelLimits() {
    var _localStorage$getItem, _localStorage$getItem2;
    if (levelLimitsAdded) {
      return;
    } else {
      removeLimitsDiv();
      levelLimitsAdded = true;
    }
    const minLsLevelToSet = (_localStorage$getItem = localStorage.getItem(minLsKey)) != null ? _localStorage$getItem : 100;
    const maxLsLevelToSet = (_localStorage$getItem2 = localStorage.getItem(maxLsKey)) != null ? _localStorage$getItem2 : 300;
    const titleContainer = document.querySelectorAll('[class^="appHeader"]')[0];
    const limitsDiv = document.createElement('div');
    limitsDiv.id = pickpocketLimitsDivId;
    limitsDiv.innerHTML = `
  <label for="minlvl">Min Lvl:</label>
  <input type="number" id="terek_minlvl" name="minlvl" min="100" max="250" step="50" value="${minLsLevelToSet}" />
  <label for="maxlvl">Max Lvl:</label>
  <input type="number" id="terek_maxlvl" name="maxlvl" min="150" max="300" step="50" value="${maxLsLevelToSet}" />
  `;
    titleContainer.appendChild(limitsDiv);
    document.querySelector('#terek_minlvl').addEventListener('input', e => {
      const minLvlElement = e.target;
      localStorage.setItem(minLsKey, minLvlElement.value);
      formatCurrentCrimesContainer();
    });
    document.querySelector('#terek_maxlvl').addEventListener('input', e => {
      const maxLvlElement = e.target;
      localStorage.setItem(maxLsKey, maxLvlElement.value);
      formatCurrentCrimesContainer();
    });
  }
  function removeLimitsDiv() {
    const limitsDiv = document.getElementById(pickpocketLimitsDivId);
    if (limitsDiv) {
      limitsDiv.remove();
    }
  }

// Need to wait for page to initialize, before we know this. Assume 1, until then
  let currentSkillLevel = 1;
  function findChildByClassStartingWith(name, parentEle) {
    for (const child of parentEle.children) {
      for (const childClass of child.classList) {
        if (!!childClass && childClass.startsWith(name)) {
          return child;
        }
      }
      if (child.children) {
        const innerResult = findChildByClassStartingWith(name, child);
        if (innerResult) {
          return innerResult;
        }
      }
    }
    return null;
  }

  /**
   *
   * @return {
   *    csSemantic: "tooHard", // Police officer when you're lvl 1
   *    activitySemantic: "ideal",    // Businesswoman "on phone"
   *    buildSemantic: "tooHard"     // Skinny businessman,
   *    finalSemantic: "tooHard"
   * }
   */
  function setColorsForCrimeTarget(crimeChild) {
    var _localStorage$getItem3, _localStorage$getItem4;
    crimeChild = crimeChild.children[0].children[0];
    const crimesDivAkaSections = findChildByClassStartingWith('sections', crimeChild);
    const mainSection = findChildByClassStartingWith('mainSection', crimesDivAkaSections);
    const targetTypeEle = findChildByClassStartingWith('titleAndProps', mainSection).children[0];
    let targetType;
    for (const type of ALL_MARK_TYPES) {
      if (targetTypeEle.textContent.startsWith(type)) {
        // Handle mobile view e.g. "Police officer 5m 10s"
        targetType = type;
      }
    }
    const minLsLevel = (_localStorage$getItem3 = localStorage.getItem(minLsKey)) != null ? _localStorage$getItem3 : 100;
    const maxLsLevel = (_localStorage$getItem4 = localStorage.getItem(maxLsKey)) != null ? _localStorage$getItem4 : 300;
    if (MARK_CS_LEVELS_MAP[targetType] < minLsLevel || MARK_CS_LEVELS_MAP[targetType] > maxLsLevel) {
      crimeChild.style.display = 'none';
    } else if (crimeChild.style.display === 'none') {
      crimeChild.style.display = 'block';
    }

    // e.g. Average 5'0" 158 lbs
    const physicalPropsEle = findChildByClassStartingWith('titleAndProps', mainSection).children[1];
    const physicalProps = physicalPropsEle.textContent;

    // Average
    const build = physicalProps.substring(0, physicalProps.indexOf(' '));

    // e.g. Begging0s
    const activityEle = findChildByClassStartingWith('activity', mainSection);
    const activity = activityEle.textContent;

    // e.g. Begging
    // The ternary handles mobile - in mobile we don't get the status like "Begging" so we can't do optimize there.
    const activityName = activity.match(/^\D+/) ? activity.match(/^\D+/)[0] : '';

    // e.g. 0s
    // const activityTime = activity.substring(activityName.length);

    //
    // type DifficultySemantic = 'ideal' | 'easy' | 'tooEasy' | 'tooHard' | 'uncategorized'
    // interface Difficulties: { [key]: DifficultySemantic } = {
    //   csSemantic: 'tooHard',       // Police officer when you're lvl 1
    //   activitySemantic: 'ideal',    // Businesswoman 'on phone'
    //   buildSemantic: 'tooHard'     // Skinny businessman
    //   finalSemantic: 'tooHard',    // Based on all the above
    // }
    //
    const difficulties = getDifficulties(targetType, build, activityName);

    //
    // Now Set all the colors
    //
    if (difficulties.buildSemantic) {
      physicalPropsEle.style.color = colors[difficulties.buildSemantic];
    }
    if (difficulties.activitySemantic) {
      activityEle.style.color = colors[difficulties.activitySemantic];
    }
    for (const type of ALL_MARK_TYPES) {
      if (targetTypeEle.textContent.startsWith(type)) {
        // Handle mobile view e.g. "Police officer 5m 10s"
        if (targetTypeEle.textContent.indexOf('%)') === -1) {
          targetTypeEle.textContent = targetTypeEle.textContent + ` (${MARK_CS_LEVELS_MAP[type]}%)`;
        }
        targetTypeEle.style.color = colors[difficulties.csSemantic];
      }
    }

    // Set 'Pickpocket' button color
    const divContainingButton = findChildByClassStartingWith('commitButtonSection', crimesDivAkaSections);
    divContainingButton.style.backgroundColor = colors[difficulties.finalSemantic];
  }
  const skillCats = ['Safe', 'Moderately Unsafe', 'Unsafe', 'Risky', 'Dangerous', 'Very Dangerous'];
  const skillStarts = [1, 10, 35, 65, 90, 100];
  function getMaxSkillIndex() {
    let idx = 0;
    skillStarts.forEach((ele, currentIdx) => {
      if (Math.floor(currentSkillLevel) >= ele) {
        idx = currentIdx;
      }
    });
    return idx;
  }
  function getAllSafeSkillCats() {
    const maxIndex = getMaxSkillIndex();
    if (maxIndex >= skillCats.length) {
      return skillCats.slice();
    } else {
      return skillCats.slice(0, maxIndex + 1);
    }
  }
  const markGroups = {
    // CS 1-20
    Safe: ['Drunk man', 'Drunk woman', 'Homeless person', 'Junkie', 'Elderly man', 'Elderly woman'],
    // CS 10-70
    'Moderately Unsafe': ['Laborer', 'Postal worker', 'Young man', 'Young woman', 'Student'],
    // CS 35-90
    Unsafe: ['Classy lady', 'Rich kid', 'Sex worker'],
    // CS 65+
    Risky: ['Thug', 'Jogger', 'Businessman', 'Businesswoman', 'Gang member'],
    // CS 90+
    Dangerous: ['Cyclist'],
    // ???
    'Very Dangerous': ['Mobster', 'Police officer']
  };

  /**
   * @param mark e.g. 'Rich Kid'
   *
   * @return 'ideal' | 'easy' | 'tooEasy' | 'tooHard' | 'uncategorized'
   */
  function getMarkIdealityBasedOnCS(mark) {
    // type colorSemantic = 'ideal' | 'easy' | 'tooEasy' | 'tooHard' | 'uncategorized'
    const safeSkillCats = getAllSafeSkillCats();
    for (let idx = 0; idx < safeSkillCats.length; idx++) {
      const safeSkillCat = safeSkillCats[idx];
      if (markGroups[safeSkillCat].includes(mark)) {
        if (idx === safeSkillCats.length - 1) {
          return 'ideal';
        } else if (idx === safeSkillCats.length - 2) {
          return 'easy';
        } else {
          return 'tooEasy';
        }
      }
    }
    return 'tooHard';
  }

  /**
   *
   * @param markType  Elderly woman
   * @param build     Average
   * @param status    Begging
   *
   * @return {
   *    csSemantic: "tooHard", // Police officer when you're lvl 1
   *    activitySemantic: "ideal",    // Businesswoman "on phone"
   *    buildSemantic: "tooHard"     // Skinny businessman,
   *    finalSemantic: "tooHard"
   * }
   */
  function getDifficulties(markType, build, status) {
    // TODO builds and statuses to favor. Too much for now
    const buildsToAvoid = {
      Businessman: ['Skinny'],
      'Drunk man': ['Muscular'],
      'Gang member': ['Muscular'],
      'Sex worker': ['Muscular'],
      Student: ['Athletic'],
      Thug: ['Muscular']
    };
    const statusesToAvoid = {
      Businessman: ['Walking'],
      'Drunk man': ['Distracted'],
      'Drunk woman': ['Distracted'],
      'Homeless person': ['Loitering'],
      Junkie: ['Loitering'],
      Laborer: ['Distracted'],
      'Police officer': ['Walking'],
      'Sex worker': ['Distracted'],
      Thug: ['Loitering', 'Walking']
    };
    const difficulties = {
      csSemantic: 'uncategorized',
      activitySemantic: undefined,
      buildSemantic: undefined,
      finalSemantic: 'uncategorized'
    };

    // type colorSemantic = 'ideal' | 'easy' | 'tooEasy' | 'tooHard' | 'uncategorized'
    difficulties.csSemantic = getMarkIdealityBasedOnCS(markType);

    // We use csSemantic as baseline; activity and build can override.
    difficulties.finalSemantic = difficulties.csSemantic;
    if (buildsToAvoid[markType] && buildsToAvoid[markType].includes(build)) {
      difficulties.finalSemantic = 'tooHard';
      difficulties.buildSemantic = 'tooHard';
    }
    if (statusesToAvoid[markType] && statusesToAvoid[markType].includes(status)) {
      difficulties.finalSemantic = 'tooHard';
      difficulties.activitySemantic = 'tooHard';
    }
    return difficulties;
  }
  function getCrimesContainer() {
    const crimesContainerName = document.querySelectorAll('[class^="currentCrime"]')[0].classList[0];
    return document.getElementsByClassName(crimesContainerName)[0].children[3];
  }
  function setSkillLevel() {
     currentSkillLevel = document.getElementById('crime-stats-panel').children[0].children[1].children[0].children[0].children[0].children[0].children[2].textContent;
    console.log('skill level = ', currentSkillLevel);
  }

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// The part of the script that starts listening to the page is below
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

// If we land directly on pickpocket page, these handle it correctly.
  if (window.location.href.includes('#/pickpocketing')) {
    setTimeout(startListeningToFormatNewCrimes, 650);
  }

//
// GreaseMonkey can't listen for Pickpocket page directly, so we run this on all crimes pages.
// however if we navigate away from Pickpocket, we stop listening with our observer
//
  function handleCrimesHeaderMutation(mutations) {
    const headerText = mutations[0].target.textContent;
    if (headerText === 'Pickpocketing') {
      setTimeout(startListeningToFormatNewCrimes, 650);
    } else if (observer) {
      removeLimitsDiv();
      observer.disconnect();
      observer = undefined;
    }
  }

  let crimesHeaderTarget = document.querySelector('.crimes-app h4[class^="heading"');
  let crimesHeaderObserver = new MutationObserver(handleCrimesHeaderMutation);
  crimesHeaderObserver.observe(crimesHeaderTarget, { characterData: true, attributes: false, childList: false, subtree: true});

  function formatCurrentCrimesContainer() {
    setSkillLevel();
    addAndListenToLevelLimits();
    let count = 0;
    for (const node of getCrimesContainer().children) {

      // First or last in this container are garbage. First is not visible, last isn't even an item
      if (count++ === 0 || node.classList.toString().indexOf('virtualItemsBackdrop') !== -1) {
        continue;
      }
      setColorsForCrimeTarget(node);
    }
  }

  let observer;
  function startListeningToFormatNewCrimes() {
    if (observer) {
      return;
    }

    formatCurrentCrimesContainer();
    setSkillLevel();
    addAndListenToLevelLimits();

    // Select the node that will be observed for mutations
    const targetNode = getCrimesContainer();

    // Options for the observer (which mutations to observe)
    const config = { attributes: false, childList: true, subtree: false };

    // Callback function to execute when mutations are observed
    const callback = mutationList => {
      for (const mutation of mutationList) {
        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
          let count = 0;
          for (const node of targetNode.children) {
            if (count++ === 0) {
              continue;
            }
            setColorsForCrimeTarget(node);
          }
        }
      }
    };

    // Create an observer instance linked to the callback function
    observer = new MutationObserver(callback);

    // Start observing the target node for configured mutations
    observer.observe(targetNode, config);
  }

})();