BUSTR: Busting Reminder + PDA

Guess how many busts you can do without getting jailed

// ==UserScript==
// @name         BUSTR: Busting Reminder + PDA
// @namespace    http://torn.city.com.dot.com.com
// @version      1.0.10
// @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.err(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);
  }
})();