SB100 / GGn Equipment Loadout Manager

// ==UserScript==
// @namespace    https://openuserjs.org/users/SB100
// @name         GGn Equipment Loadout Manager
// @description  Set loadouts for your equipment so you can mass equip and unequip items
// @updateURL    https://openuserjs.org/meta/SB100/GGn_Equipment_Loadout_Manager.meta.js
// @version      1.4.1
// @author       SB100
// @copyright    2021, SB100 (https://openuserjs.org/users/SB100)
// @license      MIT
// @include      https://gazellegames.net/user.php?action=equipment
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

// ==OpenUserJS==
// @author SB100
// ==/OpenUserJS==

/* jshint esversion: 6 */

/**
 * =============================
 * ADVANCED OPTIONS
 * =============================
 */

// If you want the Equipment Loadout Manager to be opened by default when you visit the equipment page
const DEFAULT_OPENED = true;

// If you want to bypass the confirm dialog when clicking Equip Items
const BYPASS_CONFIRM = false;

// If you want to show the quick equip buttons when the equipment loadout manager form is closed
const SHOW_QUICK_BUTTONS = true;

/**
 * =============================
 * END ADVANCED OPTIONS
 * DO NOT MODIFY BELOW THIS LINE
 * =============================
 */

/**
 * A cache of all the items we've analyzed on the page
 */
const ITEM_CACHE = {};

/**
 * Wait a specific amount of time.
 * Use this in an async function via: `await wait(ms)`
 */
function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

/**
 * Shallow compare 2 objects for equality
 */
function objShallowEqual(obj1, obj2) {
  return Object.keys(obj1).length === Object.keys(obj2).length &&
    Object.keys(obj1).every(key => obj1[key] === obj2[key]);
}

/**
 * Get the current user id
 */
function getUserId() {
  return unsafeWindow.userid;
}

/**
 * Get the users auth key
 */
function getAuthKey() {
  return unsafeWindow.authkey;
}

/**
 * Turn a slot id into a slot name
 */
function getSlotName(slotId) {
  switch (slotId) {
    case 1:
      return 'Helmet';
    case 2:
      return 'Upper Body';
    case 3:
      return 'Arms';
    case 4:
      return 'Legs';
    case 5:
      return 'Hands';
    case 6:
      return 'Feet';
    case 7:
      return 'Left Hand';
    case 8:
      return 'Right Hand';
    case 9:
      return 'Necklace';
    case 10:
      return 'Left Ring'
    case 11:
      return 'Right Ring'
    case 12:
      return 'Backpack';
    case 13:
      return 'Clothes';
    case 14:
      return 'Pet 1';
    case 15:
      return 'Pet 2';
    default:
      return 'Unknown';
  }
}

/**
 * Turn a slot name into a slot id
 */
function getSlotId(slotName) {
  switch (slotName) {
    case 'Helmet':
      return 1;
    case 'Upper Body':
      return 2;
    case 'Arms':
      return 3;
    case 'Legs':
      return 4;
    case 'Hands':
      return 5;
    case 'Feet':
      return 6;
    case 'Left Hand':
      return 7;
    case 'Right Hand':
      return 8;
    case 'Necklace':
      return 9;
    case 'Left Ring':
      return 10;
    case 'Right Ring':
      return 11;
    case 'Backpack':
      return 12;
    case 'Clothes':
      return 13;
    case 'Pet 1':
      return 14;
    case 'Pet 2':
      return 15;
    default:
      return 0;
  }
};

/**
 * Get all items currently available to the user.
 * If wantOnlyEquip is true, this will only return items that are currently equipped
 */
