MWI Loadout Manager

Setup loadout according to Combat Simulator export files

// ==UserScript==
// @name        MWI Loadout Manager
// @namespace   https://github.com/tobytorn
// @description Setup loadout according to Combat Simulator export files
// @author      tobytorn
// @match       https://www.milkywayidle.com/*
// @match       https://test.milkywayidle.com/*
// @version     1.0.1
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @supportURL  https://github.com/tobytorn/mwi-loadout-manager
// @license     MIT
// @require     https://unpkg.com/jquery@3.7.0/dist/jquery.min.js
// ==/UserScript==

(function () {
  'use strict';

  const MISC_SVG = '/static/media/misc_sprite.2864433e.svg';
  const ITEMS_SVG = '/static/media/items_sprite.8134f2ed.svg';
  const ABILITIES_SVG = '/static/media/abilities_sprite.7b4605a0.svg';

  const CSS = `
    .lmInput {
      color: var(--color-text-dark-mode);
      background: var(--color-midnight-700);
      border: none;
      padding: 4px;
      resize: none;
      outline: none;
    }
    .lmError {
      color: var(--color-warning-hover);
      display: none;
    }
    .lmItemIcon {
      position: absolute;
      top: 15%;
      left: 15%;
      width: 70%;
      height: 70%;
    }
    .lmEnhancementLevel {
      z-index: 1;
      position: absolute;
      top: 1px;
      left: 1px;
      color: var(--color-orange-400);
      text-shadow: 1px 1px 3px var(--color-midnight-500);
      font-size: 12px;
      font-weight: 500;
      line-height: 12px;
    }
    .lmPanelEntry {
      margin: 0 auto 8px auto;
      width: fit-content;
    }
    .CombatZones_consumablesAndAbilitiesContainer__rb6Fi.lmSeen > *,
    .Party_consumablesAndAbilitiesContainer__2ff8f.lmSeen > * {
      margin-left: auto;
    }
    .lmCombatEntry {
      align-self: center;
      padding: 8px;
    }
    .lmCombatEntry.lmEntryCorrect::after {
      content: 'Correct';
      font-size: 14px;
      color: var(--color-jade-600);
    }
    .lmCombatEntry.lmEntryWrong::after {
      content: 'Wrong';
      font-size: 14px;
      color: var(--color-warning);
    }
    .lmSetTriggers {
      margin-bottom: 8px;
    }
    .lmNoPick {
      margin-bottom: 4px;
      text-align: center;
      color: var(--color-warning);
    }

    .lmModalContent {
      width: 350px;
      height: 550px;
      display: flex;
      flex-direction: column;
      gap: 4px;
      align-items: center;
    }
    .lmModalTitle {
      font-size: 16px;
      font-weight: 500;
      line-height: 20px;
    }
    .lmModalTabContainer {
      flex-shrink: 0;
      flex-grow: 0;
      margin: 0;
      overflow: hidden;
      width: 100%;
      border-bottom: 2px solid var(--color-divider);
      display: flex;
      letter-spacing: 0.02857em;
    }
    .lmTabButton {
      color: var(--color-text-dark-mode);
      font-size: 14px;
      font-weight: 500;
      min-height: 32px;
      height: 32px;
      margin: 0;
      border-top-left-radius: 4px;
      border-top-right-radius: 4px;
      padding: 6px;
      cursor: pointer;
    }
    .lmTabButton:hover {
      background: var(--color-midnight-300);
    }
    .lmTabButton.lmActive {
      background: var(--color-space-600);
    }
    .lmModalTabContent {
      display: flex;
      width: 100%;
      height: 100%;
      flex-direction: column;
      gap: 4px;
      text-align: left;
      overflow: auto;
    }
    .lmModalTabContent:not(.lmActive) {
      display: none;
    }
    #lmSelectTab .lmSelectTabNote {
      font-style: italic;
    }
    #lmLoadoutList {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }
    #lmLoadoutList .lmLoadout {
      border-radius: 4px;
      border: 2px solid var(--color-midnight-700);
      background: var(--color-midnight-700);
      padding: 8px;
      display: flex;
      flex-direction: column;
      gap: 8px;
      cursor: pointer;
    }
    #lmLoadoutList .lmLoadout:hover {
      background-color: var(--color-midnight-300);
    }
    #lmLoadoutList .lmLoadout.lmActive {
      border: 2px solid var(--color-neutral-200);
    }
    #lmLoadoutList .lmLoadoutNameContainer {
      display: flex;
      gap: 4px;
    }
    #lmLoadoutList .lmLoadoutName {
      flex-grow: 1;
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
    }
    #lmLoadoutList .lmLoadout.lmActive .lmLoadoutName {
      white-space: normal;
    }
    #lmLoadoutList .lmLoadoutStar {
      height: 20px;
      width: 20px;
      flex-shrink: 0;
    }
    #lmLoadoutList .lmLoadout:not(.lmCurrent) .lmLoadoutStar {
      display: none;
    }
    #lmLoadoutList .lmLoadoutSummary {
      display: grid;
      gap: 4px;
      grid-template-columns: repeat(6, 45px);
    }
    #lmLoadoutList .lmLoadoutSummary:empty::after {
      content: 'This exactly matches your current loadout';
      font-style: italic;
      white-space: nowrap;
      color: var(--color-market-buy);
    }
    #lmLoadoutList .lmLoadout.lmActive .lmLoadoutSummary {
      display: none;
    }
    #lmLoadoutList .lmLoadoutDetails {
      display: grid;
      grid-template-columns: repeat(3, 45px);
      gap: 4px;
      justify-content: center;
    }
    #lmLoadoutList .lmLoadout:not(.lmActive) .lmLoadoutDetails {
      display: none;
    }
    #lmLoadoutList .lmLoadoutSlot {
      height: 45px;
      width: 45px;
      background-color: var(--color-midnight-500);
      border-radius: 4px;
      border: 1px solid var(--color-space-300);
      position: relative;
    }
    #lmLoadoutList .lmTriggerIcon {
      display: none;
      z-index: 1;
      position: absolute;
      left: 50%;
      bottom: -1px;
      transform: translate(-50%, 50%);
      width: 16px;
      height: 16px;
    }
    #lmLoadoutList .lmTriggerIcon svg {
      filter: brightness(0) saturate(100%) invert(25%) sepia(95%) saturate(3196%) hue-rotate(346deg) brightness(93%) contrast(84%);
    }
    #lmLoadoutList .lmTriggerIcon > div {
      position: absolute;
      width: 50%;
      height: 50%;
      border-radius: 50%;
      background-color: var(--color-midnight-500);
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      z-index: -1;
    }
    #lmLoadoutList .lmLoadoutSlot.lmBadTriggers .lmTriggerIcon {
      display: block;
    }
    #lmLoadoutList .lmLoadoutDetails .lmLoadoutSlot.lmBad {
      border: 3px solid var(--color-warning);
    }
    #lmLoadoutList .lmLoadoutActions {
      display: flex;
      gap: 4px;
    }
    #lmLoadoutList .lmDelete {
      background: var(--color-market-sell);
    }
    #lmLoadoutList .lmDelete:hover {
      background: var(--color-market-sell-hover);
    }
    #lmLoadoutList .lmDelete::after {
      content: 'Delete';
    }
    #lmLoadoutList .lmDelete.lmConfirming {
      background: var(--color-disabled);
    }
    #lmLoadoutList .lmDelete.lmConfirming::after {
      content: 'Confirm Delete';
    }
    #lmLoadoutList .lmDelete.lmConfirming.lmConfirmed {
      background: var(--color-warning);
    }
    #lmLoadoutList .lmDelete.lmConfirming.lmConfirmed:hover {
      background: var(--color-warning-hover);
    }
    #lmLoadoutList .lmLoadout:not(.lmActive) .lmLoadoutActions {
      display: none;
    }
    #lmLoadoutList .lmLoadout.lmCurrent .lmSelect,
    #lmLoadoutList .lmLoadout.lmCurrent .lmDelete {
      display: none;
    }
    #lmLoadoutList .lmLoadout:not(.lmCurrent) .lmDeselect {
      display: none;
    }
    #lmImportTab textarea {
      flex-grow: 1;
    }

    .EquipmentPanel_playerModel__3LRB6 > .lmHighlight .ItemSelector_itemSelector__2eTV6,
    .AbilitySlot_abilitySlot__22oxh.lmHighlight .AbilitySlot_slot__3BSD4,
    .ConsumableSlot_consumableSlotContainer__2DwgD.lmHighlight .ConsumableSlot_itemSelectorContainer__RODer {
      position: relative;
    }
    .EquipmentPanel_playerModel__3LRB6 > .lmHighlight .ItemSelector_itemSelector__2eTV6::after,
    .ConsumableSlot_consumableSlotContainer__2DwgD.lmHighlight .ConsumableSlot_itemSelectorContainer__RODer::after,
    .AbilitySlot_abilitySlot__22oxh.lmHighlight .AbilitySlot_slot__3BSD4::after {
      content: '';
      display: block;
      width: 100%;
      height: 100%;
      position: absolute;
      top: 0;
      left: 0;
      border-radius: inherit;
      border: 3px solid var(--color-warning);
      pointer-events: none;
    }
    .lmHighlightTriggers .CombatTriggersSetting_combatTriggersSetting__380iI {
      filter: brightness(0) saturate(100%) invert(25%) sepia(95%) saturate(3196%) hue-rotate(346deg) brightness(93%) contrast(84%);
    }
    .lmPick {
      position: relative;
      grid-area: 1 / 1;
    }
    .lmPick.lmPickSecond {
      grid-column-start: 2;
    }
    .lmPick::after {
      content: '';
      display: block;
      width: 100%;
      height: 100%;
      position: absolute;
      top: 0;
      left: 0;
      border-radius: 4px;
      border: 3px solid var(--color-jade-600);
      pointer-events: none;
    }
    .lmCombatUnitWrongLoadout::after {
      content: 'Wrong Loadout';
      width: 100%;
      height: 20px;
      font-size: 14px;
      font-weight: 500;
      color: var(--color-warning);
    }
  `;
  const PANEL_ENTRY_HTML = '<div class="lmPanelEntry"><div class="lmEntry Button_button__1Fe9z">Loadout</div></div>';
  const COMBAT_ENTRY_HTML = '<div class="lmCombatEntry"><div class="lmEntry Button_button__1Fe9z">Loadout</div></div>';
  const SET_TRIGGERS_BUTTON_HTML =
    '<div class="lmSetTriggers Button_button__1Fe9z Button_fullWidth__17pVU">Apply Selected Loadout</div>';
  const NO_PICK_NOTE_HTML = '<div class="lmNoPick">Item not owned for the selected loadout</div>';
  const LOADOUT_HTML = `
    <div class="lmLoadout">
      <div class="lmLoadoutNameContainer">
        <div class="lmLoadoutName"></div>
        <div class="lmLoadoutStar">
          <svg width="100%" height="100%">
            <use href="${MISC_SVG}#elite_2_star"></use>
          </svg>
        </div>
      </div>
      <div class="lmLoadoutSummary"></div>
      <div class="lmLoadoutDetails"><!-- equipments and abilities --></div>
      <div class="lmLoadoutDetails"><!-- consumables --></div>
      <div class="lmLoadoutActions">
        <div class="lmSelect Button_button__1Fe9z Button_fullWidth__17pVU">Highlight</div>
        <div class="lmDelete Button_button__1Fe9z Button_fullWidth__17pVU"></div>
        <div class="lmDeselect Button_button__1Fe9z Button_fullWidth__17pVU">Clear highlight</div>
      </div>
    </div>
  `;
  const LOADOUT_ITEM_HTML = `
    <div class="lmLoadoutSlot">
      <div class="lmItemIcon">
        <svg width="100%" height="100%"><use href=""></use></svg>
      </div>
      <div class="lmEnhancementLevel"></div>
      <div class="lmTriggerIcon">
        <div><!-- a small dot to make the center of this icon opaque --></div>
        <svg width="100%" height="100%"><use href="${MISC_SVG}#settings"></use></svg>
      </div>
    </div>
  `;
  const SETTING_MODAL_HTML = `
    <div id="lmSettingModal" class="Modal_modalContainer__3B80m">
      <div class="Modal_background__2B88R"></div>
      <div class="Modal_modal__1Jiep">
        <div class="lmModalContent">
          <div class="lmModalTitle">Loadout Manager</div>
          <div class="lmModalTabContainer">
            <div class="lmTabButton lmActive" data-lm-tab="lmSelectTab">Loadouts</div>
            <div class="lmTabButton" data-lm-tab="lmImportTab">Import</div>
            <div class="lmTabButton" data-lm-tab="lmCurrentTab">Current</div>
          </div>
          <div id="lmSelectTab" class="lmModalTabContent lmActive">
            <div class="lmSelectTabNote">Select a loadout to compare with your current one</div>
            <div id="lmLoadoutList"></div>
          </div>
          <div id="lmImportTab" class="lmModalTabContent">
            <div>Loadout name:</div>
            <input class="lmInput" type="text" maxlength="100">
            <div class="lmError" data-lm-field="name"></div>
            <div>Combat simulator export file:</div>
            <textarea class="lmInput"></textarea>
            <div class="lmError" data-lm-field="data"></div>
            <div id="lmImportSave" class="Button_button__1Fe9z Button_success__6d6kU Button_fullWidth__17pVU">Save</div>
          </div>
          <div id="lmCurrentTab" class="lmModalTabContent">
            <div>Enter a name to save your current loadout:</div>
            <input class="lmInput" type="text" maxlength="100">
            <div class="lmError" data-lm-field="name"></div>
            <div id="lmSaveCurrent" class="Button_button__1Fe9z Button_success__6d6kU Button_fullWidth__17pVU">Save</div>
          </div>
        </div>
        <div class="Modal_closeButton__3eTF7">
          <div role="img" alt="Close" class="Icon_icon__2LtL_">
            <svg width="100%" height="100%"><use href="${MISC_SVG}#close_menu"></use></svg>
          </div>
        </div>
      </div>
    </div>
  `;

  const ITEM_HRID_PREFIX = '/items/';
  const ABILITY_HRID_PREFIX = '/abilities/';
  const LOCATION_HRID_PREFIX = '/item_locations/';
  const ITEM_HREF_PREFIX = `${ITEMS_SVG}#`;
  const ABILITY_HREF_PREFIX = `${ABILITIES_SVG}#`;
  const TRIGGER_COMPARATOR_PREFIX = '/combat_trigger_comparators/';
  const TRIGGER_CONDITION_PREFIX = '/combat_trigger_conditions/';
  const TRIGGER_DEPENDENCY_PREFIX = '/combat_trigger_dependencies/';
  const EQUIPMENT_GRID_POSITION = {
    head: [1, 2],
    body: [2, 2],
    legs: [3, 2],
    feet: [4, 2],
    hands: [3, 1],
    main_hand: [2, 1],
    two_hand: [2, 1],
    off_hand: [2, 3],
    pouch: [3, 3],
    back: [1, 1],
    neck: [1, 5],
    earrings: [2, 5],
    ring: [3, 5],
  };
  const SLOT_WITH_TRIGGERS = {
    abilities: {
      hrefPrefix: ABILITY_HREF_PREFIX,
      hridPrefix: ABILITY_HRID_PREFIX,
      hridField: 'abilityHrid',
      gridIndex: 0,
      gridPosition: [5, 1],
    },
    food: {
      hrefPrefix: ITEM_HREF_PREFIX,
      hridPrefix: ITEM_HRID_PREFIX,
      hridField: 'itemHrid',
      gridIndex: 1,
      gridPosition: [1, 1],
    },
    drinks: {
      hrefPrefix: ITEM_HREF_PREFIX,
      hridPrefix: ITEM_HRID_PREFIX,
      hridField: 'itemHrid',
      gridIndex: 1,
      gridPosition: [1, 4],
    },
  };

  const $ = window.jQuery;

  class LoadoutManager {
    constructor() {
      this.id = null;
      this.playerName = '';
      this.loadouts = [];
      this.selected = null;
      this.current = {};
    }

    onInitCharacterData(msg) {
      this.id = msg.character.id.toString();
      this.playerName = msg.character.name;
      const savedData = GM_getValue(this.id, {});
      this.loadouts = savedData.loadouts ?? [];
      this.selected = this.loadouts.find((x) => x.name === savedData.selectedName) ?? null;
      this.current = {
        abilities: Array.from({ length: 5 }, () => ({ abilityHrid: '' })),
        drinks: [],
        food: [],
        equipment: [],
        triggerMap: { ...msg.abilityCombatTriggersMap, ...msg.consumableCombatTriggersMap },
      };
      this.onConsumableSlotsUpdated(msg);
      for (const ability of msg.characterAbilities) {
        if (ability.slotNumber > 0) {
          this.current.abilities[ability.slotNumber - 1].abilityHrid = ability.abilityHrid;
        }
      }
      for (const item of msg.characterItems) {
        const location = item.itemLocationHrid.slice(LOCATION_HRID_PREFIX.length);
        if (location in EQUIPMENT_GRID_POSITION) {
          this.current.equipment.push({
            itemLocationHrid: item.itemLocationHrid,
            itemHrid: item.itemHrid,
            enhancementLevel: item.enhancementLevel,
          });
        }
      }
    }

    onConsumableSlotsUpdated(msg) {
      this.current.drinks = msg.actionTypeDrinkSlotsMap['/action_types/combat'].slice(0, 3).map((x) => ({
        itemHrid: x?.itemHrid ?? '',
      }));
      this.current.food = msg.actionTypeFoodSlotsMap['/action_types/combat'].slice(0, 3).map((x) => ({
        itemHrid: x?.itemHrid ?? '',
      }));
    }

    onItemsUpdated(msg) {
      const removedLocations = (msg.endCharacterItems ?? [])
        .filter((x) => x.count === 0)
        .map((x) => x.itemLocationHrid);
      this.current.equipment = this.current.equipment.filter((x) => !removedLocations.includes(x.itemLocationHrid));
      for (const item of msg.endCharacterItems) {
        if (item.count === 1 && item.itemLocationHrid.slice(LOCATION_HRID_PREFIX.length) in EQUIPMENT_GRID_POSITION) {
          this.current.equipment.push({
            itemLocationHrid: item.itemLocationHrid,
            itemHrid: item.itemHrid,
            enhancementLevel: item.enhancementLevel,
          });
        }
      }
    }

    onAbilitiesUpdated(msg) {
      for (const ability of msg.endCharacterAbilities) {
        for (const currentAbility of this.current.abilities) {
          if (currentAbility.abilityHrid === ability.abilityHrid) {
            currentAbility.abilityHrid = '';
          }
        }
        if (ability.slotNumber > 0) {
          this.current.abilities[ability.slotNumber - 1].abilityHrid = ability.abilityHrid;
        }
      }
    }

    onCombatTriggersUpdated(msg) {
      let target;
      if (msg.combatTriggerTypeHrid === '/combat_trigger_types/ability') {
        target = msg.abilityHrid;
      } else if (msg.combatTriggerTypeHrid === '/combat_trigger_types/consumable') {
        target = msg.itemHrid;
      }
      this.current.triggerMap[target] = msg.combatTriggers;
    }

    save() {
      if (this.id === null) {
        throw new Error('LoadoutManager not initialized');
      }
      GM_setValue(this.id, {
        loadouts: this.loadouts,
        selectedName: this.selected?.name ?? null,
      });
    }

    add(loadout) {
      this.loadouts.push(loadout);
      this.save();
    }

    addCurrent(name) {
      const data = structuredClone(this.current);
      try {
        validateLoadoutData(data);
      } catch (err) {
        console.log('[Loadout Manager] addCurrent error', err);
        return;
      }
      this.add({ name, data });
    }

    select(name) {
      const loadout = this.loadouts.find((x) => x.name === name);
      if (!loadout) {
        return;
      }
      this.selected = loadout;
      this.save();
    }

    deselect() {
      this.selected = null;
      this.save();
    }

    delete(name) {
      const index = this.loadouts.findIndex((x) => x.name === name);
      if (index < 0) {
        return;
      }
      this.loadouts.splice(index, 1);
      if (this.selected?.name === name) {
        this.selected = null;
      }
      this.save();
    }

    /**
     * @param {keyof typeof EQUIPMENT_GRID_POSITION} location
     * @param {Loadout | undefined} loadout
     * @returns {Item | null}
     */
    compareEquipment(location, loadout = undefined) {
      loadout = loadout ?? this.selected;
      if (!loadout) {
        return null;
      }
      const item = loadout.data.equipment.find((x) => x.itemLocationHrid === `${LOCATION_HRID_PREFIX}${location}`);
      if (!item) {
        return null;
      }
      const currentItem = this.current.equipment.find(
        (x) => x.itemLocationHrid === `${LOCATION_HRID_PREFIX}${location}`,
      );
      if (currentItem?.itemHrid === item.itemHrid && currentItem.enhancementLevel >= item.enhancementLevel) {
        return null;
      } else {
        return item;
      }
    }

    /**
     * @param {'abilities' | 'food' | 'drinks'} type
     * @param {number} index
     * @param {Loadout | undefined} loadout
     * @returns {{ slot: Ability | Item } | { triggers: Trigger[] } | {}}
     */
    compareSlotWithTriggers(type, index, loadout = undefined) {
      loadout = loadout ?? this.selected;
      if (!loadout) {
        return {};
      }
      const item = loadout.data[type][index];
      const currentItem = manager.current[type][index];
      const hridField = type === 'abilities' ? 'abilityHrid' : 'itemHrid';
      if (currentItem?.[hridField] === item[hridField]) {
        const triggers = loadout.data.triggerMap[item[hridField]] ?? [];
        const currentTriggers = manager.current.triggerMap[item[hridField]] ?? [];
        if (!this.compareTriggers(triggers, currentTriggers)) {
          return { triggers };
        }
      } else {
        return { slot: item };
      }
      return {};
    }

    compareTriggers(t1, t2) {
      if (t1.length !== t2.length) {
        return false;
      }
      return t1.every(
        (x, i) =>
          x.comparatorHrid === t2[i].comparatorHrid &&
          x.conditionHrid === t2[i].conditionHrid &&
          x.dependencyHrid === t2[i].dependencyHrid &&
          x.value === t2[i].value,
      );
    }

    /** @returns { boolean } true if the selected loadout exactly matches the current one or no loadout is selected */
    compareSelectedAndCurrent() {
      if (!this.selected) {
        return true;
      }
      if (Object.keys(EQUIPMENT_GRID_POSITION).some((x) => manager.compareEquipment(x) !== null)) {
        return false;
      }
      for (const type of Object.keys(SLOT_WITH_TRIGGERS)) {
        for (let i = 0; i < this.selected.data[type].length; i++) {
          if (!$.isEmptyObject(manager.compareSlotWithTriggers(type, i))) {
            return false;
          }
        }
      }
      return true;
    }
  }

  class DomMonitor {
    constructor() {
      this.equipmentPanels = document.body.getElementsByClassName('EquipmentPanel_equipmentPanel__29pDG');
      this.abilitiesPanels = document.body.getElementsByClassName('AbilitiesPanel_abilitiesPanel__2kLc9');
      this.combatZoneSettings = document.body.getElementsByClassName(
        'CombatZones_consumablesAndAbilitiesContainer__rb6Fi',
      );
      this.combatPartySettings = document.body.getElementsByClassName('Party_consumablesAndAbilitiesContainer__2ff8f');
      this.equipmentContainers = document.body.getElementsByClassName('EquipmentPanel_playerModel__3LRB6');
      this.consumablesContainers = document.body.getElementsByClassName(
        'ActionTypeConsumableSlots_actionTypeConsumableSlots__1VwJx',
      );
      this.combatAbilitiesContainers = document.body.getElementsByClassName('CombatZones_abilitiesContainer__1mYiZ');
      this.partyAbilitiesContainers = document.body.getElementsByClassName('Party_abilitiesContainer__VAksm');
      this.abilitiesPanelAbilitiesContainers = document.body.getElementsByClassName(
        'AbilitiesPanel_abilityGrid__-p-VF',
      );
      this.combatUnits = document.body.getElementsByClassName('CombatUnit_combatUnit__1m3XT');
    }

    run() {
      const bodyOb = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          for (const added of mutation.addedNodes) {
            if (added instanceof HTMLElement) {
              this.onChange();
              return;
            }
          }
        }
      });
      bodyOb.observe(document.body, { childList: true, subtree: true });
      const tooltipOb = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          for (const added of mutation.addedNodes) {
            if (
              added instanceof HTMLElement &&
              added.getAttribute('role') === 'tooltip' &&
              added.classList.contains('MuiTooltip-popperInteractive')
            ) {
              this.onTooltipAdded(added);
            }
          }
        }
      });
      tooltipOb.observe(document.body, { childList: true });
    }

    onChange() {
      this.checkCollection(this.equipmentPanels, addEntryButtonInEquipmentPanel);
      this.checkCollection(this.abilitiesPanels, addEntryButtonInAbilitiesPanel);
      this.checkCollection(this.combatZoneSettings, updateEntryButtonInCombatSettings);
      this.checkCollection(this.combatPartySettings, updateEntryButtonInCombatSettings);
      this.checkCollection(this.equipmentContainers, highlightEquipments);
      this.checkCollection(this.consumablesContainers, highlightConsumables);
      this.checkCollection(this.combatAbilitiesContainers, highlightAbilities);
      this.checkCollection(this.partyAbilitiesContainers, highlightAbilities);
      this.checkCollection(this.abilitiesPanelAbilitiesContainers, highlightAbilities);
      this.checkCollection(this.combatUnits, highlightCombatUnit);
    }

    checkCollection(collection, fn) {
      for (const element of collection) {
        if (!element.classList.contains('lmSeen')) {
          element.classList.add('lmSeen');
          fn(element);
        }
      }
    }

    refresh() {
      this.refreshCollection(this.combatZoneSettings, updateEntryButtonInCombatSettings);
      this.refreshCollection(this.combatPartySettings, updateEntryButtonInCombatSettings);
      this.refreshCollection(this.equipmentContainers, highlightEquipments);
      this.refreshCollection(this.consumablesContainers, highlightConsumables);
      this.refreshCollection(this.combatAbilitiesContainers, highlightAbilities);
      this.refreshCollection(this.partyAbilitiesContainers, highlightAbilities);
      this.refreshCollection(this.abilitiesPanelAbilitiesContainers, highlightAbilities);
      this.refreshCollection(this.combatUnits, highlightCombatUnit);
    }

    refreshCollection(collection, fn) {
      for (const element of collection) {
        fn(element);
      }
    }

    onTooltipAdded(tooltip) {
      const $tooltip = $(tooltip);
      if ($tooltip.find('.ItemSelector_menu__12sEM').length > 0) {
        updateItemTooltip($tooltip);
      } else if ($tooltip.find('.AbilitySlot_abilitySelector__kwDWq').length > 0) {
        updateAbilitiesTooltip($tooltip);
      } else if ($tooltip.find('.CombatTriggersSetting_combatTriggersEditMenu__QL_kp').length > 0) {
        updateTriggersTooltip($tooltip);
      }
    }
  }

  const manager = new LoadoutManager();
  const domMonitor = new DomMonitor();

  // For debug only
  // eslint-disable-next-line no-unused-vars
  function debugInjectJq() {
    const script = document.createElement('script');
    script.src = 'https://unpkg.com/jquery@3.7.0/dist/jquery.min.js';
    document.head.appendChild(script);
  }

  function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  // https://stackoverflow.com/a/70267397
  function interceptWebSocket(fn) {
    const property = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data');
    const data = property.get;

    function lookAtMessage() {
      const msg = data.call(this);
      if (!(this.currentTarget instanceof WebSocket)) {
        return msg;
      }
      Object.defineProperty(this, 'data', { value: msg }); //anti-loop
      fn(msg);
      return msg;
    }

    property.get = lookAtMessage;
    Object.defineProperty(MessageEvent.prototype, 'data', property);
  }

  // https://github.com/facebook/react/issues/10135#issuecomment-314441175
  function changeReactInput($element, value) {
    if ($element.length === 0) {
      return;
    }
    const element = $element[0];
    const valueSetter = Object.getOwnPropertyDescriptor(element, 'value').set;
    const prototype = Object.getPrototypeOf(element);
    const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
    if (valueSetter && valueSetter !== prototypeValueSetter) {
      prototypeValueSetter.call(element, value);
    } else {
      valueSetter.call(element, value);
    }
    element.dispatchEvent(new Event('input', { bubbles: true }));
  }

  // https://stackoverflow.com/a/53754780
  function changeReactSelect($element, value) {
    $element.val(value)[0]?.dispatchEvent(new Event('change', { bubbles: true }));
  }

  function validatePrefix(s, prefix) {
    return s.startsWith(prefix) && /^[a-z0-9_]+$/.test(s.slice(prefix.length));
  }

  function validateLoadoutData(data) {
    if (
      data.abilities.length !== 5 ||
      !data.abilities.every((x) => validatePrefix(x.abilityHrid, ABILITY_HRID_PREFIX) || x.abilityHrid === '')
    ) {
      throw new Error('Invalid abilities');
    }
    if (
      data.drinks.length !== 3 ||
      !data.drinks.every((x) => validatePrefix(x.itemHrid, ITEM_HRID_PREFIX) || x.itemHrid === '')
    ) {
      throw new Error('Invalid drinks');
    }
    if (
      data.food.length !== 3 ||
      !data.food.every((x) => validatePrefix(x.itemHrid, ITEM_HRID_PREFIX) || x.itemHrid === '')
    ) {
      throw new Error('Invalid food');
    }
    for (const [key, triggers] of Object.entries(data.triggerMap)) {
      if (!validatePrefix(key, ABILITY_HRID_PREFIX) && !validatePrefix(key, ITEM_HRID_PREFIX)) {
        throw new Error('Invalid trigger key');
      }
      if (
        !triggers.every(
          (x) =>
            validatePrefix(x.comparatorHrid, TRIGGER_COMPARATOR_PREFIX) &&
            validatePrefix(x.conditionHrid, TRIGGER_CONDITION_PREFIX) &&
            validatePrefix(x.dependencyHrid, TRIGGER_DEPENDENCY_PREFIX) &&
            Number.isFinite(x.value),
        )
      ) {
        throw new Error(`Invalid trigger of "${key}"`);
      }
    }
    if (
      !data.equipment.every(
        (x) =>
          validatePrefix(x.itemHrid, ITEM_HRID_PREFIX) &&
          validatePrefix(x.itemLocationHrid, LOCATION_HRID_PREFIX) &&
          x.enhancementLevel >= 0,
      )
    ) {
      throw new Error('Invalid equipment');
    }
  }

  function parseLoadoutData(str) {
    const data = JSON.parse(str);
    const loadoutData = {
      abilities: data.abilities.slice(0, 5),
      drinks: data.drinks['/action_types/combat'].slice(0, 3),
      food: data.food['/action_types/combat'].slice(0, 3),
      equipment: data.player.equipment,
      triggerMap: data.triggerMap,
    };
    validateLoadoutData(loadoutData);
    return loadoutData;
  }

  function formatTriggers(triggers) {
    const lines = triggers.map((x) =>
      [
        x.dependencyHrid.slice(TRIGGER_DEPENDENCY_PREFIX.length),
        x.conditionHrid.slice(TRIGGER_CONDITION_PREFIX.length),
        x.comparatorHrid.slice(TRIGGER_COMPARATOR_PREFIX.length),
        x.value.toString(),
      ].join(' '),
    );
    return lines.join('\n');
  }

  function renderLoadoutDetails($row, loadout) {
    const $summary = $row.find('.lmLoadoutSummary');
    const $details = $row.find('.lmLoadoutDetails');
    for (const [location, [r, c]] of Object.entries(EQUIPMENT_GRID_POSITION)) {
      const $item = $(LOADOUT_ITEM_HTML);
      $item.attr('data-lm-location', location);
      const item = loadout.data.equipment.find((x) => x.itemLocationHrid === `${LOCATION_HRID_PREFIX}${location}`);
      if (item) {
        $item.find('.lmItemIcon use').attr('href', ITEM_HREF_PREFIX + item.itemHrid.slice(ITEM_HRID_PREFIX.length));
        if (item.enhancementLevel > 0) {
          $item.find('.lmEnhancementLevel').text(`+${item.enhancementLevel}`);
        }
        if (manager.compareEquipment(location, loadout)) {
          $item.addClass('lmBad');
          $summary.append($item.clone());
        }
      }
      $item.css('grid-row', r);
      $item.css('grid-column', c);
      if (location === 'two_hand') {
        if (item) {
          $details.find('[data-lm-location=main_hand]').remove();
        } else {
          continue;
        }
      }
      $details.eq(0).append($item);
    }
    for (const [type, slotInfo] of Object.entries(SLOT_WITH_TRIGGERS)) {
      for (let i = 0; i < loadout.data[type].length; i++) {
        const item = loadout.data[type][i];
        const $item = $(LOADOUT_ITEM_HTML);
        if (item[slotInfo.hridField] !== '') {
          $item
            .find('.lmItemIcon use')
            .attr('href', slotInfo.hrefPrefix + item[slotInfo.hridField].slice(slotInfo.hridPrefix.length));
        }
        const highlight = manager.compareSlotWithTriggers(type, i, loadout);
        if ('slot' in highlight) {
          $item.addClass('lmBad');
          $summary.append($item.clone());
        } else if ('triggers' in highlight) {
          $item.attr('title', formatTriggers(highlight.triggers));
          $summary.append($item.clone());
          $item.addClass('lmBadTriggers');
        }
        $item.css('grid-row', slotInfo.gridPosition[0]);
        $item.css('grid-column', slotInfo.gridPosition[1] + i);
        $details.eq(slotInfo.gridIndex).append($item);
      }
    }
  }

  function refreshLoadouts(activeName) {
    const $content = $('#lmLoadoutList');
    $content.empty();
    for (const loadout of manager.loadouts) {
      const $row = $(LOADOUT_HTML);
      $row.find('.lmLoadoutName').text(loadout.name);
      $row.attr('data-lm-name', loadout.name);
      renderLoadoutDetails($row, loadout);
      if (loadout === manager.selected) {
        $row.addClass('lmCurrent');
      }
      if (loadout.name === activeName) {
        $row.addClass('lmActive');
      }
      $content.append($row);
    }
    $content.find('.lmLoadout').on('click', function () {
      const $this = $(this);
      const isActive = $this.hasClass('lmActive');
      $content.find('.lmLoadout').removeClass('lmActive');
      $this.toggleClass('lmActive', !isActive);
      const $confirming = $content.find('.lmConfirming');
      $confirming.removeClass('lmConfirming');
      $confirming.removeClass('lmConfirmed');
    });
    $content.find('.lmSelect').on('click', function () {
      const name = $(this).closest('.lmLoadout').attr('data-lm-name');
      manager.select(name);
      refreshLoadouts(name);
      domMonitor.refresh();
      return false; // stop propagation
    });
    $content.find('.lmDelete').on('click', function () {
      const $this = $(this);
      if ($this.hasClass('lmConfirmed')) {
        const name = $this.closest('.lmLoadout').attr('data-lm-name');
        manager.delete(name);
        refreshLoadouts(null);
      } else if (!$this.hasClass('lmConfirming')) {
        $this.addClass('lmConfirming');
        setTimeout(() => {
          if ($this.hasClass('lmConfirming')) {
            $this.addClass('lmConfirmed');
          }
        }, 1000);
      }
      return false; // stop propagation
    });
    $content.find('.lmDeselect').on('click', function () {
      const name = $(this).closest('.lmLoadout').attr('data-lm-name');
      manager.deselect();
      refreshLoadouts(name);
      domMonitor.refresh();
      return false; // stop propagation
    });
  }

  function checkLoadoutName(name) {
    if (name.length === 0) {
      return 'Empty name';
    }
    if (manager.loadouts.some((x) => x.name === name)) {
      return 'Duplicate name';
    }
  }

  function saveImportedLoadout() {
    const $importTab = $('#lmImportTab');
    $importTab.find('.lmError').hide();
    const name = $importTab.find('input').val().trim();
    const dataStr = $importTab.find('textarea').val();

    const nameError = checkLoadoutName(name);
    if (nameError) {
      $importTab.find('.lmError[data-lm-field="name"]').text(nameError).show();
      return;
    }
    let data;
    try {
      data = parseLoadoutData(dataStr);
    } catch (err) {
      $importTab
        .find('.lmError[data-lm-field="data"]')
        .text(`Invalid data: ${String(err)}`)
        .show();
      return;
    }
    manager.add({ name, data });

    $importTab.find('input').val('');
    $importTab.find('textarea').val('');
    $('.lmModalContent .lmTabButton[data-lm-tab="lmSelectTab"]').trigger('click');
    refreshLoadouts(name);
  }

  function saveCurrentLoadout() {
    const $tab = $('#lmCurrentTab');
    $tab.find('.lmError').hide();
    const name = $tab.find('input').val().trim();

    const nameError = checkLoadoutName(name);
    if (nameError) {
      $tab.find('.lmError[data-lm-field="name"]').text(nameError).show();
      return;
    }
    manager.addCurrent(name);

    $tab.find('input').val('');
    $('.lmModalContent .lmTabButton[data-lm-tab="lmSelectTab"]').trigger('click');
    refreshLoadouts(name);
  }

  function showSettingModal() {
    const $modal = $(SETTING_MODAL_HTML);
    $modal.insertAfter($('.GamePage_gamePage__ixiPl').first());
    $modal.find('.Modal_background__2B88R').on('click', closeSettingModal);
    $modal.find('.Modal_closeButton__3eTF7').on('click', closeSettingModal);
    $modal.find('.lmTabButton').on('click', function () {
      const $this = $(this);
      $this.siblings().removeClass('lmActive');
      $this.addClass('lmActive');
      const tabName = this.dataset.lmTab;
      const $modal = $this.closest('.lmModalContent');
      $modal.find('.lmModalTabContent').removeClass('lmActive');
      $modal.find(`#${tabName}`).addClass('lmActive');
      $modal.find('.lmError').hide();
    });
    $modal.find('#lmImportSave').on('click', saveImportedLoadout);
    $modal.find('#lmSaveCurrent').on('click', saveCurrentLoadout);
    refreshLoadouts(null);
  }

  function closeSettingModal() {
    $('#lmSettingModal').remove();
  }

  function onWebSocketMessage(msg) {
    try {
      const parsed = JSON.parse(msg);
      switch (parsed.type) {
        case 'init_character_data':
          manager.onInitCharacterData(parsed);
          break;
        case 'action_type_consumable_slots_updated':
          manager.onConsumableSlotsUpdated(parsed);
          break;
        case 'items_updated':
          manager.onItemsUpdated(parsed);
          break;
        case 'abilities_updated':
          manager.onAbilitiesUpdated(parsed);
          break;
        case 'combat_triggers_updated':
          manager.onCombatTriggersUpdated(parsed);
          break;
        default:
          return;
      }
      domMonitor.refresh();
    } catch (err) {
      console.log('[Loadout Manager] error parsing websocket message', err);
      return;
    }
  }

  function addEntryButtonInEquipmentPanel(panel) {
    const $entry = $(PANEL_ENTRY_HTML);
    $entry.find('.lmEntry').on('click', showSettingModal);
    $(panel).find('[class=EquipmentPanel_title__CY-rf]').first().after($entry);
  }

  function addEntryButtonInAbilitiesPanel(panel) {
    const $entry = $(PANEL_ENTRY_HTML);
    $entry.find('.lmEntry').on('click', showSettingModal);
    $(panel).find('[class=AbilitiesPanel_title__2_8WC]').first().after($entry);
  }

  function updateEntryButtonInCombatSettings(container) {
    const $container = $(container);
    let $entry = $container.find('.lmCombatEntry');
    if ($entry.length === 0) {
      $entry = $(COMBAT_ENTRY_HTML);
      $entry.find('.lmEntry').on('click', showSettingModal);
      $container.append($entry);
    }
    const isCorrect = manager.compareSelectedAndCurrent();
    $entry.toggleClass('lmEntryCorrect', manager.selected !== null && isCorrect);
    $entry.toggleClass('lmEntryWrong', manager.selected !== null && !isCorrect);
  }

  function highlightEquipments(container) {
    const $equipments = $(container).children();
    $equipments.each(function () {
      const $equipment = $(this);
      const r = Number($equipment.css('grid-row-start'));
      const c = Number($equipment.css('grid-column-start'));
      const locations = Object.entries(EQUIPMENT_GRID_POSITION)
        .filter(([, pos]) => r === pos[0] && c === pos[1])
        .map(([location]) => location);
      for (const location of locations) {
        const item = manager.compareEquipment(location);
        $equipment.toggleClass('lmHighlight', item !== null);
        $equipment.data('lmItem', item);
        if (item !== null) {
          break;
        }
      }
    });
  }

  function highlightConsumables(container) {
    const $container = $(container);
    const $slots = $container.find('.ConsumableSlot_consumableSlotContainer__2DwgD');
    if ($slots.length !== 6) {
      return;
    }
    for (let i = 0; i < 3; i++) {
      const $slot = $slots.eq(i);
      const highlight = manager.compareSlotWithTriggers('food', i);
      $slot.toggleClass('lmHighlight', 'slot' in highlight);
      $slot.toggleClass('lmHighlightTriggers', 'triggers' in highlight);
      $slot.data('lmItem', highlight.slot);
      $slot.data('lmTriggers', highlight.triggers);
    }
    for (let i = 0; i < 3; i++) {
      const $slot = $slots.eq(i + 3);
      const highlight = manager.compareSlotWithTriggers('drinks', i);
      $slot.toggleClass('lmHighlight', 'slot' in highlight);
      $slot.toggleClass('lmHighlightTriggers', 'triggers' in highlight);
      $slot.data('lmItem', highlight.slot);
      $slot.data('lmTriggers', highlight.triggers);
    }
  }

  function highlightAbilities(container) {
    const $container = $(container);
    const $slots = $container.find('.AbilitySlot_abilitySlot__22oxh');
    if ($slots.length !== 5) {
      return;
    }
    for (let i = 0; i < 5; i++) {
      const $slot = $slots.eq(i);
      const highlight = manager.compareSlotWithTriggers('abilities', i);
      $slot.toggleClass('lmHighlight', 'slot' in highlight);
      $slot.toggleClass('lmHighlightTriggers', 'triggers' in highlight);
      $slot.data('lmAbility', highlight.slot);
      $slot.data('lmTriggers', highlight.triggers);
    }
  }

  function highlightCombatUnit(combatUnit) {
    const $combatUnit = $(combatUnit);
    const name = $combatUnit.find('.CombatUnit_name__1SlO1').text();
    if (name !== manager.playerName) {
      return;
    }
    $combatUnit.toggleClass('lmCombatUnitWrongLoadout', !manager.compareSelectedAndCurrent());
  }

  function updateItemTooltip($tooltip) {
    const tooltipId = $tooltip.attr('id');
    const item = $(`[aria-labelledby="${tooltipId}"]`).closest('.lmHighlight').data('lmItem');
    if (!item) {
      return;
    }
    if (item.itemHrid === '') {
      $tooltip.find('.ItemSelector_removeButton__3i8Lj').addClass('lmPick');
      return;
    }
    const itemName = item.itemHrid.slice(ITEM_HRID_PREFIX.length);
    const $items = $tooltip.find(`use[href$="#${itemName}"]`).closest('.ItemSelector_itemContainer__3olqe');
    let isMatched;
    if ('enhancementLevel' in item) {
      let $matched = null;
      let $best = null;
      let bestLevel = item.enhancementLevel;
      $items.each(function () {
        const $this = $(this);
        const level = Number($this.find('.Item_enhancementLevel__19g-e').text());
        if (level === item.enhancementLevel) {
          $matched = $this;
        }
        if (level > bestLevel) {
          $best = $this;
        }
      });
      $matched?.addClass('lmPick');
      $best?.addClass('lmPick');
      $best?.toggleClass('lmPickSecond', $matched !== null && $best !== null);
      isMatched = $matched !== null || $best !== null;
    } else {
      $items.first().addClass('lmPick');
      isMatched = $items.length > 0;
    }
    if (!isMatched) {
      $tooltip.find('.ItemSelector_menu__12sEM').before(NO_PICK_NOTE_HTML);
    }
  }

  function updateAbilitiesTooltip($tooltip) {
    const tooltipId = $tooltip.attr('id');
    const ability = $(`[aria-labelledby="${tooltipId}"]`).closest('.lmHighlight').data('lmAbility');
    if (!ability) {
      return;
    }
    if (ability.abilityHrid === '') {
      $tooltip.find('.AbilitySlot_removeButton__1GpmP').addClass('lmPick');
      return;
    }
    const abilityName = ability.abilityHrid.slice(ABILITY_HRID_PREFIX.length);
    const $svgUse = $tooltip.find(`use[href$="${abilityName}"]`);
    if ($svgUse.length > 0) {
      const $grid = $tooltip.find('.AbilitySlot_availableAbilities__s-5qp');
      $grid.children().addClass('lmTemp');
      $svgUse.closest('.lmTemp').addClass('lmPick');
      $grid.children().removeClass('lmTemp');
    } else {
      $tooltip.find('.AbilitySlot_abilitySelector__kwDWq').before(NO_PICK_NOTE_HTML);
    }
  }

  function updateTriggersTooltip($tooltip) {
    const tooltipId = $tooltip.attr('id');
    const triggers = $(`[aria-labelledby="${tooltipId}"]`).closest('.lmHighlightTriggers').data('lmTriggers');
    if (!triggers) {
      return;
    }
    const $button = $(SET_TRIGGERS_BUTTON_HTML);
    $tooltip.find('.Button_success__6d6kU').before($button);
    $button.on('click', async function () {
      $button.off('click');
      $button.text('Please wait');
      $button.addClass('Button_disabled__wCyIq');
      // Add/remove trigger inputs
      const REMOVE_SELECTOR = '.CombatTriggersSetting_removeButtonContainer__dpk_o .Button_warning__1-AMI';
      const oldCount = $tooltip.find(REMOVE_SELECTOR).length;
      for (let i = 0; i < oldCount - triggers.length; i++) {
        const $remove = $tooltip.find(REMOVE_SELECTOR).last();
        while ($remove.hasClass('Button_disabled__wCyIq')) {
          await sleep(100);
        }
        $remove.trigger('click');
      }
      for (let i = 0; i < triggers.length - oldCount; i++) {
        const $addCondition = $tooltip
          .find('.CombatTriggersSetting_buttonContainer__1NSkb .Button_button__1Fe9z')
          .first();
        while ($addCondition.hasClass('Button_disabled__wCyIq')) {
          await sleep(100);
        }
        $addCondition.trigger('click');
      }
      // Set each trigger
      const $groups = $tooltip.find('.CombatTriggersSetting_inputs__2lxPR');
      if ($groups.length !== triggers.length) {
        $button.text('Unexpected dropdown count');
        return;
      }
      for (let i = 0; i < triggers.length; i++) {
        const trigger = triggers[i];
        const $group = $groups.eq(i);
        changeReactSelect($group.find('.CombatTriggersSetting_dependencySelect__3foBB'), trigger.dependencyHrid);
        changeReactSelect($group.find('.CombatTriggersSetting_conditionSelect__njiG5'), trigger.conditionHrid);
        changeReactSelect($group.find('.CombatTriggersSetting_comparatorSelect__tFrQS'), trigger.comparatorHrid);
        changeReactInput($group.find('.CombatTriggersSetting_valueInput__3B34p'), trigger.value.toString());
      }

      $button.text('Done. Please click Save.');
    });
  }

  function main() {
    // debugInjectJq();
    GM_addStyle(CSS);
    interceptWebSocket(onWebSocketMessage);
    domMonitor.run();
  }

  main();
})();