Hordes UI Mod

Various UI mods for Hordes.io.

当前为 2019-12-27 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Hordes UI Mod
// @version      0.201
// @description  Various UI mods for Hordes.io.
// @author       Sakaiyo & Chandog#6373
// @match        https://hordes.io/play
// @grant        GM_addStyle
// @namespace https://greasyfork.org/users/160017
// ==/UserScript==
/**
  * Potential Future Changes:
  * TODO: Add whisper chat filter
  * TODO: Implement inventory sorting
  * TODO: Remove all reliance on svelte- classes, likely breaks with updates
  * TODO: Add cooldown on skills (leverage skill icon URL, have a map for each skill icon mapping to its respective cooldown)
  * TODO: Clicking names in party to add as friends
  * TODO: Rotate map canvas with your direction
  * TODO: Move[Drag] player/target health bars
  * TODO: Tooltip on buffs to show name+basic effect [cant get level probably]
  * TODO: (MAYBE, Confirm if dev is ok w it) Ctrl clicking item in inventory when Merchant is open to paste item name into search field, and search
  * TODO: (MAYBE, confirm if dev is ok w it) Ctrl clicking item to copy details so user can paste in chat
  * TODO: (MAYBE) Improved healer party frames
  * TODO: (MAYBE) Add toggleable option to include chat messages to right of party frame
  * TODO: (MAYBE) Fame/time meter
  * TODO: (MAYBE) EXP/time meter
  * TODO: (MAYBE) Heals per second meter
  * TODO: (MAYBE, Confirm if dev is ok w it) Alt+click to auto-equip item (emulate right click + click on Equip item in context menu)
  * TODO BUGFIX: If any windows are dragged outside of the screen view, they become inaccessible. We should pop it back into view automatically if it's dragged too far
  * TODO NOTE: For inventory sort / etc, can hover over tooltips with `element.dispatchEvent(new Event('pointerenter'))`
  */