function getItems(wantOnlyEquip = false) {
  const selector = wantOnlyEquip ? '.itemslot .item' : '.itemslot .item, #items-wrapper .item';
  return Array.from(document.querySelectorAll(selector)).reduce((result, elem) => {
    const slots = elem.dataset.slots.split(',');
    const conjunction = Array.isArray(slots) && slots.pop();

    slots.forEach(s => {
      const slot = parseInt(s, 10);
      const slotName = getSlotName(slot);
      const id = parseInt(elem.dataset.id, 10);

      if (!result[slotName]) {
        result[slotName] = [];
      }

      ITEM_CACHE[id] = {
        id,
        name: decodeURI(elem.dataset.itemName),
        slots: slots.map(slot => parseInt(slot, 10)),
        conjunction: conjunction
      }

      result[slotName].push(ITEM_CACHE[id]);
    });

    return result;
  }, {});
}

/**
 * Get an item's details by an item id
 */
function getItem(itemId) {
  if (ITEM_CACHE[itemId]) {
    return ITEM_CACHE[itemId];
  }

  const elems = Array.from(document.querySelectorAll('.itemslot .item, #items-wrapper .item'));
  for (let i = 0, len = elems.length; i < len; i += 1) {
    const elem = elems[i];
    const id = parseInt(elem.dataset.id, 10)

    if (id === itemId) {
      const slots = elem.dataset.slots.split(',');
      const conjunction = Array.isArray(slots) && slots.pop();

      ITEM_CACHE[id] = {
        id,
        name: decodeURI(elem.dataset.itemName),
        slots: slots.map(slot => parseInt(slot, 10)),
        conjunction: conjunction
      }

      return ITEM_CACHE[id];
    }
  }

  return {}
}

/**
 * Group items by their item slots and conjunction.
 * We need this so that we can [un]equip rings and pets one at a time to avoid a race condition
 */
function groupItems(itemIds) {
  const seen = {
    0: []
  };
  const results = {
    0: []
  };

  for (let i = 0, iLen = itemIds.length; i < iLen; i += 1) {
    const item = getItem(itemIds[i]);
    const key = `${item.slots.sort().join(':')}:${item.conjunction}`;
    let pushed = false;

    for (let j = 0, jLen = Object.keys(seen).length; j < jLen; j += 1) {
      if (seen[j].includes(key) === false) {
        results[j].push(item.id);
        seen[j].push(key);
        pushed = true;
        break;
      }
    }

    if (pushed === false) {
      results[Object.keys(results).length] = [item.id];
      seen[Object.keys(seen).length] = [key];
    }
  }

  return results;
}

/**
 * Get all saved loadouts
 */
function getLoadouts() {
  const existing = GM_getValue('loadouts', '{}');
  return JSON.parse(existing);
}

/**
 * Send a request to the server to equip or unequip an item
 */
function sendEquipRequest(isEquip, itemId, userId, authKey) {
  let resolver;
  let rejecter;
  const p = new Promise((resolveFn, rejectFn) => {
    resolver = resolveFn;
    rejecter = rejectFn;
  });

  const url = `/user.php?action=ajax_equip_item&auth=${authKey}&itemid=${itemId}&userid=${userId}&equiptype=${isEquip ? 'equip' : 'unequip'}`;
  GM_xmlhttpRequest({
    method: 'get',
    url: url,
    timeout: 5000,
    onloadstart: function () {

    },
    onload: function () {
      resolver();
    },
    onerror: function () {
      rejecter('Error')
    },
    ontimeout: function () {
      rejecter('Timeout')
    },
  });

  return p;
}

/**
 * Convenience method for bulk equipping or unequipping items
 */
function equipAllItemIds(itemIds, userId, authKey, isEquip = true) {
  const promises = itemIds.map(itemId => sendEquipRequest(isEquip, itemId, userId, authKey));
  return Promise.all(promises);
}

/**
 * Turn a form into a JSON object, without the loadout name
 */
function formToJson(formData) {
  const result = {};
  for (const [key, val] of formData.entries()) {
    if (key === 'loadout-name') continue;
    result[key] = parseInt(val, 10);
  }
  return result;
}

// --------------------------------------------------------------------------------------------- Handlers

/**
 * Handler for when the "Equip Items" button is clicked. This will:
 * - Check that the current loadout is up to date (i.e. all changes have been saved)
 * - Calculates what needs to be unequipped, and what needs to be equipped
 * - Confirms the changes with the user
 * - Sends requests to the server to equip and unequip items
 * - Reloads the page
 */
