BUSTR: Busting Reminder + PDA

Guess how many busts you can do without getting jailed

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         BUSTR: Busting Reminder + PDA
// @namespace    http://torn.city.com.dot.com.com
// @version      1.0.11
// @description  Guess how many busts you can do without getting jailed
// @author       Adobi & Ironhydedragon
// @match        https://www.torn.com/*
// @license      MIT
// @run-at       document-end
// ==/UserScript==

console.log('😎 BUSTR-SCRIPT ON!!!!'); // TEST

////////  GLOBAL VARIABLES
////  State
let GLOBAL_BUSTR_STATE = {
  userSettings: {
    reminderLimits: {
      redLimit: 0, // will be red at this number and under
      greenLimit: 3, // will be green at this number and over
    },
    statsRefreshRate: 60, // time in seconds
    customPenaltyThreshold: 0, // leave at 0 if you want to use the prediction algorithm
    // quickBust: true,
    // quickBail: false,
    showHardnessScore: true, // set to 'false' to remove hardnessScore rendering and reordering
  },
  penaltyScore: 0,
  penaltyThreshold: 0,
  availableBusts: 0,
  timestampsArray: [],
  lastFetchTimestampMs: 0,
  renderedView: undefined,
};

const PDA_API_KEY = '###PDA-APIKEY###';
function isPDA() {
  const PDATestRegex = !/^(###).+(###)$/.test(PDA_API_KEY);

  return PDATestRegex;
}

////  Colors
const greenMossDark = '#4b5738';
const greenMossDarkTranslucent = 'rgb(75, 87, 56, 0.9)';
const greenMoss = '#57693a';
const greenMossTranslucent = 'rgb(87, 105, 58, 0.9)';
const greenApple = '#85b200';
const greenAppleTranslucent = 'rgba(134, 179, 0, 0.4)';

const orangeFulvous = '#d08000';
const orangeFulvousTranslucent = 'rgba(209, 129, 0, 0.3)';
const orangeAmber = '#ffbf00';
const orangeAmberTranslucent = 'rgba(255, 191, 0, 0.4)';

const redFlame = '#e64d1a';
const redFlameTranslucent = 'rgba(230, 77, 25, 0.3)';
const redMelon = '#ffa8a8';
const redMelonTranslucent = 'rgba(255, 168, 168, 0.3)';

////  Utils Functions

function createTimestampsArray(data) {
  let timestamps = [];

  for (const entry in data.log) {
    timestamps.push(data.log[entry].timestamp);
  }

  return timestamps;
}

function calcTenHours(hours) {
  return hours / 7.2;
}

function calcPenaltyScore(timestampsArray) {
  const currentTime = Date.now() / 1000;

  let score = 0;
  let localScore = 0;
  for (const ts of timestampsArray) {
    const hours = (currentTime - ts) / 60 / 60;
    const tenHours = calcTenHours(hours);
    if (hours <= 72) {
      localScore = 128 / Math.pow(2, tenHours);
      score += localScore;
    }
  }
  return Math.floor(score);
}

function calcPenaltyThreshold(timestampsArray) {
  if (getUserSettings().customPenaltyThreshold && typeof getUserSettings().customPenaltyThreshold === 'number') return getUserSettings().customPenaltyThreshold;
  const period = 24 * 60 * 60 * 3;
  let longestSequence = 0;
  let currentSequence = 1;
  let currentMin = timestampsArray[0];
  let currentMax = timestampsArray[0];
  let firstTimestamp;

  for (let i = 1; i < timestampsArray.length; i++) {
    const TS = timestampsArray[i];
    if (currentMin - TS <= period && currentMax - TS <= period) {
      currentSequence++;
      currentMin = Math.min(currentMin, TS);
      currentMax = Math.max(currentMax, TS);
    } else {
      if (longestSequence < currentSequence) {
        firstTimestamp = currentMin;
      }
      longestSequence = Math.max(longestSequence, currentSequence);
      currentSequence = 1;
      currentMin = TS;
      currentMax = TS;
    }
  }

  let currentMaxScore = 0;
  for (let i = 0; i < timestampsArray.length - longestSequence; i++) {
    let score = 0;
    let localScore = 0;
    const initial_timestamp = timestampsArray[i];
    for (let j = 0; j < longestSequence; j++) {
      const hours = (initial_timestamp - timestampsArray[i + j]) / 60 / 60;
      const tenHours = calcTenHours(hours);
      localScore = 128 / Math.pow(2, tenHours);
      score += localScore;
    }
    currentMaxScore = Math.max(currentMaxScore, score);
  }
  return Math.floor(currentMaxScore);
}

function calcAvailableBusts(penaltyScore, penaltyThreshold) {
  return Math.floor((penaltyThreshold - penaltyScore) / 128);
}

async function fetchBustsData(apiKey) {
  try {
    const url = `https://api.torn.com/user/?selections=log&log=5360&key=${apiKey}`;

    const response = await fetch(url);
    const data = await response.json();
    setLastFecthTimestampMs();

    if (data.error) {
      if (data.error.error === 'Incorrect key' || data.error.error === 'Access level of this key is not high enough') {
        throw new Error(`Error: ${data.error.error}`);
      }
      throw new Error('Something went wrong');
    }
    return data;
  } catch (err) {
    console.error(err);
  }
}

function calcBustrStats(timestampsArray) {
  const penaltyScore = calcPenaltyScore(timestampsArray);

  const penaltyThreshold = calcPenaltyThreshold(timestampsArray);

  const availableBusts = calcAvailableBusts(penaltyScore, penaltyThreshold);

  return { penaltyScore, penaltyThreshold, availableBusts };
}

function getLevelJailDurationInfo(playerEl) {
  const levelEl = playerEl.querySelector('.level');
  const durationEl = playerEl.querySelector('.time');

  const level = +levelEl.innerText.match(/\d+/)[0];
  const hours = +durationEl.innerText.match(/\d+(?=h)/) || 0;
  const mins = +durationEl.innerText.match(/\d+(?=m)/) || 0;
  const durationInHours = hours + mins / 60;

  return [level, +durationInHours];
}

function calcHardnessScore(level, durationInHours) {
  return Math.floor(level * (durationInHours + 3));
}

function renderHardnessScore(playerEl, hardnessScore) {
  playerEl.querySelector('.bustr-hardness-score').textContent = hardnessScore;
}

function sortByHardnessScore(playerEl, hardnessScore) {
  playerEl.style.order = hardnessScore;
}

//// Callback functions
function submitFormCallback() {
  const inputEl = document.querySelector('#bustr-form__input');
  const submitBtnEl = document.querySelector('#bustr-form__submit');

  const apiKey = inputEl.value;
  if (apiKey.length !== 16) {
    inputEl.style.border = `2px solid ${redFlame}`;
    submitBtnEl.disabled = true;
    return;
  }
  setApiKey(apiKey);
  dismountBustrForm();
  window.location.reload();
}

function inputValidatorCallback(event) {
  const inputEl = document.querySelector('#bustr-form__input');
  const submitBtnEl = document.querySelector('#bustr-form__submit');
  if (event.target.value.length === 16) {
    submitBtnEl.disabled = false;
    inputEl.style.border = '1px solid #444';
  }
  if (event.target.value.length !== 16) {
    submitBtnEl.disabled = true;
  }
}

async function successfulBustMutationCallback(mutationList, observer) {
  try {
    for (const mutation of mutationList) {
      if (!mutation.target.innerText) return;
      if (mutation.target.innerText.match(/^(You busted ).+/) && mutation.removedNodes.length > 0) {
        observer.disconnect();
        console.log('SuccessfulBust', Date.now()); // TEST

        const newPenaltyScore = getPenaltyScore() + 128;
        setPenaltyScore(newPenaltyScore);

        const newAvailableBusts = calcAvailableBusts(getPenaltyScore(), getPenaltyThreshold());
        setAvailableBusts(newAvailableBusts);
        renderBustrStats({ availableBusts: getAvailableBusts(), penaltyScore: getPenaltyScore() });

        successfulBustUpdateController();
        renderBustrColorClass();
      }
    }
  } catch (err) {
    console.error(err);
  }
}

function hardnessScoreCallback(mutationList, observer) {
  for (const mutation of mutationList) {
    if (mutation.target.classList.contains('user-info-list-wrap') && mutation.addedNodes.length > 1) {
      hardnessScoreController();
      observer.disconnect();
    }
  }
}

//// Observers
function createJailMutationObserver() {
  const jailObserver = new MutationObserver(successfulBustMutationCallback);
  jailObserver.observe(document, {
    attributes: false,
    childList: true,
    subtree: true,
  });
}

function createHardnessScoreObserver() {
  const harnessScoreObserver = new MutationObserver(hardnessScoreCallback);
  harnessScoreObserver.observe(document, {
    attributes: false,
    childList: true,
    subtree: true,
  });
}

////////  MODEL ////////
////  Getters and Setters
function setGlobalBustrState(newState) {
  GLOBAL_BUSTR_STATE = { ...GLOBAL_BUSTR_STATE, ...newState };
  localStorage.setItem('globalBustrState', JSON.stringify(GLOBAL_BUSTR_STATE));
  saveGlobalBustrState();
}
function getGlobalBustrState() {
  return GLOBAL_BUSTR_STATE;
}
function loadGlobalBustrState() {
  if (!localStorage.getItem('globalBustrState')) return;
  const loadedState = JSON.parse(localStorage.getItem('globalBustrState'));
  GLOBAL_BUSTR_STATE = { ...GLOBAL_BUSTR_STATE, ...loadedState };
  return localStorage.getItem('globalBustrState');
}
function saveGlobalBustrState() {
  localStorage.setItem('globalBustrState', JSON.stringify(getGlobalBustrState()));
}
function deleteGlobalBustrState() {
  GLOBAL_BUSTR_STATE = {
    userSettings: {
      reminderLimits: {
        redLimit: 0,
        greenLimit: 3,
      },
    },
    penaltyScore: 0,
    penaltyThreshold: 0,
    availableBusts: 0,
    timestampsArray: [],
  };
  localStorage.removeItem('bustrGlobalState');
}

function getMyViewportWidthType() {
  let width = visualViewport.width;

  if (width > 1000) return 'Desktop';
  if (width < 1000 || width) return 'Mobile';
  throw new Error('Visual viewport not loaded');
}

function setApiKey(apiKey) {
  localStorage.setItem('bustrApiKey', JSON.stringify(apiKey));
}
function getApiKey() {
  if (isPDA()) return PDA_API_KEY;

  if (!localStorage.getItem('bustrApiKey')) return;

  return JSON.parse(localStorage.getItem('bustrApiKey'));
}

function deleteApiKey() {
  localStorage.removeItem('bustrApiKey');
}

function setUserSettings(newUserSettings, currentState) {
  currentState = currentState || getGlobalBustrState();

  const newState = { ...currentState, userSettings: newUserSettings };
  setGlobalBustrState(newState);
}
function getUserSettings() {
  return getGlobalBustrState().userSettings;
}

function setRenderedView(newRenderedView, currentState) {
  currentState = currentState || getGlobalBustrState();

  const newState = { ...currentState, renderedView: newRenderedView };
  setGlobalBustrState(newState);
}
function getRenderedView(newRenderedView, currentState) {
  return getGlobalBustrState().renderedView;
}

function setTimestampsArray(newTimestampsArr, currentState) {
  currentState = currentState || getGlobalBustrState();

  return setGlobalBustrState({
    ...currentState,
    timestampsArray: newTimestampsArr,
  });
}
function getTimestampsArray() {
  return getGlobalBustrState().timestampsArray;
}

function setLastFecthTimestampMs(currentState) {
  currentState = currentState || getGlobalBustrState();

  const currentTimestampMs = Date.now();
  setGlobalBustrState({
    ...currentState,
    lastFetchTimestampMs: currentTimestampMs,
  });
}
function getLastFecthTimestampMs() {
  return getGlobalBustrState().lastFetchTimestampMs;
}

function setPenaltyThreshold(newPenaltyThreshold, currentState) {
  currentState = currentState || getGlobalBustrState();

  const newState = { ...currentState, penaltyThreshold: newPenaltyThreshold };
  setGlobalBustrState(newState);
}
function getPenaltyThreshold() {
  return getGlobalBustrState().penaltyThreshold;
}

function setPenaltyScore(newPenaltyScore, currentState) {
  currentState = currentState || getGlobalBustrState();

  const newState = { ...currentState, penaltyScore: newPenaltyScore };
  setGlobalBustrState(newState);
}
function getPenaltyScore() {
  return getGlobalBustrState().penaltyScore;
}

function setAvailableBusts(newAvailableBusts, currentState) {
  currentState = currentState || getGlobalBustrState();

  const newState = { ...currentState, availableBusts: newAvailableBusts };
  setGlobalBustrState(newState);
}
function getAvailableBusts() {
  return getGlobalBustrState().availableBusts;
}

////////  VIEW  ////////
////  Stylesheet
const bustrStylesheetHTML = `<style>
  .bustr--green {
    --color: ${greenApple}
  }
  .bustr--orange {
    --color: ${orangeFulvous}
  }
  .bustr--red {
    --color: ${redFlame}
  }
  .dark-mode.bustr--green,
  .bustr--green .swiper-slide {
    --color: ${greenApple}
  }
  .dark-mode.bustr--orange,
  .bustr--orange .swiper-slide {
    --color: ${orangeAmber}
  }
  .dark-mode.bustr--red,
  .bustr--red .swiper-slide {
    --color: ${redMelon}
  }

  #bustr-form.header-wrapper-top {
    display: flex;
  }
  #bustr-form.header-wrapper-top .container {
    display: flex;
    justify-content: start;
    align-items: center;
    padding-left: 20px;
  }

  #bustr-form.header-wrapper-top h2 {
    display: block;
    text-align: center;
    margin: 0;
    width: 172px;
  }

  #bustr-form.header-wrapper-top input {
    background: linear-gradient(0deg,#111,#000);
    border-radius: 5px;
    box-shadow: 0 1px 0 hsla(0,0%,100%,.102);
    box-sizing: border-box;
    color: #9f9f9f;
    display: inline;
    font-weight: 400;
    height: 24px;
    width: clamp(170px, 50%, 250px);
    margin: 0 0 0 21px;
    outline: none;
    padding: 0 10px 0 10px;
    
    font-size: 12px;
    font-style: italic; 
    vertical-align: middle;
    border: 0;
    text-shadow: none;
    z-index: 100;
  }
  #bustr-form.header-wrapper-top a {
    margin: 0 8px;
  }

  #nav-jail .bustr-stats,
  #bustr-context .bustr-stats {
    color: var(--color, inherit);
  }
  #nav-jail .bustr-stats span {
    margin-left: unset;
  }

  #bustr-context.contextMenu___bjhoL {
    display: none;
    left: unset;
    right: -92px;
    padding: 0 8px;
    z-index: 9999;
  }
  .contextMenuActive___e6i_B #bustr-context.contextMenu___bjhoL {
    display: flex;
  }
  #bustr-context.contextMenu___bjhoL .arrow___tKP13 {
    right: unset;
    left: -6px;
    border-width: 8px 6px 8px 0;
    border-color: transparent #444 transparent transparent;
  }
  #bustr-context.contextMenu___bjhoL .arrow___tKP13:before {
    border-color: transparent #373636 transparent transparent;
    border-width: 6px 5px 6px 0;
    content: "";
    left: unset;
    right: -6px;
    top: -6px;
  }

  #prefs-tab-menu #bustr-settings {
    display: none;
  }
  #prefs-tab-menu #bustr-settings.active {
    display: block;
  }
  #bustr-settings input[type="number"] {
    height: 24px;
    width: 48px;
    padding: 1px 5px;
    text-align: center;
  }

  #bustr-settings-dropdown:hover {
    background: #fff;
  }
  .dark-mode #prefs-tab-menu #bustr-settings-dropdown:hover {
    background: #444;
  }
  #prefs-tab-menu #bustr-settings-sidetab.active {
    background: #fff;
    color: #999
  }
  .dark-mode #prefs-tab-menu #bustr-settings-sidetab.active {
    background: #444;
    color: #999
  }

  #body .users-list-title {
    display: flex;
    justify-content: start;
    align-items: center;
  }
  #body .users-list-title .title{
    width: 269px;
  }
  #body .users-list-title .time{
    width: 50px;
  }
  #body .users-list-title .level{
    width: 53px;
  }
  #body .users-list-title .reason{
    width: 205px;
  }
  #body .users-list-title .hardness{
    display: block;
    width: 79px;
    text-align: center;
  }

  #body .user-info-list-wrap > li .info-wrap .hardness {
    display: block; 
    text-align: center;
  }
  #body .user-info-list-wrap > li .info-wrap .hardness span.title {
    display: none;
  }

  #body .user-info-list-wrap {
    display: flex;
    flex-direction: column;
    justify-content: start;
    align-items: center;
  }
  #body .user-info-list-wrap > li  {
    display: flex; 
    flex-wrap: wrap; 
    justify-content: start; 
    align-items: center;
  }

  #body .user-info-list-wrap > li .info-wrap {
    display: flex; 
    flex-wrap: wrap;
    justify-content: start; 
    align-items: center;
  }
  #body .user-info-list-wrap > li .info-wrap .time {
    width: 54px;
  }
  #body .user-info-list-wrap > li .info-wrap .level {
    width: 57px;
  }
  #body .user-info-list-wrap > li .info-wrap .reason {
    width: 193px;
  }
  #body .user-info-list-wrap > li .info-wrap .hardness {
    width: 50px;
  }

  @media screen and (max-width:1000px) {
    #bustr-form.header-wrapper-top h2 {
      width: 148px;
    }
    #bustr-form.header-wrapper-top input {
      margin-left: 10px;
    }
  }
  @media screen and (max-width:784px) {
    #bustr-form.header-wrapper-top h2 {
      font-size: 16px;
      width: 80px;
    }
    #body .users-list-title .hardness{
      display: none;
    }
    #body .user-info-list-wrap > li .info-wrap .hardness span.title{
      display: block;
    }
    #body .user-info-list-wrap > li .info-wrap .reason {
      width: 164px;
      border-right: 1px solid rgb(34, 34, 34);
    }
    #body .user-info-list-wrap > li .info-wrap .hardness {
      width: 64px;
    }
  }
    @media screen and (max-width:386px) {
      
      #body .user-info-list-wrap > li .info-wrap .time {
        width: 98px;
        height: 37px;
      }
      #body .user-info-list-wrap > li .info-wrap .level {
        width: 91px;
        height: 37px;
      }
      #body .user-info-list-wrap > li .info-wrap .reason {
        width: 171px;
        height: 24px;
        border-right: 1px solid rgb(34, 34, 34);
      }
      #body .user-info-list-wrap > li .info-wrap .hardness {
        width: 107px;
      }
    }
  }
    </style>`;
function renderBustrStylesheet() {
  const headEl = document.querySelector('head');
  headEl.insertAdjacentHTML('beforeend', bustrStylesheetHTML);
}

function renderBustrColorClass(availableBusts) {
  const redLimit = typeof getUserSettings().reminderLimits.redLimit === 'number' ? getUserSettings().reminderLimits.redLimit : 0;
  const greenLimit = typeof getUserSettings().reminderLimits.greenLimit === 'number' ? getUserSettings().reminderLimits.greenLimit : 3;

  if (+availableBusts <= redLimit) {
    if (document.body.classList.contains('bustr--red')) return;
    document.body.classList.add('bustr--red');
    document.body.classList.remove('available___ZS04X', 'bustr--green', 'bustr--orange');
    return;
  }

  if (+availableBusts >= greenLimit) {
    if (document.body.classList.contains('bustr--green')) return;
    document.body.classList.add('available___ZS04X', 'bustr--green');
    document.body.classList.remove('bustr--orange', 'bustr--red');
    return;
  }

  if (availableBusts > redLimit && availableBusts < greenLimit) {
    if (document.body.classList.contains('bustr--orange')) return;
    document.body.classList.add('bustr--orange');
    document.body.classList.remove('available___ZS04X', 'bustr--green', 'bustr--red');
  }
}
//// Init form view
function renderBustrForm() {
  const topHeaderBannerEl = document.querySelector('#topHeaderBanner');
  const bustrFormHTML = `
      <div id="bustr-form" class="header-wrapper-top">
        <div class="container clear-fix">
          <h2>Bustr API</h2>
          <input
            id="bustr-form__input"
            type="text"
            placeholder="Enter a full-acces API key..."
          />
          <a href="#" id="bustr-form__submit"  type="btn" disabled><span class="link-text">Submit</span</button>
        </div>
      </div>`;

  topHeaderBannerEl.insertAdjacentHTML('afterbegin', bustrFormHTML);
}

function dismountBustrForm() {
  document.querySelector('#bustr-form').remove();
}

function renderBustrStats(statsObj) {
  for (const [key, value] of Object.entries(statsObj)) {
    const statsElArr = [...document.querySelectorAll(`.bustr-stats__${key}`)];
    statsElArr.forEach((el) => (el.textContent = value));
  }
}

async function requireElement(selectors) {
  try {
    await new Promise((res, rej) => {
      if (document.querySelector(selectors)) res();

      maxCycles = 500;
      let current = 1;
      const interval = setInterval(() => {
        if (document.querySelector(selectors)) {
          clearInterval(interval);
          res();
        }
        if (current === maxCycles) {
          clearInterval(interval);
          rej('Timeout: Could not find jail link');
        }
        current++;
      }, 10);
    });
  } catch (err) {
    console.error(err);
  }
}

//// Desktop view
async function renderBustrDesktopView() {
  try {
    await requireElement('#nav-jail a');
    const jailLinkEl = document.querySelector('#nav-jail a');
    if (jailLinkEl.querySelector('.bustr-stats')) return;

    const statsHTML = `
        <span class="amount___p8QZX bustr-stats">
          <span class="bustr-stats__penaltyScore">#</span> / <span class="bustr-stats__penaltyThreshold">#</span> : <span class="bustr-stats__availableBusts">#</span>
        </span>`;

    jailLinkEl.insertAdjacentHTML('beforeend', statsHTML);
  } catch (err) {
    console.error(err);
  }
}

//// Mobile view
function renderMobileBustrNotification() {
  const jailLinkEl = document.querySelector('#nav-jail a');

  const notificationHTML = `
    <div class="mobileAmount___ua3ye bustr-stats"><span class="bustr-stats__availableBusts">#</span></div>`;
  jailLinkEl.insertAdjacentHTML('beforebegin', notificationHTML);
}

async function renderBustrMobileView() {
  try {
    await requireElement('#nav-jail a');
    const jailLinkEl = document.querySelector('#nav-jail');
    if (jailLinkEl.querySelector('.bustr-stats')) return;

    renderMobileBustrNotification();

    const bustrContextMenuHTML = `
      <div id="bustr-context" class='contextMenu___bjhoL bustr-context-menu'>
        <span class='linkName___FoKha bustr-stats'>
        <span class="bustr-stats__penaltyScore">#</span> / <span class="bustr-stats__penaltyThreshold">#</span> : <span class="bustr-stats__availableBusts">#</span>
        </span>
        <span class='arrow___tKP13 bustr-arrow'></span>
      </div>`;

    jailLinkEl.insertAdjacentHTML('afterend', bustrContextMenuHTML);
  } catch (err) {
    console.error(err);
  }
}

function renderHardnessJailView() {
  const headingsContainerEl = document.querySelector('.users-list-title');
  const hardnessTitleHTML = `
    <span class="hardness title-divider divider-spiky">Hardness</span>`;
  if (!headingsContainerEl.querySelector('span.hardness')) {
    headingsContainerEl.children[3].insertAdjacentHTML('afterend', hardnessTitleHTML);
  }

  const playerRowsArr = [...document.querySelectorAll('.user-info-list-wrap > li')];
  playerRowsArr.forEach((el) => {
    const playerInfoContainerEl = el.querySelector('.info-wrap');
    const hardnessScoreHTML = `
      <span class="hardness reason">
        <span class="title bold">HARDNESS</span>
        <span class="bustr-hardness-score">#####</span>
      </span>`;

    if (!playerInfoContainerEl) return;
    if (!playerInfoContainerEl.querySelector('.hardness.reason')) {
      playerInfoContainerEl.children[2].insertAdjacentHTML('afterend', hardnessScoreHTML);
    }
  });
}

////////  CONTROLLERS  ////////
async function initController() {
  try {
    renderBustrStylesheet();

    if (isPDA() && !getApiKey()) {
      setApiKey(PDA_API_KEY);
    }

    if (getMyViewportWidthType() === 'Desktop') {
      await renderBustrDesktopView();
      setRenderedView('Desktop');
    }
    if (getMyViewportWidthType() === 'Mobile') {
      await renderBustrMobileView();
      setRenderedView('Mobile');
    }

    if (getApiKey()) return;

    // if not saved render bustr form
    renderBustrForm();

    // set event liseners
    //// Event listeners
    document.querySelector('#bustr-form__submit').addEventListener('click', submitFormCallback);
    document.querySelector('#bustr-form__input').addEventListener('input', inputValidatorCallback);
    document.querySelector('#bustr-form__input').addEventListener('keyup', (event) => {
      if (event.key === 'Enter' || event.keyCode === 13) {
        submitFormCallback();
      }
    });
  } catch (err) {
    console.error(err);
  }
}

//// load
async function loadController() {
  try {
    // guard clause if no api key
    if (!getApiKey()) return;

    if (loadGlobalBustrState()) {
      loadGlobalBustrState();
    }

    // fetch data
    const data = await fetchBustsData(getApiKey());
    setTimestampsArray(createTimestampsArray(data));

    const statsObj = calcBustrStats(getTimestampsArray());
    setPenaltyScore(statsObj.penaltyScore);
    setPenaltyThreshold(statsObj.penaltyThreshold);
    setAvailableBusts(statsObj.availableBusts);

    // render color class
    renderBustrColorClass(getAvailableBusts());

    // render stats
    renderBustrStats(statsObj);
  } catch (err) {
    // deleteApiKey();
    console.error(err);
  }
}

function successfulBustUpdateController() {
  createJailMutationObserver();
}

function refreshStatsController() {
  const statsRefreshRate = typeof getUserSettings().statsRefreshRate === 'number' && getUserSettings().statsRefreshRate > 0 ? getUserSettings().statsRefreshRate : 60;
  setInterval(async () => {
    await loadController();
  }, statsRefreshRate * 1000 || 60000);
}

async function viewportResizeController() {
  try {
    visualViewport.addEventListener('resize', async (e) => {
      if (!getRenderedView()) return;

      const viewportWidthType = getMyViewportWidthType();
      if (viewportWidthType !== getRenderedView()) {
        initController();
        await loadController();
      }
    });
  } catch (error) {
    console.error(error); // TEST
  }
}

function hardnessScoreController() {
  if (window.location.pathname !== '/jailview.php') return;
  createHardnessScoreObserver();

  renderHardnessJailView();
  const playersArr = [...document.querySelectorAll('ul.user-info-list-wrap > li')];

  if (playersArr[0].classList.contains('last')) return;
  for (const playerEl of playersArr) {
    const [level, durationInHours] = getLevelJailDurationInfo(playerEl);
    const hardnessScore = calcHardnessScore(level, durationInHours);
    renderHardnessScore(playerEl, hardnessScore);
    sortByHardnessScore(playerEl, hardnessScore);
  }
}

//// Promise race conditions
// necessary as PDA scripts are inject after window.onload
const PDAPromise = new Promise((res, rej) => {
  if (document.readyState === 'complete') res();
});

const browserPromise = new Promise((res, rej) => {
  window.addEventListener('load', () => res());
});

(async function () {
  try {
    await Promise.race([PDAPromise, browserPromise]);
    await initController();
    await loadController();
    if (getUserSettings().showHardnessScore) {
      hardnessScoreController();
    }
    successfulBustUpdateController();
    refreshStatsController();
    viewportResizeController();
  } catch (err) {
    console.error(err);
  }
})();