(function() {
	'use strict';

	// If this version is different from the user's stored state,
	// e.g. they have upgraded the version of this script and there are breaking changes,
	// then their stored state will be deleted.
	const BREAKING_VERSION = 1;
	const VERSION = '0.201'; // Should match version in UserScript description

	const DEFAULT_CHAT_TAB_NAME = 'Untitled';
	const STORAGE_STATE_KEY = 'hordesio-uimodsakaiyo-state';

	let state = {
		breakingVersion: BREAKING_VERSION,
		chat: {
			GM: true,
		},
		windowsPos: {},
		blockList: {},
		friendsList: {},
		mapOpacity: 70, // e.g. 70 = opacity: 0.7
		friendNotes: {},
		chatTabs: [],
		xpMeterState: {
			currentXp: 0,
			xpGains: [], // array of xp deltas every second
			averageXp: 0,
			gainedXp: 0,
			currentLvl: 0,
		}
	};

	// tempState is saved only between page refreshes.
	const tempState = {
		// The last name clicked in chat
		chatName: null,
		lastMapWidth: 0,
		lastMapHeight: 0,
		xpMeterInterval: null, // tracks the interval for fetching xp data
	};

	// UPDATING STYLES BELOW - Must be invoked in main function
	GM_addStyle(`
		/* Transparent chat bg color */
		.frame.svelte-1vrlsr3 {
			background: rgba(0,0,0,0.4);
		}

		/* Our mod's chat message color */
		.textuimod {
			color: #00dd33;
		}

		/* Allows windows to be moved */
		.window {
			position: relative;
		}

		/* Allows last clicked window to appear above all other windows */
		.js-is-top {
			z-index: 9998 !important;
		}
		.panel.context:not(.commandlist) {
			z-index: 9999 !important;
		}
		/* The item icon being dragged in the inventory */
		.container.svelte-120o2pb {
			z-index: 9999 !important;
		}

		/* All purpose hidden class */
		.js-hidden {
			display: none;
		}

		/* Custom chat context menu, invisible by default */
		.js-chat-context-menu {
			display: none;
		}

		.js-chat-context-menu .name {
			color: white;
			padding: 2px 4px;
		}

		/* Allow names in chat to be clicked */
		#chat .name,
		.textwhisper .textf1 {
			pointer-events: all !important;
		}

		/* Custom chat filter colors */
		.js-chat-gm {
			color: #a6dcd5;
		}

		/* Class that hides chat lines */
		.js-line-hidden,
		.js-line-blocked {
			display: none;
		}

		/* Enable chat & map resize */
		.js-chat-resize {
			resize: both;
			overflow: auto;
		}
		.js-map-resize:hover {
			resize: both;
			overflow: auto;
			direction: rtl;
		}

		/* The browser resize icon */
		*::-webkit-resizer {
			background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
			border-radius: 8px;
			box-shadow: 0 1px 1px rgba(0,0,0,1);
		}
		*::-moz-resizer {
			background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
			border-radius: 8px;
			box-shadow: 0 1px 1px rgba(0,0,0,1);
		}

		.js-map-btns {
			position: absolute;
			top: 46px;
			right: 12px;
			z-index: 999;
			width: 100px;
			height: 100px;
			text-align: right;
			display: none;
			pointer-events: all;
		}
		.js-map-btns:hover {
			display: block;
		}
		.js-map-btns button {
			border-radius: 10px;
			font-size: 18px;
			padding: 0 5px;
			background: rgba(0,0,0,0.4);
			border: 0;
			color: white;
			font-weight: bold;
			pointer: cursor;
		}
		/* On hover of map, show opacity controls */
		.js-map:hover .js-map-btns {
			display: block;
		}

		/* Custom css for settings page, duplicates preexisting settings pane grid */
		.uimod-settings {
			display: grid;
			grid-template-columns: 2fr 3fr;
			grid-gap: 8px;
			align-items: center;
			max-height: 390px;
			margin: 0 20px;
			overflow-y: auto;
		}
		/* Friends list CSS, similar to settings but supports 4 columns */
		.uimod-friends {
			display: grid;
			grid-template-columns: 2fr 1.1fr 1.5fr 0.33fr 3fr;
			grid-gap: 8px;
			align-items: center;
			max-height: 390px;
			margin: 0 20px;
			overflow-y: auto;
		}
		/* Our custom window, closely mirrors main settings window */
		.uimod-custom-window {
			position: absolute;
			top: 100px;
			left: 50%;
			transform: translate(-50%, 0);
			min-width: 350px;
			max-width: 600px;
			width: 90%;
			height: 80%;
			min-height: 350px;
			max-height: 500px;
			z-index: 9;
			padding: 0px 10px 5px;
		}
		/* Custom chat tabs */
		.uimod-chat-tabs {
			position: fixed;
			margin-top: -22px;
			left: 5px;
			pointer-events: all;
			color: #5b858e;
			font-size: 12px;
			font-weight: bold;
		}
		.uimod-chat-tabs > div {
			cursor: pointer;
			background-color: rgba(0,0,0,0.4);
			border-top-right-radius: 4px;
			border-top-left-radius: 4px;
			display: inline-block;
			border: 1px black solid;
			border-bottom: 0;
			margin-right: 2px;
			padding: 3px 5px;
		}
		.uimod-chat-tabs > div:not(.js-selected-tab):hover {
			border-color: #aaa;
		}
		.uimod-chat-tabs > .js-selected-tab {
			color: #fff;
		}

		/* Chat tab custom config */
		.uimod-chat-tab-config {
			position: absolute;
			z-index: 9999999;
			background-color: rgba(0,0,0,0.6);
			color: white;
			border-radius: 3px;
			text-align: center;
			padding: 8px 12px 8px 6px;
			width: 175px;
			font-size: 14px;
			border: 1px solid black;
			display: none;
		}

		.uimod-chat-tab-config-grid {
			grid-template-columns: 35% 65%;
			display: grid;
			grid-gap: 6px;
			align-items: center;
		}

		.uimod-chat-tab-config h1 {
			font-size: 16px;
			margin-top: 0;
		}

		.uimod-chat-tab-config .btn,
		.uimod-chat-tab-config input {
			font-size: 12px;
		}

		.container.uimod-xpmeter-1 {
			z-index: 6;
		}

		.window.uimod-xpmeter-2 {
			padding: 5px;
			height: 100%;
			display: grid;
			grid-template-rows: 30px 1fr;
			grid-gap: 4px;
			transform-origin: inherit;
			min-width: fit-content;
		}

		.titleframe.uimod-xpmeter-2 {
			line-height: 1em;
			display: flex;
			align-items: center;
			position: relative;
			letter-spacing: 0.5px;
		}

		.titleicon.uimod-xpmeter-2 {
			margin: 3px;
		}

		.title.uimod-xpmeter-2 {
			width: 100%;
			padding-left: 4px;
			font-weight: bold;
		}

		.slot.uimod-xpmeter-2 {
			min-height: 0;
		}

		.wrapper.uimod-xpmeter-1 {
			width: 200px;
		}

		.bar.uimod-xpmeter-3 {
			background-color: rgba(45, 66, 71, 0.7);
			border-radius: 1.5px;
			position: relative;
			color: #DAE8EA;
			overflow: hidden;
			text-shadow: 1px 1px 2px #10131d;
			white-space: nowrap;
			text-transform: capitalize;
			font-weight: bold;
		}

		.buttons.uimod-xpmeter-1 {
			line-height: 1;
			font-size: 13px;
		}

		.left.uimod-xpmeter-3 {
			padding-left: 4px;
			position: relative;
			z-index: 1;
		}

		.right.uimod-xpmeter-3 {
			position: absolute;
			right: 7px;
			z-index: 1;
		}
	`);


	const modHelpers = {
		// Gets the node of a tooltip for any element
		// Must be `await`'d to use, e.g. `await getTooltipContent($element)`
		getTooltipContent: async ($elementToHoverOver) => {
			$elementToHoverOver.dispatchEvent(new Event('pointerenter'));
			const closeTooltipPromise = new Promise(resolve => setTimeout(() => {
				resolve($elementToHoverOver.querySelector('.slotdescription').cloneNode(true));
				$elementToHoverOver.dispatchEvent(new Event('pointerleave'));
			}, 0));
			const $tooltip = await closeTooltipPromise;
			return $tooltip;
		},

		// Automated chat command helpers
		// (We've been OK'd to do these by the dev - all automation like this should receive approval from the dev)
		whisperPlayer: playerName => {
			enterTextIntoChat(`/whisper ${playerName} `);
		},
		partyPlayer: playerName => {
			enterTextIntoChat(`/partyinvite ${playerName}`);
			submitChat();
		},

		// Filters all chat based on custom filters
		filterAllChat: () => {
			// Blocked user filter
			Object.keys(state.blockList).forEach(blockedName => {
				// Get the `.name` elements from the blocked user, if we haven't already hidden their messages
				const $blockedChatNames = Array.from(document.querySelectorAll(`[data-chat-name="${blockedName}"]:not(.js-line-blocked)`));
				// Hide each of their messages
				$blockedChatNames.forEach($name => {
					// Add the class name to $name so we can track whether it's been hidden in our CSS selector $blockedChatNames
					$name.classList.add('js-line-blocked');
					const $line = $name.parentNode.parentNode.parentNode;
					// Add the class name to $line so we can visibly hide the entire chat line
					$line.classList.add('js-line-blocked');
				});
			})

			// Custom channel filter
			Object.keys(state.chat).forEach(channel => {
				Array.from(document.querySelectorAll(`.text${channel}.content`)).forEach($textItem => {
					const $line = $textItem.parentNode.parentNode;
					$line.classList.toggle('js-line-hidden', !state.chat[channel]);
				});
			});
		},

		// Makes chat context menu visible and appear under the mouse
		showChatContextMenu: (name, mousePos) => {
			// Right before we show the context menu, we want to handle showing/hiding Friend/Unfriend
			const $contextMenu = document.querySelector('.js-chat-context-menu');
			$contextMenu.querySelector('[name="friend"]').classList.toggle('js-hidden', !!state.friendsList[name]);
			$contextMenu.querySelector('[name="unfriend"]').classList.toggle('js-hidden', !state.friendsList[name]);

			$contextMenu.querySelector('.js-name').textContent = name;
			$contextMenu.setAttribute('style', `display: block; left: ${mousePos.x}px; top: ${mousePos.y}px;`);
		},

		// Close chat context menu if clicking outside of it
		closeChatContextMenu: clickEvent => {
			const $target = clickEvent.target;
			// If clicking on name or directly on context menu, don't close it
			// Still closes if clicking on context menu item
			if ($target.classList.contains('js-is-context-menu-initd')
				|| $target.classList.contains('js-chat-context-menu')) {
				return;
			}

			const $contextMenu = document.querySelector('.js-chat-context-menu');
			$contextMenu.style.display = 'none';
		},

		friendPlayer: playerName => {
			if (state.friendsList[playerName]) {
				return;
			}

			state.friendsList[playerName] = true;
			modHelpers.addChatMessage(`${playerName} has been added to your friends list.`);
			save();
		},

		unfriendPlayer: playerName => {
			if (!state.friendsList[playerName]) {
				return;
			}

			delete state.friendsList[playerName];
			delete state.friendNotes[playerName];
			modHelpers.addChatMessage(`${playerName} is no longer on your friends list.`);
			save();
		},

		// Adds player to block list, to be filtered out of chat
		blockPlayer: playerName => {
			if (state.blockList[playerName]) {
				return;
			}

			state.blockList[playerName] = true;
			modHelpers.filterAllChat();
			modHelpers.addChatMessage(`${playerName} has been blocked.`)
			save();
		},

		// Removes player from block list and makes their messages visible again
		unblockPlayer: playerName => {
			delete state.blockList[playerName];
			modHelpers.addChatMessage(`${playerName} has been unblocked.`);
			save();

			// Make messages visible again
			const $chatNames = Array.from(document.querySelectorAll(`.js-line-blocked[data-chat-name="${playerName}"]`));
			$chatNames.forEach($name => {
				$name.classList.remove('js-line-blocked');
				const $line = $name.parentNode.parentNode.parentNode;
				$line.classList.remove('js-line-blocked');
			});
		},

		// Pushes message to chat
		// TODO: The margins for the message are off slightly compared to other messages - why?
		addChatMessage: text => {
			const newMessageHTML = `
			<div class="linewrap svelte-1vrlsr3">
				<span class="time svelte-1vrlsr3">00.00</span>
				<span class="textuimod content svelte-1vrlsr3">
				<span class="capitalize channel svelte-1vrlsr3">UIMod</span>
				</span>
				<span class="svelte-1vrlsr3">${text}</span>
			</div>
			`;

			const element = makeElement({
				element: 'article',
				class: 'line svelte-1vrlsr3',
				content: newMessageHTML
			});
			document.querySelector('#chat').appendChild(element);
		},

		// Gets current chat filters as represented in the UI
		// filter being true means it's invisible(filtered) in chat
		// filter being false means it's visible(unfiltered) in chat
		getCurrentChatFilters: () => {
			// Saved by the official game client
			const gameFilters = JSON.parse(localStorage.getItem('filteredChannels'));
			return {
				global: gameFilters.includes('global'),
				faction: gameFilters.includes('faction'),
				party: gameFilters.includes('party'),
				clan: gameFilters.includes('clan'),
				pvp: gameFilters.includes('pvp'),
				inv: gameFilters.includes('inv'),
				GM: !state.chat.GM, // state.chat.GM is whether or not GM chat is shown - we want whether or not GM chat should be hidden
			};
		},

		// Shows the chat tab config window for a specific tab, displayed in a specific position
		showChatTabConfigWindow: (tabId, pos) => {
			const $chatTabConfig = document.querySelector('.js-chat-tab-config');
			const chatTab = state.chatTabs.find(tab => tab.id === tabId);
			// Update position and name in chat tab config
			$chatTabConfig.style.left = `${pos.x}px`;
			$chatTabConfig.style.top = `${pos.y}px`;
			$chatTabConfig.querySelector('.js-chat-tab-name').value = chatTab.name;

			// Store tabId in state, to be used by the Remove/Add buttons in config window
			tempState.editedChatTabId = tabId;

			// Hide remove button if only one chat tab left - can't remove last one
			// Show it if more than one chat tab left
			const chatTabCount = Object.keys(state.chatTabs).length;
			const $removeChatTabBtn = $chatTabConfig.querySelector('.js-remove-chat-tab');
			$removeChatTabBtn.style.display = chatTabCount < 2 ? 'none' : 'block';

			// Show chat tab config
			$chatTabConfig.style.display = 'block';
		},

		// Adds chat tab to DOM, sets it as selected
		// If argument chatTab is provided, will use that name+id
		// If no argument is provided, will create new tab name/id and add it to state
		// isInittingTab is optional boolean, if `true`, will _not_ set added tab as selected. Used when initializing all chat tabs on load
		// Returns newly added tabId
		addChatTab: (chatTab, isInittingTab) => {
			let tabName = DEFAULT_CHAT_TAB_NAME;
			let tabId = uuid();
			if (chatTab) {
				tabName = chatTab.name;
				tabId = chatTab.id;
			} else {
				// If no chat tab was provided, create it in state
				state.chatTabs.push({
					name: tabName,
					id: tabId,
					filters: modHelpers.getCurrentChatFilters(),
				});
				save();
			}

			const $tabs = document.querySelector('.js-chat-tabs');
			const $tab = makeElement({
				element: 'div',
				content: tabName,
			});
			$tab.setAttribute('data-tab-id', tabId);

			// Add chat tab to DOM
			$tabs.appendChild($tab);

			// Wire chat tab up to open config on right click
			$tab.addEventListener('contextmenu', clickEvent => {
				const mousePos = { x: clickEvent.pageX, y: clickEvent.pageY };
				modHelpers.showChatTabConfigWindow(tabId, mousePos);
			});
			// And select chat tab on left click
			$tab.addEventListener('click', () => {
				modHelpers.selectChatTab(tabId);
			});

			if (!isInittingTab) {
				// Select the newly added chat tab
				modHelpers.selectChatTab(tabId);
			}

			// Returning tabId to all adding new tab to pass tab ID to `showChatTabConfigWindow`
			return tabId;
		},

		// Selects chat tab [on click], updating client chat filters and custom chat filters
		selectChatTab: tabId => {
			// Remove selected class from everything, then add selected class to clicked tab
			Array.from(document.querySelectorAll('[data-tab-id]')).forEach($tab => {
				$tab.classList.remove('js-selected-tab');
			});
			const $tab = document.querySelector(`[data-tab-id="${tabId}"]`);
			$tab.classList.add('js-selected-tab');

			const tabFilters = state.chatTabs.find(tab => tab.id === tabId).filters;
			// Simulating clicks on the filters to turn them on/off
			const $filterButtons = Array.from(document.querySelectorAll('.channelselect small'));
			Object.keys(tabFilters).forEach(filter => {
				const $filterButton = $filterButtons.find($btn => $btn.textContent === filter);
				const isCurrentlyFiltered = $filterButton.classList.contains('textgrey');

				// If is currently filtered but filter for this tab is turned off, click it to turn filter off
				if (isCurrentlyFiltered && !tabFilters[filter]) {
					$filterButton.click();
				}
				// If it is not currently filtered but filter for this tab is turned on, click it to turn filter on
				if (!isCurrentlyFiltered && tabFilters[filter]) {
					$filterButton.click();
				}
			});

			// Update state for our custom chat filters to match the tab's configuration, then filter chat for it
			const isGMChatVisible = !tabFilters.GM;
			modHelpers.setGMChatVisibility(isGMChatVisible);

			// Update the selected tab in state
			state.selectedChatTabId = tabId;
			save();
		},

		// Updates state.chat.GM and the DOM to make text white/grey depending on if gm chat is visible/filtered
		// Then filters chat and saves updated chat state
		setGMChatVisibility: isGMChatVisible => {
			const $chatGM = document.querySelector(`.js-chat-gm`);
			state.chat.GM = isGMChatVisible;
			$chatGM.classList.toggle('textgrey', !state.chat.GM);
			modHelpers.filterAllChat();
			save();
		},

		getCurrentCharacterLvl: () => Number(document.querySelector('#ufplayer .bgmana > .left').textContent.split('Lv. ')[1]),

		getCurrentXp: () => Number(document.querySelector('#expbar .progressBar > .left').textContent.split('/')[0].trim()),

		getNextLevelXp: () => Number(document.querySelector('#expbar .progressBar > .left').textContent.split('/')[1].trim()),

		// user invoked reset of xp meter stats
		resetXpMeterState: () => {
			state.xpMeterState.xpGains = []; // array of xp deltas every second
			state.xpMeterState.averageXp = 0;
			state.xpMeterState.gainedXp = 0;
			save();
			document.querySelector('.js-xp-time').textContent = '-:-:-';
		},

		// toggle the xp meter
		toggleXpMeterVisibility: () => {
			const xpMeterContainer = document.querySelector('.js-xpmeter');
			xpMeterContainer.style.display = xpMeterContainer.style.display === 'none' ? 'block' : 'none';
		},

	};

	// MAIN MODS BELOW
	const mods = [
		// Creates DOM elements for custom chat filters
		function newChatFilters() {
			const $channelselect = document.querySelector('.channelselect');
			if (!document.querySelector(`.js-chat-gm`)) {
				const $gm = makeElement({
					element: 'small',
					class: `btn border black js-chat-gm ${state.chat.GM ? '' : 'textgrey'}`,
					content: 'GM'
				});
				$channelselect.appendChild($gm);
			}
		},

		// Creates DOM elements and wires them up for custom chat tabs and chat tab config
		// Note: Should be done after creating new custom chat filters
		function customChatTabs() {
			// Create the chat tab configuration DOM
			const $chatTabConfigurator = makeElement({
				element: 'div',
				class: 'uimod-chat-tab-config js-chat-tab-config',
				content: `
					<h1>Chat Tab Config</h1>
					<div class="uimod-chat-tab-config-grid">
						<div>Name</div><input type="text" class="js-chat-tab-name" value="untitled"></input>
						<div class="btn orange js-remove-chat-tab">Remove</div><div class="btn blue js-save-chat-tab">Ok</div>
					</div>
				`,
			});
			document.body.append($chatTabConfigurator);

			// Wire it up
			document.querySelector('.js-remove-chat-tab').addEventListener('click', () => {
				// Remove the chat tab from state
				const editedChatTab = state.chatTabs.find(tab => tab.id === tempState.editedChatTabId);
				const editedChatTabIndex = state.chatTabs.indexOf(editedChatTab);
				state.chatTabs.splice(editedChatTabIndex, 1);

				// Remove the chat tab from DOM
				const $chatTab = document.querySelector(`[data-tab-id="${tempState.editedChatTabId}"]`);
				$chatTab.parentNode.removeChild($chatTab);

				// If we just removed the currently selected chat tab
				if (tempState.editedChatTabId === state.selectedChatTabId) {
					// Select the chat tab to the left of the removed one
					const nextChatTabIndex = editedChatTabIndex === 0 ? 0 : editedChatTabIndex - 1;
					modHelpers.selectChatTab(state.chatTabs[nextChatTabIndex].id);
				}

				// Close chat tab config
				document.querySelector('.js-chat-tab-config').style.display = 'none';
			});

			document.querySelector('.js-save-chat-tab').addEventListener('click', () => {
				// Set new chat tab name in DOM
				const $chatTab = document.querySelector(`[data-tab-id="${state.selectedChatTabId}"]`);
				const newName = document.querySelector('.js-chat-tab-name').value;
				$chatTab.textContent = newName;

				// Set new chat tab name in state
				// `selectedChatTab` is a reference on `state.chatTabs`, so updating it above still updates it in the state - we want to save that
				const selectedChatTab = state.chatTabs.find(tab => tab.id === state.selectedChatTabId);
				selectedChatTab.name = newName;
				save();

				// Close chat tab config
				document.querySelector('.js-chat-tab-config').style.display = 'none';
			});

			// Create the initial chat tabs HTML
			const $chat = document.querySelector('#chat');
			const $chatTabs = makeElement({
				element: 'div',
				class: 'uimod-chat-tabs js-chat-tabs',
				content: '<div class="js-chat-tab-add">+</div>',
			});

			// Add them to the DOM
			$chat.parentNode.insertBefore($chatTabs, $chat);

			// Add all our chat tabs from state
			state.chatTabs.forEach(chatTab => {
				const isInittingTab = true;
				modHelpers.addChatTab(chatTab, isInittingTab);
			});

			// Wire up the add chat tab button
			document.querySelector('.js-chat-tab-add').addEventListener('click', clickEvent => {
				const chatTabId = modHelpers.addChatTab();
				const mousePos = { x: clickEvent.pageX, y: clickEvent.pageY };
				modHelpers.showChatTabConfigWindow(chatTabId, mousePos);
			});

			// If initial chat tab doesn't exist, create it based off current filter settings
			if (!Object.keys(state.chatTabs).length) {
				const tabId = uuid();
				const chatTab = {
					name: 'Main',
					id: tabId,
					filters: modHelpers.getCurrentChatFilters()
				};
				state.chatTabs.push(chatTab);
				save();
				modHelpers.addChatTab(chatTab);
			}

			// Wire up click event handlers onto the filters to update the selected chat tab's filters in state
			document.querySelector('.channelselect').addEventListener('click', clickEvent => {
				const $elementMouseIsOver = document.elementFromPoint(clickEvent.clientX, clickEvent.clientY);

				// We only want to change the filters if the user manually clicks the filter button
				// If they clicked a chat tab and we programatically set filters, we don't want to update
				// the current tab's filter state
				if (!$elementMouseIsOver.classList.contains('btn')) {
					return;
				}
				const selectedChatTab = state.chatTabs.find(tab => tab.id === state.selectedChatTabId);
				selectedChatTab.filters = modHelpers.getCurrentChatFilters();
				save();
			});

			// Select the currently selected tab in state on mod initialization
			if (state.selectedChatTabId) {
				modHelpers.selectChatTab(state.selectedChatTabId);
			}
		},

		// Wire up new chat buttons to toggle in state+ui
		function newChatFilterButtons() {
			const $chatGM = document.querySelector(`.js-chat-gm`);
			$chatGM.addEventListener('click', () => {
				modHelpers.setGMChatVisibility(!state.chat.GM);
			});
		},

		// Filter out chat in UI based on chat buttons state
		function filterChatObserver() {
			const chatObserver = new MutationObserver(modHelpers.filterAllChat);
			chatObserver.observe(document.querySelector('#chat'), { attributes: true, childList: true });
		},

		// Drag all windows by their header
		function draggableUIWindows() {
			Array.from(document.querySelectorAll('.window:not(.js-can-move)')).forEach($window => {
				$window.classList.add('js-can-move');
				dragElement($window, $window.querySelector('.titleframe'));
			});
		},

		// Save dragged UI windows position to state
		function saveDraggedUIWindows() {
			Array.from(document.querySelectorAll('.window:not(.js-window-is-saving)')).forEach($window => {
				$window.classList.add('js-window-is-saving');
				const $draggableTarget = $window.querySelector('.titleframe');
				const windowName = $draggableTarget.querySelector('[name="title"]').textContent;
				$draggableTarget.addEventListener('mouseup', () => {
					state.windowsPos[windowName] = $window.getAttribute('style');
					save();
				});
			});
		},

		// Loads draggable UI windows position from state
		function loadDraggedUIWindowsPositions() {
			Array.from(document.querySelectorAll('.window:not(.js-has-loaded-pos)')).forEach($window => {
				$window.classList.add('js-has-loaded-pos');
				const windowName = $window.querySelector('[name="title"]').textContent;
				const pos = state.windowsPos[windowName];
				if (pos) {
					$window.setAttribute('style', pos);
				}
			});
		},

		// Makes chat resizable
		function resizableChat() {
			// Add the appropriate classes
			const $chatContainer = document.querySelector('#chat').parentNode;
			$chatContainer.classList.add('js-chat-resize');

			// Load initial chat and map size
			if (state.chatWidth && state.chatHeight) {
				$chatContainer.style.width = state.chatWidth;
				$chatContainer.style.height = state.chatHeight;
			}

			// Save chat size on resize
			const resizeObserverChat = new ResizeObserver(() => {
				const chatWidthStr = window.getComputedStyle($chatContainer, null).getPropertyValue('width');
				const chatHeightStr = window.getComputedStyle($chatContainer, null).getPropertyValue('height');
				state.chatWidth = chatWidthStr;
				state.chatHeight = chatHeightStr;
				save();
			});
			resizeObserverChat.observe($chatContainer);
		},

		// Makes map resizable
		function resizeableMap() {
			const $map = document.querySelector('.container canvas').parentNode;
			const $canvas = $map.querySelector('canvas');
			$map.classList.add('js-map-resize');

			// Track whether we're clicking (resizing) map or not
			// Used to detect if resize changes are manually done, or from minimizing/maximizing map (with [M])
			$map.addEventListener('mousedown', () => {
				tempState.clickingMap = true;
			});
			// Sometimes the mouseup event may be registered outside of the map - we account for this
			document.body.addEventListener('mouseup', () => {
				tempState.clickingMap = false;
			});

			const onMapResize = () => {
				// Get real values of map height/width, excluding padding/margin/etc
				const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
				const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
				const mapWidth = Number(mapWidthStr.slice(0, -2));
				const mapHeight = Number(mapHeightStr.slice(0, -2));

				// If height/width are 0 or unset, don't resize canvas
				if (!mapWidth || !mapHeight) {
					return;
				}

				if ($canvas.width !== mapWidth) {
					$canvas.width = mapWidth;
				}

				if ($canvas.height !== mapHeight) {
					$canvas.height = mapHeight;
				}

				// If we're clicking map, i.e. manually resizing, then save state
				// Don't save state when minimizing/maximizing map via [M]
				if (tempState.clickingMap) {
					state.mapWidth = mapWidthStr;
					state.mapHeight = mapHeightStr;
					save();
				} else {
					const isMaximized = mapWidth > tempState.lastMapWidth && mapHeight > tempState.lastMapHeight;
					if (!isMaximized) {
						$map.style.width = state.mapWidth;
						$map.style.height = state.mapHeight;
					}
				}

				// Store last map width/height in temp state, so we know if we've minimized or maximized
				tempState.lastMapWidth = mapWidth;
				tempState.lastMapHeight = mapHeight;
			};

			if (state.mapWidth && state.mapHeight) {
				$map.style.width = state.mapWidth;
				$map.style.height = state.mapHeight;
				onMapResize(); // Update canvas size on initial load of saved map size
			}

			// On resize of map, resize canvas to match
			const resizeObserverMap = new ResizeObserver(onMapResize);
			resizeObserverMap.observe($map);

			// We need to observe canvas resizes to tell when the user presses M to open the big map
			// At that point, we resize the map to match the canvas
			const triggerResize = () => {
				// Get real values of map height/width, excluding padding/margin/etc
				const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
				const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
				const mapWidth = Number(mapWidthStr.slice(0, -2));
				const mapHeight = Number(mapHeightStr.slice(0, -2));

				// If height/width are 0 or unset, we don't care about resizing yet
				if (!mapWidth || !mapHeight) {
					return;
				}

				if ($canvas.width !== mapWidth) {
					$map.style.width = `${$canvas.width}px`;
				}

				if ($canvas.height !== mapHeight) {
					$map.style.height = `${$canvas.height}px`;
				}
			};

			// We debounce the canvas resize, so it doesn't resize every single
			// pixel you move when resizing the DOM. If this were to happen,
			// resizing would constantly be interrupted. You'd have to resize a tiny bit,
			// lift left click, left click again to resize a tiny bit more, etc.
			// Resizing is smooth when we debounce this canvas.
			const debouncedTriggerResize = debounce(triggerResize, 50);
			const resizeObserverCanvas = new ResizeObserver(debouncedTriggerResize);
			resizeObserverCanvas.observe($canvas);
		},

		function mapControls() {
			const $map = document.querySelector('.container canvas');
			if (!$map.parentNode.classList.contains('js-map')) {
				$map.parentNode.classList.add('js-map');
			}
			const $mapContainer = document.querySelector('.js-map');

			// On load, update map opacity to match state
			// We modify the opacity of the canvas and the background color alpha of the parent container
			// We do this to allow our opacity buttons to be visible on hover with 100% opacity
			// (A surprisingly difficult enough task to require this implementation)
			const updateMapOpacity = () => {
				$map.style.opacity = String(state.mapOpacity / 100);
				const mapContainerBgColor = window.getComputedStyle($mapContainer, null).getPropertyValue('background-color');
				// Credit for this regexp + This opacity+rgba dual implementation: https://stackoverflow.com/questions/16065998/replacing-changing-alpha-in-rgba-javascript
				let opacity = state.mapOpacity / 100;
				// This is a slightly lazy browser workaround to fix a bug.
				// If the opacity is `1`, and it sets `rgba` to `1`, then the browser changes the
				// rgba to rgb, dropping the alpha. We could account for that and add the `alpha` back in
				// later, but setting the max opacity to very close to 1 makes sure the issue never crops up.
				// Fun fact: 0.99 retains the alpha, but setting this to 0.999 still causes the browser to drop the alpha. Rude.
				if (opacity === 1) {
					opacity = 0.99;
				}
				const newBgColor = mapContainerBgColor.replace(/[\d\.]+\)$/g, `${opacity})`);
				$mapContainer.style['background-color'] = newBgColor;

				// Update the button opacity
				const $addBtn = document.querySelector('.js-map-opacity-add');
				const $minusBtn = document.querySelector('.js-map-opacity-minus');
				// Hide plus button if the opacity is max
				if (state.mapOpacity === 100) {
					$addBtn.style.visibility = 'hidden';
				} else {
					$addBtn.style.visibility = 'visible';
				}
				// Hide minus button if the opacity is lowest
				if (state.mapOpacity === 0) {
					$minusBtn.style.visibility = 'hidden';
				} else {
					$minusBtn.style.visibility = 'visible';
				}
			};

			const $mapButtons = makeElement({
				element: 'div',
				class: 'js-map-btns',
				content: `
					<button class="js-map-opacity-add">+</button>
					<button class="js-map-opacity-minus">-</button>
					<button class="js-map-reset">r</button>
				`,
			});

			// Add it right before the map container div
			$map.parentNode.insertBefore($mapButtons, $map);

			const $addBtn = document.querySelector('.js-map-opacity-add');
			const $minusBtn = document.querySelector('.js-map-opacity-minus');
			const $resetBtn = document.querySelector('.js-map-reset');
			// Hide the buttons if map opacity is maxed/minimum
			if (state.mapOpacity === 100) {
				$addBtn.style.visibility = 'hidden';
			}
			if (state.mapOpacity === 0) {
				$minusBtn.style.visibility = 'hidden';
			}

			// Wire it up
			$addBtn.addEventListener('click', clickEvent => {
				// Update opacity
				state.mapOpacity += 10;
				save();
				updateMapOpacity();
			});

			$minusBtn.addEventListener('click', clickEvent => {
				// Update opacity
				state.mapOpacity -= 10;
				save();
				updateMapOpacity();
			});

			$resetBtn.addEventListener('click', clickEvent => {
				state.mapOpacity = 70;
				state.mapWidth = '174px';
				state.mapHeight = '174px';
				save();
				updateMapOpacity();
				$mapContainer.style.width = state.mapWidth;
				$mapContainer.style.height = state.mapHeight;
			});

			updateMapOpacity();
		},

		// The last clicked UI window displays above all other UI windows
		// This is useful when, for example, your inventory is near the market window,
		// and you want the window and the tooltips to display above the market window.
		function selectedWindowIsTop() {
			Array.from(document.querySelectorAll('.window:not(.js-is-top-initd)')).forEach($window => {
				$window.classList.add('js-is-top-initd');

				$window.addEventListener('mousedown', () => {
					// First, make the other is-top window not is-top
					const $otherWindowContainer = document.querySelector('.js-is-top');
					if ($otherWindowContainer) {
						$otherWindowContainer.classList.remove('js-is-top');
					}

					// Then, make our window's container (the z-index container) is-top
					$window.parentNode.classList.add('js-is-top');
				});
			});
		},

		// The F icon and the UI that appears when you click it
		function customFriendsList() {
			var friendsIconElement = makeElement({
				element: 'div',
				class: 'btn border black js-friends-list-icon',
				content: 'F',
			});
			// Add the icon to the right of Elixir icon
			const $elixirIcon = document.querySelector('#sysgem');
			$elixirIcon.parentNode.insertBefore(friendsIconElement, $elixirIcon.nextSibling);

			// Create the friends list UI
			document.querySelector('.js-friends-list-icon').addEventListener('click', () => {
				if (document.querySelector('.js-friends-list')) {
					// Don't open the friends list twice.
					return;
				}
				let friendsListHTML = '';
				Object.keys(state.friendsList).sort().forEach(friendName => {
					friendsListHTML += `
						<div data-player-name="${friendName}">${friendName}</div>
						<div class="btn blue js-whisper-player" data-player-name="${friendName}">Whisper</div>
						<div class="btn blue js-party-player" data-player-name="${friendName}">Party invite</div>
						<div class="btn orange js-unfriend-player" data-player-name="${friendName}">X</div>
						<input type="text" class="js-friend-note" data-player-name="${friendName}" value="${state.friendNotes[friendName] || ''}"></input>
					`;
				});

				const customFriendsWindowHTML = `
					<h3 class="textprimary">Friends list</h3>
					<div class="uimod-friends">${friendsListHTML}</div>
					<p></p>
					<div class="btn purp js-close-custom-friends-list">Close</div>
				`;

				const $customFriendsList = makeElement({
					element: 'div',
					class: 'menu panel-black js-friends-list uimod-custom-window',
					content: customFriendsWindowHTML,
				});
				document.body.appendChild($customFriendsList);

				// Wire up the buttons
				Array.from(document.querySelectorAll('.js-whisper-player')).forEach($button => {
					$button.addEventListener('click', clickEvent => {
						const name = clickEvent.target.getAttribute('data-player-name');
						modHelpers.whisperPlayer(name);
					});
				});
				Array.from(document.querySelectorAll('.js-party-player')).forEach($button => {
					$button.addEventListener('click', clickEvent => {
						const name = clickEvent.target.getAttribute('data-player-name');
						modHelpers.partyPlayer(name);
					});
				});
				Array.from(document.querySelectorAll('.js-unfriend-player')).forEach($button => {
					$button.addEventListener('click', clickEvent => {
						const name = clickEvent.target.getAttribute('data-player-name');
						modHelpers.unfriendPlayer(name);

						// Remove the blocked player from the list
						Array.from(document.querySelectorAll(`.js-friends-list [data-player-name="${name}"]`)).forEach($element => {
							$element.parentNode.removeChild($element);
						});
					});
				});
				Array.from(document.querySelectorAll('.js-friend-note')).forEach($element => {
					$element.addEventListener('change', clickEvent => {
						const name = clickEvent.target.getAttribute('data-player-name');
						state.friendNotes[name] = clickEvent.target.value;
					});
				})

				// The close button for our custom UI
				document.querySelector('.js-close-custom-friends-list').addEventListener('click', () => {
					const $friendsListWindow = document.querySelector('.js-friends-list');
					$friendsListWindow.parentNode.removeChild($friendsListWindow);
				});
			});
		},

		// Custom settings UI, currently just Blocked players
		function customSettings() {
			const $settings = document.querySelector('.divide:not(.js-settings-initd)');
			if (!$settings) {
				return;
			}

			$settings.classList.add('js-settings-initd');
			const $settingsChoiceList = $settings.querySelector('.choice').parentNode;
			$settingsChoiceList.appendChild(makeElement({
				element: 'div',
				class: 'choice js-blocked-players',
				content: 'Blocked players',
			}));

			// Upon click, we display our custom settings window UI
			document.querySelector('.js-blocked-players').addEventListener('click', () => {
				let blockedPlayersHTML = '';
				Object.keys(state.blockList).sort().forEach(blockedName => {
					blockedPlayersHTML += `
						<div data-player-name="${blockedName}">${blockedName}</div>
						<div class="btn orange js-unblock-player" data-player-name="${blockedName}">Unblock player</div>
					`;
				});

				const customSettingsHTML = `
					<h3 class="textprimary">Blocked players</h3>
					<div class="settings uimod-settings">${blockedPlayersHTML}</div>
					<p></p>
					<div class="btn purp js-close-custom-settings">Close</div>
				`;

				const $customSettings = makeElement({
					element: 'div',
					class: 'menu panel-black js-custom-settings uimod-custom-window',
					content: customSettingsHTML,
				});
				document.body.appendChild($customSettings);

				// Wire up all the unblock buttons
				Array.from(document.querySelectorAll('.js-unblock-player')).forEach($button => {
					$button.addEventListener('click', clickEvent => {
						const name = clickEvent.target.getAttribute('data-player-name');
						modHelpers.unblockPlayer(name);

						// Remove the blocked player from the list
						Array.from(document.querySelectorAll(`.js-custom-settings [data-player-name="${name}"]`)).forEach($element => {
							$element.parentNode.removeChild($element);
						});
					});
				});
				// And the close button for our custom UI
				document.querySelector('.js-close-custom-settings').addEventListener('click', () => {
					const $customSettingsWindow = document.querySelector('.js-custom-settings');
					$customSettingsWindow.parentNode.removeChild($customSettingsWindow);
				});
			});
		},

		// This creates the initial chat context menu DOM (which starts as hidden)
		function createChatContextMenu() {
			if (document.querySelector('.js-chat-context-menu')) {
				return;
			}

			let contextMenuHTML = `
				<div class="js-name">...</div>
				<div class="choice" name="party">Party invite</div>
				<div class="choice" name="whisper">Whisper</div>
				<div class="choice" name="friend">Friend</div>
				<div class="choice" name="unfriend">Unfriend</div>
				<div class="choice" name="block">Block</div>
			`
			document.body.appendChild(makeElement({
				element: 'div',
				class: 'panel context border grey js-chat-context-menu',
				content: contextMenuHTML,
			}));

			const $chatContextMenu = document.querySelector('.js-chat-context-menu');
			$chatContextMenu.querySelector('[name="party"]').addEventListener('click', () => {
				modHelpers.partyPlayer(tempState.chatName);
			});
			$chatContextMenu.querySelector('[name="whisper"]').addEventListener('click', () => {
				modHelpers.whisperPlayer(tempState.chatName);
			});
			$chatContextMenu.querySelector('[name="friend"]').addEventListener('click', () => {
				modHelpers.friendPlayer(tempState.chatName);
			});
			$chatContextMenu.querySelector('[name="unfriend"]').addEventListener('click', () => {
				modHelpers.unfriendPlayer(tempState.chatName);
			});
			$chatContextMenu.querySelector('[name="block"]').addEventListener('click', () => {
				modHelpers.blockPlayer(tempState.chatName);
			});
		},

		// This opens a context menu when you click a user's name in chat
		function chatContextMenu() {
			const addContextMenu = ($name, name) => {
				$name.classList.add('js-is-context-menu-initd');
				// Add name to element so we can target it in CSS when filtering chat for block list
				$name.setAttribute('data-chat-name', name);

				const showContextMenu = clickEvent => {
					// TODO: Is there a way to pass the name to showChatContextMenumethod, instead of storing in tempState?
					tempState.chatName = name;
					modHelpers.showChatContextMenu(name, { x: clickEvent.pageX, y: clickEvent.pageY });
				};
				$name.addEventListener('click', showContextMenu); // Left click
				$name.addEventListener('contextmenu', showContextMenu); // Right click works too
			};
			Array.from(document.querySelectorAll('#chat .name:not(.js-is-context-menu-initd)')).forEach(($name) => {
				addContextMenu($name, $name.textContent);
			});
			Array.from(document.querySelectorAll('.textwhisper .textf1:not(.js-is-context-menu-initd)')).forEach($whisperName => {
				// $whisperName's textContent is "to [name]" or "from [name]", so we cut off the first word
				let name = $whisperName.textContent.split(' ');
				name.shift(); // Remove the first word
				name = name.join(' ');
				addContextMenu($whisperName, name);
			});
		},

		// Adds XP Meter DOM icon and window, starts continuous interval to get current xp over time
		function xpMeter() {
			const $layoutContainer = document.querySelector('body > div.layout > div.container:nth-child(1)');
			const $dpsMeterToggleElement = document.querySelector('#systrophy');
			const $xpMeterToggleElement = makeElement({ element: 'div', class: 'js-sysxp js-xpmeter-icon btn border black', content: 'XP' });

			const xpMeterHTMLString = `
				<div class="l-corner-lr container uimod-xpmeter-1 js-xpmeter" style="display: none">
					<div class="window panel-black uimod-xpmeter-2">
						<div class="titleframe uimod-xpmeter-2">
							<img src="/assets/ui/icons/trophy.svg?v=3282286" class="titleicon svgicon uimod-xpmeter-2">
								<div class="textprimary title uimod-xpmeter-2">
									<div name="title">Experience / XP</div>
								</div>
								<img src="/assets/ui/icons/cross.svg?v=3282286" class="js-xpmeter-close-icon btn black svgicon">
						</div>
						<div class="slot uimod-xpmeter-2" style="">
							<div class="wrapper uimod-xpmeter-1">
								<div class="bar  uimod-xpmeter-3" style="z-index: 0;">
									<div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
										<span class="left uimod-xpmeter-3">XP per minute:</span>
										<span class="right uimod-xpmeter-3 js-xpm">-</span>
									</div>
									<div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
										<span class="left uimod-xpmeter-3">XP per hour:</span>
										<span class="right uimod-xpmeter-3 js-xph">-</span>
									</div>
									<div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
										<span class="left uimod-xpmeter-3">XP Gained:</span>
										<span class="right uimod-xpmeter-3 js-xpg">-</span>
									</div>
									<div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
										<span class="left uimod-xpmeter-3">XP Left:</span>
										<span class="right uimod-xpmeter-3 js-xpl">-</span>
									</div>
									<div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
										<span class="left uimod-xpmeter-3">Session Time: </span>
										<span class="right uimod-xpmeter-3 js-xp-s-time">-</span>
									</div>
									<div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
										<span class="left uimod-xpmeter-3">Time to lvl: </span>
										<span class="right uimod-xpmeter-3 js-xp-time">-</span>
									</div>
								</div>
							</div>
							<div class="grid buttons marg-top uimod-xpmeter-1 js-xpmeter-reset-button">
								<div class="btn grey">Reset</div>
							</div>
						</div>
					</div>
				</div>
			`;

			$dpsMeterToggleElement.parentNode.insertBefore($xpMeterToggleElement, $dpsMeterToggleElement.nextSibling);

			const $xpMeterElement = makeElement({ element: 'div', content: xpMeterHTMLString.trim() })
			$layoutContainer.appendChild($xpMeterElement.firstChild);

			// Wire up icon and xpmeter window
			document.querySelector('.js-sysxp').addEventListener('click', modHelpers.toggleXpMeterVisibility);
			document.querySelector('.js-xpmeter-close-icon').addEventListener('click', modHelpers.toggleXpMeterVisibility);
			document.querySelector('.js-xpmeter-reset-button').addEventListener('click', modHelpers.resetXpMeterState);

			state.xpMeterState.currentXp = modHelpers.getCurrentXp();
			state.xpMeterState.currentLvl = modHelpers.getCurrentCharacterLvl();
			save();

			if (tempState.xpMeterInterval) clearInterval(tempState.xpMeterInterval);

			// every second we run the operations for xp meter, update xps, calc delta, etc
			tempState.xpMeterInterval = setInterval(() => {
				if (!document.querySelector('#expbar')) {
					return;
				}

				const currentXp = modHelpers.getCurrentXp();
				const nextLvlXp = modHelpers.getNextLevelXp();
				const currentLvl = modHelpers.getCurrentCharacterLvl();

				state.xpMeterState.gainedXp += currentXp - state.xpMeterState.currentXp;
				state.xpMeterState.xpGains.push(currentXp - state.xpMeterState.currentXp); // array of xp deltas every second
				state.xpMeterState.currentXp = currentXp;
				state.xpMeterState.averageXp = state.xpMeterState.xpGains.reduce((a, b) => a + b) / state.xpMeterState.xpGains.length;
				save();

				if (document.querySelector('.js-xpmeter')) {
					document.querySelector('.js-xpm').textContent = parseInt((state.xpMeterState.averageXp * 60).toFixed(0)).toLocaleString();
					document.querySelector('.js-xph').textContent = parseInt((state.xpMeterState.averageXp * 60 * 60).toFixed(0)).toLocaleString();
					document.querySelector('.js-xpg').textContent = state.xpMeterState.gainedXp.toLocaleString();
					document.querySelector('.js-xpl').textContent = (nextLvlXp - currentXp).toLocaleString();
					document.querySelector('.js-xp-s-time').textContent = msToString(state.xpMeterState.xpGains.length * 1000)
					// need a positive integer for averageXp to calc time left
					if (state.xpMeterState.averageXp > 0) document.querySelector('.js-xp-time').textContent = msToString((nextLvlXp - currentXp) / state.xpMeterState.averageXp * 1000);
				}

				if (state.xpMeterState.currentLvl < currentLvl) {
					modHelpers.resetXpMeterState();
					state.xpMeterState.currentLvl = currentLvl;
					save();
				}
			}, 1000);
		}
	];

	// Add new DOM, load our stored state, wire it up, then continuously rerun specific methods whenever UI changes
	function initialize() {
		// If the Hordes.io tab isn't active for long enough, it reloads the entire page, clearing this mod
		// We check for that and reinitialize the mod if that happens
		const $layout = document.querySelector('.layout');
		if ($layout.classList.contains('uimod-initd')) {
			return;
		}

		modHelpers.addChatMessage(`Hordes UI Mod v${VERSION} by Sakaiyo has been initialized.`);

		$layout.classList.add('uimod-initd')
		load();
		mods.forEach(mod => mod());

		// Continuously re-run specific mods methods that need to be executed on UI change
		const rerunObserver = new MutationObserver(() => {
			// If new window appears, e.g. even if window is closed and reopened, we need to rewire it
			// Fun fact: Some windows always exist in the DOM, even when hidden, e.g. Inventory
			// 		     But some windows only exist in the DOM when open, e.g. Interaction
			const modsToRerun = [
				'saveDraggedUIWindows',
				'draggableUIWindows',
				'loadDraggedUIWindowsPositions',
				'selectedWindowIsTop',
				'customSettings',
			];
			modsToRerun.forEach(modName => {
				mods.find(mod => mod.name === modName)();
			});
		});
		rerunObserver.observe(document.querySelector('.layout > .container'), { attributes: false, childList: true, });

		// Rerun only on chat
		const chatRerunObserver = new MutationObserver(() => {
			mods.find(mod => mod.name === 'chatContextMenu')();
			modHelpers.filterAllChat();

		});
		chatRerunObserver.observe(document.querySelector('#chat'), { attributes: false, childList: true, });

		// Event listeners for document.body might be kept when the game reloads, so don't reinitialize them
		if (!document.body.classList.contains('js-uimod-initd')) {
			document.body.classList.add('js-uimod-initd');

			// Close chat context menu when clicking outside of it
			document.body.addEventListener('click', modHelpers.closeChatContextMenu);
		}
	}

	// Initialize mods once UI DOM has loaded
	const pageObserver = new MutationObserver(() => {
		const isUiLoaded = !!document.querySelector('.layout');
		if (isUiLoaded) {
			initialize();
		}
	});
	pageObserver.observe(document.body, { attributes: true, childList: true })

	// UTIL METHODS
	// Save to in-memory state and localStorage to retain on refresh
	function save(items) {
		localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
	}

	// Load localStorage state if it exists
	// NOTE: If user is trying to load unsupported version of stored state,
	//	   e.g. they just upgraded to breaking version, then we delete their stored state
	function load() {
		const storedStateJson = localStorage.getItem(STORAGE_STATE_KEY)
		if (storedStateJson) {
			const storedState = JSON.parse(storedStateJson);
			if (storedState.breakingVersion !== BREAKING_VERSION) {
				localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
				return;
			}
			state = {
				...state,
				...storedState,
			};
		}
	}

	// Nicer impl to create elements in one method call
	function makeElement(args) {
		const $node = document.createElement(args.element);
		if (args.class) { $node.className = args.class; }
		if (args.content) { $node.innerHTML = args.content; }
		if (args.src) { $node.src = args.src; }
		return $node;
	}

	function enterTextIntoChat(text) {
		// Open chat input
		const enterEvent = new KeyboardEvent('keydown', {
			bubbles: true, cancelable: true, keyCode: 13
		});
		document.body.dispatchEvent(enterEvent);

		// Place text into chat
		const $input = document.querySelector('#chatinput input');
		$input.value = text;

		// Get chat input to recognize slash commands and change the channel
		// by triggering the `input` event.
		// (Did some debugging to figure out the channel only changes when the
		//  svelte `input` event listener exists.)
		const inputEvent = new KeyboardEvent('input', {
			bubbles: true, cancelable: true
		})
		$input.dispatchEvent(inputEvent);
	}

	function submitChat() {
		const $input = document.querySelector('#chatinput input');
		const kbEvent = new KeyboardEvent('keydown', {
			bubbles: true, cancelable: true, keyCode: 13
		});
		$input.dispatchEvent(kbEvent);
	}

	// Credit: https://stackoverflow.com/a/14234618 (Has been slightly modified)
	// $draggedElement is the item that will be dragged.
	// $dragTrigger is the element that must be held down to drag $draggedElement
	function dragElement($draggedElement, $dragTrigger) {
		let offset = [0, 0];
		let isDown = false;
		$dragTrigger.addEventListener('mousedown', function(e) {
			isDown = true;
			offset = [
				$draggedElement.offsetLeft - e.clientX,
				$draggedElement.offsetTop - e.clientY
			];
		}, true);
		document.addEventListener('mouseup', function() {
			isDown = false;
		}, true);

		document.addEventListener('mousemove', function(e) {
			event.preventDefault();
			if (isDown) {
				$draggedElement.style.left = (e.clientX + offset[0]) + 'px';
				$draggedElement.style.top = (e.clientY + offset[1]) + 'px';
			}
		}, true);
	}

	// Credit: David Walsh
	function debounce(func, wait, immediate) {
		var timeout;
		return function() {
			var context = this, args = arguments;
			var later = function() {
				timeout = null;
				if (!immediate) func.apply(context, args);
			};
			var callNow = immediate && !timeout;
			clearTimeout(timeout);
			timeout = setTimeout(later, wait);
			if (callNow) func.apply(context, args);
		};
	}

	// Credit: https://gist.github.com/jcxplorer/823878
	// Generate random UUID string
	function uuid() {
		var uuid = "", i, random;
		for (i = 0; i < 32; i++) {
			random = Math.random() * 16 | 0;
			if (i == 8 || i == 12 || i == 16 || i == 20) {
				uuid += "-";
			}
			uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16);
		}
		return uuid;
	}

	// milliseconds to humand readable
	function msToString(ms) {
		const pad = value => (value < 10 ? `0${value}` : value);
		const hours = pad(Math.floor((ms / (1000 * 60 * 60)) % 60));
		const minutes = pad(Math.floor((ms / (1000 * 60)) % 60));
		const seconds = pad(Math.floor((ms / 1000) % 60));
		return `${hours}:${minutes}:${seconds}`;
	}

})();