async function handleEquip() {
  // currently equip
  const currentlyEquipObj = getItems(true);
  const currentlyEquipIds = Object.keys(currentlyEquipObj).reduce((result, key) => {
    const values = currentlyEquipObj[key];
    values.forEach(value => result.push(value.id));
    return result;
  }, []);
  const uniqueEquipIds = [...new Set(currentlyEquipIds)]

  // loadout items
  const uniqueLoadoutIds = [];
  const formData = new FormData(document.querySelector('.loadout-manager__form'));
  for (const [key, val] of formData.entries()) {
    if (key === 'loadout-name') continue;
    uniqueLoadoutIds.push(parseInt(val, 10));
  }

  // check if there have been any changes in the loadout
  const loadouts = getLoadouts();
  const loadout = loadouts[formData.get('loadout-name')];
  const form = formToJson(formData);
  if (!loadout || !objShallowEqual(loadout, form)) {
    alert(`Changes detected in this loadout\rPlease verify your loadout, save it, and then try again`);
    return;
  }

  // calculate difference for what we have to unequip
  const unequip = uniqueEquipIds.filter(x => !uniqueLoadoutIds.includes(x));
  // calculate difference for what we have to equip
  const equip = uniqueLoadoutIds.filter(x => !uniqueEquipIds.includes(x));

  // check if we acutally have to make changes
  if (unequip.length === 0 && equip.length === 0) {
    window.alert('Nothing to do!');
    return;
  }

  if (!BYPASS_CONFIRM) {
    // get names of equipment we will equip and unequip
    const unequipNames = unequip.map(itemId => getItem(itemId).name);
    const equipNames = equip.map(itemId => getItem(itemId).name);

    // confirm the changes we're about to make with the user
    const message = `About to run "${formData.get('loadout-name')}" loadout. This will result in the following changes:\r\r${unequipNames.length > 0 ? `To Unequip:\r${unequipNames.map(s => ` - ${s}`).join("\r")}\r\r` : ''}${equip.length > 0 ? `To Equip:\r${equipNames.map(s => ` - ${s}`).join("\r")}\r\r` : ''}Is this ok?`;
    if (!window.confirm(message)) {
      return;
    }
  }

  // user has confirmed changes - let's make them!
  const loading = document.querySelector('.loadout-manager__loading');
  loading && loading.classList.add('loadout-manager__loading--showing');

  try {
    // group items so that we can avoid race conditions
    const unequipGroups = groupItems(unequip);
    const equipGroups = groupItems(equip);

    // first unequip what we need to
    loading.innerHTML = 'Unequipping unneeded items';
    for (let i = 0, len = Object.keys(unequipGroups).length; i < len; i += 1) {
      await equipAllItemIds(unequipGroups[i], getUserId(), getAuthKey(), false);
      await wait(250);
    }

    // then equip what we need to
    loading.innerHTML = 'Equipping missing items';
    for (let i = 0, len = Object.keys(equipGroups).length; i < len; i += 1) {
      await equipAllItemIds(equipGroups[i], getUserId(), getAuthKey(), true);
      await wait(250);
    }

    // then reload the page to see new changes
    loading.innerHTML = 'Reloading page';
    window.location.reload(true);
    return true;
  }
  catch (e) {
    // if there was an error, show the user, and do nothing more
    loading.innerHTML = 'An error occurred whilst processing the loadout';
    console.error(e);
    return false;
  }
}

/**
 * Handler for when the "Save" button is clicked.
 * This saves a loadout for future selection
 */
function handleSave(event, loadoutManager) {
  const formData = new FormData(document.querySelector('.loadout-manager__form'));
  const name = formData.get('loadout-name');

  if (name === '') {
    window.alert('You must enter a loadout name');
    return;
  }

  const loadouts = getLoadouts();
  loadouts[name] = formToJson(formData);
  GM_setValue('loadouts', JSON.stringify(loadouts));

  // recreate the dropdown, and have this value now selected
  createLoadoutDropdown(loadoutManager, name);
}

/**
 * Handler for when the "Remove" button is clicked
 * This removes a currently stored loadout
 */
function handleRemove(name, loadoutManager) {
  const loadouts = getLoadouts();
  delete loadouts[name];
  GM_setValue('loadouts', JSON.stringify(loadouts));

  // remove the form
  createLoadoutForm('-1', loadoutManager);
  // recreate the dropdown with the deleted value now gone
  createLoadoutDropdown(loadoutManager);
}

/**
 * Handler for when an item is selected.
 * This enables / disables other checkboxes in the form for a better user experience
 */
function handleItemSelect(target, items) {
  const isChecked = target.checked;

  const itemId = parseInt(target.dataset.itemId, 10);
  const currentSlotId = parseInt(target.parentNode.parentNode.dataset.slotId, 10);

  const item = items[getSlotName(currentSlotId)].find(item => item.id === itemId);
  const itemSlots = item && item.slots;
  const itemConjunction = item.conjunction;

  if (!currentSlotId || !itemSlots) {
    console.log('Couldnt find item slot data - script needs updating!');
    return;
  }

  // disable/enable all other checkboxes in the current fieldset
  Array.from(document.querySelectorAll(`.loadout-manager__form fieldset[data-slot-id="${currentSlotId}"] input[type="checkbox"]`)).forEach(option => {
    // don't act on the checkbox we just ticked
    if (parseInt(option.dataset.itemId, 10) === itemId) {
      return;
    }

    if (isChecked) {
      option.disabled = true;
      option.dataset[`disabled${itemId}`] = 1;
      return;
    }

    delete option.dataset[`disabled${itemId}`];
    if (Object.keys(option.dataset).filter(key => key.startsWith('disabled')).length === 0) {
      option.disabled = false;
    }
  });

  // disable/enable the same item that might exist in other fieldsets - it can't be equip twice
  itemSlots
    .filter(slot => slot !== currentSlotId)
    .forEach(slot => {
      Array.from(document.querySelectorAll(`.loadout-manager__form fieldset[data-slot-id="${slot}"] input[type="checkbox"]`)).forEach(option => {
        // don't act on anything which isn't the same item in another slot
        if (parseInt(option.dataset.itemId, 10) !== itemId) {
          return;
        }

        if (isChecked) {
          option.disabled = true;
          option.dataset[`disabled${itemId}`] = 1;
          return;
        }

        delete option.dataset[`disabled${itemId}`];
        if (Object.keys(option.dataset).filter(key => key.startsWith('disabled')).length === 0) {
          option.disabled = false;
        }
      });
    });

  // If the conjunctive is AND, uncheck and disable all checkboxes from this slot, and other slots in the AND
  if (itemConjunction === 'AND') {
    itemSlots
      .forEach(slot => {
        Array.from(document.querySelectorAll(`.loadout-manager__form fieldset[data-slot-id="${slot}"] input[type="checkbox"]`)).forEach(option => {
          // don't act on the checkbox we just ticked
          if (parseInt(option.dataset.itemId, 10) === itemId && slot === currentSlotId) {
            return;
          }

          if (isChecked) {
            option.checked = false;
            option.disabled = true;

            // remove all disabled entries so we can start again
            Object.keys(option.dataset).filter(key => key.startsWith('disabled')).forEach(key => delete option.dataset[key]);
            option.dataset[`disabled${itemId}`] = 1;
            return;
          }

          delete option.dataset[`disabled${itemId}`];
          if (Object.keys(option.dataset).filter(key => key.startsWith('disabled')).length === 0) {
            option.disabled = false;
          }
        });
      });
  }
}

/**
 * Handler for when you hover over a checkbox in the form.
 * This highlights the item in your inventory, or on your avatars loadout, for a better user experience
 */
function handleLabelHover(event, isHover) {
  const itemId = parseInt(event.target.dataset.itemId || event.target.querySelector('input[type="checkbox"]').dataset.itemId, 10);
  const itemNode = document.querySelector(`.item[data-id="${itemId}"]`);
  isHover ? itemNode.classList.add('highlight') : itemNode.classList.remove('highlight');
}

// --------------------------------------------------------------------------------------------- UI

/**
 * All the custom styling that powers the loadout. BEM FTW.
 */
function createStyleTag() {
  const css = `.loadout-manager {
  position: absolute;
  top: 320px;
  right: 0;
  min-width: 250px;
  padding: 10px;
  background-color: rgba(0, 0, 0, 0.7);
  transform: translateX(100%);
}

.loadout-manager.loadout-manager--opened {
  transform: translateX(0);
}

.loadout-manager__arrow {
  display: inline-block;
  position: absolute;
  left: -25px;
  top: 0;
  padding: 10px;
  background-color: rgba(0, 0, 0, 0.85);
  cursor: pointer;
  font-size: 16px;
  font-weight: bold;
}

.loadout-manager__select {
  display: block;
  margin: 15px 0;
  padding: 5px;
  height: auto;
}

.loadout-manager__form {
  overflow-y: scroll;
  max-height: 700px;
}

.loadout-manager__form input[type='text'] {
  display: block;
}

.loadout-manager__form .loadout-manager__buttons input:first-child {
  margin-left: 0;
}

.loadout-manager__form .loadout-manager__buttons input {
  cursor: pointer;
}

.loadout-manager__form .loadout-manager__buttons input:disabled {
  background-color: rgba(53.0, 43.0, 7.0, 0.5);
  cursor: not-allowed;
}

.loadout-manager__form fieldset {
  margin-bottom: 5px;
}

.loadout-manager__form label {
  display: block;
  margin-top: 2px;
}

.loadout-manager__form input[type='checkbox'] {
  margin: 0 5px;
}

.loadout-manager__loading {
  display: none;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.7);
  align-items: center;
  justify-content: center;
  padding: 15px;
  text-align: center;
}

.loadout-manager__loading.loadout-manager__loading--showing {
  display: flex;
}

@keyframes marquee {
  0% { transform: translate(0, 0); }
  50% { transform: translate(calc(50px - 100%), 0); }
  100% { transform: translate(0, 0); }
}

.loadout-quick {
  position: absolute;
  right: 0;
  top: 350px;
  width: 240px;
  padding: 15px;
  display: flex;
  flex-direction: row-reverse;
  flex-wrap: wrap;
}

.loadout-manager--opened + .loadout-quick {
  top: 410px;
}

.loadout-quick__item {
  display: block;
  position: relative;
  overflow: hidden;
  padding: 10px;
  width: 70px;
  height: 50px;
  margin: 5px;
  border-radius: 10px;
  cursor: pointer;
}

.loadout-quick__item::after {
  display: block;
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  content: '';
  overflow: hidden;
  background: linear-gradient(to right, var(--mediumBlue) 0%, transparent 15%, transparent 85%, var(--mediumBlue) 100%);
}

.loadout-quick__marquee {
  display: inline-block;
  white-space: nowrap;
  min-width: 100%;
}

.loadout-quick__item:hover .loadout-quick__marquee {
  animation: marquee 2s linear infinite;
}`;

  const style = document.createElement('style');
  style.type = 'text/css';
  style.appendChild(document.createTextNode(css));

  document.head.appendChild(style);
}

/**
 * Create the Quick Equip Buttons
 */
function createQuickLoadout(selectedValue, loadoutManager) {
  // remove any existing buttons
  const existing = document.querySelector('.loadout-quick');
  existing && existing.remove();

  // only create these buttons when nothing is selected.
  if (selectedValue !== '-1' || SHOW_QUICK_BUTTONS === false) {
    return;
  }

  // create all the options from the saved values
  const loadouts = getLoadouts();
  const fragment = document.createDocumentFragment();
  Object.keys(loadouts).forEach(loadout => {
    const btn = document.createElement('button');
    btn.classList.add('loadout-quick__item');
    btn.innerHTML = `<span class='loadout-quick__marquee'>${loadout}</span>`;

    // when we click a button, create the form, equip whats in the form, and then destroy the form. Winner!
    btn.onclick = () => {
      createLoadoutDropdown(loadoutManager, loadout);
      handleEquip();
      createLoadoutDropdown(loadoutManager);
    }

    fragment.appendChild(btn);
  });

  const loadoutQuick = document.createElement('div');
  loadoutQuick.classList.add('loadout-quick');
  loadoutQuick.appendChild(fragment);

  const wrapper = document.getElementById('wrapper');
  wrapper.appendChild(loadoutQuick);
}

/**
 * This creates a loadout form and prepopulates the checkboxes from a saved loadout
 */
function createLoadoutForm(selectId, loadoutManager) {
  // remove an existing form
  const existingForm = loadoutManager.querySelector('.loadout-manager__form');
  existingForm && existingForm.remove();

  // create the quick loadout buttons
  createQuickLoadout(selectId, loadoutManager);

  // "select an option"
  if (selectId === '-1') {
    return;
  }

  // find data on loadout that has been selected
  const loadouts = getLoadouts();
  const loadout = loadouts[selectId] || {};

  // turn all items into fieldsets containing checkboxes
  const items = getItems();
  const fieldsets = Object
    .keys(items)
    .sort((a, b) => getSlotId(a) > getSlotId(b) ? 1 : -1)
    .map(sectionName => {
      const sectionItems = items[sectionName];

      const fragment = document.createDocumentFragment();
      sectionItems.forEach(item => {
        const section = `loadout-items-${sectionName.toLowerCase().replace(/\s/, '')}`;

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.name = section;
        checkbox.value = item.id;
        checkbox.dataset.itemId = item.id;
        checkbox.dataset.conjunction = item.conjunction;
        checkbox.onchange = (event) => handleItemSelect(event.target, items);
        if (loadout[section] === item.id) {
          delete loadout[section];
          checkbox.checked = true;
        }

        const text = document.createTextNode(item.name);

        const label = document.createElement('label');
        label.onmouseover = (event) => handleLabelHover(event, true);
        label.onmouseout = (event) => handleLabelHover(event, false);

        label.appendChild(checkbox);
        label.appendChild(text);

        fragment.appendChild(label);
      });

      const fieldset = document.createElement('fieldset');
      fieldset.dataset.slotId = getSlotId(sectionName);

      const legend = document.createElement('legend');
      legend.innerHTML = sectionName;

      fieldset.appendChild(legend);
      fieldset.appendChild(fragment);

      return fieldset;
    });

  // create the form
  const form = document.createElement('form');
  form.classList.add('loadout-manager__form');
  form.action = '#!loadout';
  form.method = 'get';
  form.onsubmit = (e) => {
    e.preventDefault();
    return false;
  }

  // loadout name input
  const input = document.createElement('input');
  input.type = 'text';
  input.name = 'loadout-name';
  input.placeholder = 'Loadout Name';
  if (selectId !== '-1' && selectId !== '0') input.value = selectId;

  // buttons
  const equip = document.createElement('input');
  equip.type = 'button';
  equip.value = 'Equip Items';
  if (selectId === '-1' || selectId === '0') equip.disabled = true;
  equip.onclick = () => handleEquip();

  const save = document.createElement('input');
  save.type = 'button';
  save.value = 'Save';
  save.onclick = (event) => handleSave(event, loadoutManager);

  const remove = document.createElement('input');
  remove.type = 'button';
  remove.value = 'Delete';
  if (selectId === '-1' || selectId === '0') remove.disabled = true;
  remove.onclick = () => handleRemove(selectId, loadoutManager);

  const buttonContainer = document.createElement('div');
  buttonContainer.classList.add('loadout-manager__buttons');
  buttonContainer.appendChild(equip);
  buttonContainer.appendChild(save);
  buttonContainer.appendChild(remove);

  // construct loadout manager form
  form.appendChild(input);
  form.appendChild(buttonContainer);
  fieldsets.forEach(fieldset => form.appendChild(fieldset));

  // append to the loadout manager div
  loadoutManager.appendChild(form);

  // check if we need to disable any options, that are not NOT conjunctions
  Array.from(document.querySelectorAll(`.loadout-manager__form input[type="checkbox"]:not([data-conjunction="AND"])`)).forEach(option => {
    handleItemSelect(option, items);
  });
  // now run through the AND conjunctions and disable any options that shouldn't be available
  Array.from(document.querySelectorAll(`.loadout-manager__form input[type="checkbox"][data-conjunction="AND"]:checked`)).forEach(option => {
    handleItemSelect(option, items);
  });

  // verify all parts of the loadout were used. We've been deleting from this object as keys have been used up, so any left means that items are missing
  const missingKeys = Object.keys(loadout);
  if (missingKeys.length > 0) {
    alert(`This loadout contains items which are now missing from your inventory for the following:
${missingKeys.map(missing => ` - ${missing}`).join("\r")}

Please verify the loadout and resave it to remove this message`);
  }
}

/**
 * This creates the loadout dropdown with the saved loadouts in it
 */
function createLoadoutDropdown(loadoutManager, selectedValue = '-1') {
  // remove any existing dropdown
  const existing = document.querySelector('.loadout-manager__select');
  existing && existing.remove();

  // create all the options from the saved values
  const loadouts = getLoadouts();
  const fragment = document.createDocumentFragment();
  Object.keys(loadouts).forEach(loadout => {
    const option = document.createElement('option');
    option.value = loadout;
    option.innerText = loadout;
    if (loadout === selectedValue) option.selected = true;

    fragment.appendChild(option);
  });

  // create the select and add some default options
  const loadoutManagerSelect = document.createElement('select');
  loadoutManagerSelect.classList.add('loadout-manager__select');
  loadoutManagerSelect.innerHTML = `
  <option value="-1"> -- Select an option</option>
  <option value="0"> -- Create new loadout</option>
`;
  loadoutManagerSelect.onchange = (event) => {
    const selectId = event.target.value;
    createLoadoutForm(selectId, loadoutManager);
  };
  loadoutManagerSelect.appendChild(fragment);

  // append to the loadout manager itself
  loadoutManager.appendChild(loadoutManagerSelect);

  // create the loadout form
  createLoadoutForm(selectedValue, loadoutManager);
}

/**
 * This creates the main loadout manager
 */
function createLoadoutManager() {
  const wrapper = document.getElementById('wrapper');

  // create the loadout manager wrapper
  const loadoutManager = document.createElement('div');
  loadoutManager.classList.add('loadout-manager');
  if (DEFAULT_OPENED) loadoutManager.classList.add('loadout-manager--opened');
  loadoutManager.innerHTML = `<strong>Equipment Loadout Manager</strong>`;

  // arrow to expand and collapse the manager
  const loadoutManagerArrow = document.createElement('div');
  loadoutManagerArrow.classList.add('loadout-manager__arrow');
  loadoutManagerArrow.innerHTML = `&lsaquo;`;
  loadoutManagerArrow.onclick = () => {
    loadoutManager.classList.toggle('loadout-manager--opened');
  }

  // create the loading overload. The handleEquip function will populate the innerHTML
  const loadoutManagerLoading = document.createElement('div');
  loadoutManagerLoading.classList.add('loadout-manager__loading');

  // DOM manipulation
  loadoutManager.appendChild(loadoutManagerArrow);
  loadoutManager.appendChild(loadoutManagerLoading);
  wrapper.appendChild(loadoutManager);

  // this should always be loaded last in the loadoutManager
  createLoadoutDropdown(loadoutManager);
}

// --------------------------------------------------------------------------------------------- The main function runner

(function () {
  'use strict';
  createStyleTag();
  createLoadoutManager();
})();