DH2 Fixed

Improve Diamond Hunt 2

Versión del día 08/03/2017. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         DH2 Fixed
// @namespace    FileFace
// @description  Improve Diamond Hunt 2
// @version      0.61.1
// @author       Zorbing
// @grant        none
// @run-at       document-start
// @include      http://www.diamondhunt.co/game.php
// ==/UserScript==

(function ()
{
'use strict';

const settings = {
	hideCraftingRecipes: {
		name: 'Hide crafting recipes of finished items'
		, title: `Hides crafting recipes of:
			<ul>
				<li>furnace, oil storage and oven recipes if they aren't better than the current level</li>
				<li>machines if the user has the maximum amount of this type (counts bound and unbound items)</li>
				<li>non-stackable items which the user already owns (counts bound and unbound items)</li>
			</ul>`
		, defaultValue: true
	}
	, useNewChat: {
		name: 'Use the new chat'
		, title: `Enables using the completely new chat with pm tabs, clickable links, clickable usernames to send a pm, intelligent scrolling and suggesting commands while typing`
		, defaultValue: true
		, requiresReload: true
	}
};



/**
 * observer
 */

let observedKeys = new Map();
/**
 * Observes the given key for change
 * 
 * @param {string} key	The name of the variable
 * @param {Function} fn	The function which is called on change
 */
function observe(key, fn)
{
	if (key instanceof Array)
	{
		for (let k of key)
		{
			observe(k, fn);
		}
	}
	else
	{
		if (!observedKeys.has(key))
		{
			observedKeys.set(key, new Set());
		}
		observedKeys.get(key).add(fn);
	}
	return fn;
}
function unobserve(key, fn)
{
	if (key instanceof Array)
	{
		let ret = [];
		for (let k of key)
		{
			ret.push(unobserve(k, fn));
		}
		return ret;
	}
	if (!observedKeys.has(key))
	{
		return false;
	}
	return observedKeys.get(key).delete(fn);
}
function updateValue(key, newValue)
{
	if (window[key] === newValue)
	{
		return false;
	}

	const oldValue = window[key];
	window[key] = newValue;
	(observedKeys.get(key) || []).forEach(fn => fn(key, oldValue, newValue));
	return true;
}



/**
 * global constants
 */

const tierLevels = ['empty', 'sapphire', 'emerald', 'ruby', 'diamond'];
const tierNames = ['Standard', 'Sapphire', 'Emerald', 'Ruby', 'Diamond'];
const tierItemList = ['pickaxe', 'shovel', 'hammer', 'axe', 'rake', 'fishingRod'];
const furnaceLevels = ['stone', 'bronze', 'iron', 'silver', 'gold'];
const furnaceCapacity = [10, 30, 75, 150, 300];
const ovenLevels = ['bronze', 'iron', 'silver', 'gold'];
const maxOilStorageLevel = 4; // 7
const oilStorageSize = [10e3, 50e3, 100e3, 300e3];



/**
 * general functions
 */

let styleElement = null;
function addStyle(styleCode)
{
	if (styleElement === null)
	{
		styleElement = document.createElement('style');
		document.head.appendChild(styleElement);
	}
	styleElement.innerHTML += styleCode;
}
function getBoundKey(key)
{
	return 'bound' + key[0].toUpperCase() + key.substr(1);
}
function getTierKey(key, tierLevel)
{
	return tierLevels[tierLevel] + key[0].toUpperCase() + key.substr(1);
}
function formatNumber(num)
{
	return parseFloat(num).toLocaleString('en');
}
function formatNumbersInText(text)
{
	return text.replace(/\d(?:[\d',\.]*\d)?/g, (numStr) =>
	{
		return formatNumber(parseInt(numStr.replace(/\D/g, ''), 10));
	});
}
function now()
{
	return (new Date()).getTime();
}
function padLeft(num, padChar)
{
	return (num < 10 ? padChar : '') + num;
}
// use time format established in DHQoL (https://greasyfork.org/scripts/16041-dhqol)
function formatTimer(timer)
{
	timer = parseInt(timer, 10);
	const hours = Math.floor(timer / 3600);
	const minutes = Math.floor((timer % 3600) / 60);
	const seconds = timer % 60;
	return padLeft(hours, '0') + ':' + padLeft(minutes, '0') + ':' + padLeft(seconds, '0');
}
const timeSteps = [
	{
		threshold: 1
		, name: 'second'
		, short: 'sec'
		, padp: 0
	}
	, {
		threshold: 60
		, name: 'minute'
		, short: 'min'
		, padp: 0
	}
	, {
		threshold: 3600
		, name: 'hour'
		, short: 'h'
		, padp: 1
	}
	, {
		threshold: 86400
		, name: 'day'
		, short: 'd'
		, padp: 2
	}
];
function formatTime2NearestUnit(time, long = false)
{
	let step = timeSteps[0];
	for (let i = timeSteps.length-1; i > 0; i--)
	{
		if (time >= timeSteps[i].threshold)
		{
			step = timeSteps[i];
			break;
		}
	}
	const factor = Math.pow(10, step.padp);
	const num = Math.round(time / step.threshold * factor) / factor;
	const unit = long ? step.name + (num === 1 ? '' : 's') : step.short;
	return num + ' ' + unit;
}
function ensureTooltip(id, target)
{
	const tooltipId = 'tooltip-' + id;
	let tooltipEl = document.getElementById(tooltipId);
	if (!tooltipEl)
	{
		tooltipEl = document.createElement('div');
		tooltipEl.id = tooltipId;
		tooltipEl.style.display = 'none';
		document.getElementById('tooltip-list').appendChild(tooltipEl);
	}

	// ensure binded events to show the tooltip
	if (target.dataset.tooltipId == null)
	{
		target.dataset.tooltipId = tooltipId;
		// target.setAttribute('data-tooltip-id', tooltipId);
		window.$(target).bind({
			mousemove: window.changeTooltipPosition
			, mouseenter: window.showTooltip
			, mouseleave: window.hideTooltip
		});
	}
	return tooltipEl;
}



/**
 * persistent store
 */

const storePrefix = 'dh2-';
const store = {
	get: (key) =>
	{
		const value = localStorage.getItem(storePrefix + key);
		try
		{
			return JSON.parse(value);
		}
		catch (e) {}
		return value;
	}
	, has: (key) =>
	{
		return localStorage.hasOwnProperty(storePrefix + key);
	}
	, persist: (key, value) =>
	{
		localStorage.setItem(storePrefix + key, JSON.stringify(value));
	}
	, remove: (key) =>
	{
		localStorage.removeItem(storePrefix + key);
	}
};



/**
 * settings
 */

function getSettingName(key)
{
	return 'setting.' + key;
}
const observedSettings = new Map();
function observeSetting(key, fn)
{
	if (!observedSettings.has(key))
	{
		observedSettings.set(key, new Set());
	}
	observedSettings.get(key).add(fn);
}
function unobserveSetting(key, fn)
{
	if (!observedKeys.has(key))
	{
		return false;
	}
	return observedKeys.get(key).delete(fn);
}
function getSetting(key)
{
	if (!settings.hasOwnProperty(key))
	{
		return;
	}
	const name = getSettingName(key);
	return store.has(name) ? store.get(name) : settings[key].defaultValue;
}
function setSetting(key, newValue)
{
	if (!settings.hasOwnProperty(key))
	{
		return;
	}
	const oldValue = getSetting(key);
	store.persist(getSettingName(key), newValue);
	if (oldValue !== newValue && observedSettings.has(key))
	{
		observedSettings.get(key).forEach(fn => fn(key, oldValue, newValue));
	}
}
function initSettings()
{
	const settingsTableId = 'd2h-settings';
	const settingIdPrefix = 'dh2-setting-';
	addStyle(`
table.table-style1 tr:not([onclick])
{
	cursor: initial;
}
#tab-container-profile h2.section-title
{
	color: orange;
	line-height: 1.2rem;
	margin-top: 2rem;
}
#tab-container-profile h2.section-title > span.note
{
	font-size: 0.9rem;
}
#${settingsTableId} tr.reload td:first-child::after
{
	content: '*';
	font-weight: bold;
	margin-left: 3px;
}
	`);

	function insertAfter(newChild, oldChild)
	{
		const parent = oldChild.parentElement;
		if (oldChild.nextElementSibling == null)
		{
			parent.appendChild(newChild);
		}
		else
		{
			parent.insertBefore(newChild, oldChild.nextElementSibling);
		}
	}
	function getCheckImageSrc(value)
	{
		return 'images/icons/' + (value ? 'check' : 'x') + '.png';
	}

	const profileTable = document.getElementById('profile-toggleTable');

	const settingsHeader = document.createElement('h2');
	settingsHeader.className = 'section-title';
	settingsHeader.innerHTML = `Userscript "DH2 Fixed"<br>
		<span class="note">(* changes require reloading the tab)</span>`;

	insertAfter(settingsHeader, profileTable);

	const settingsTable = document.createElement('table');
	settingsTable.id = settingsTableId;
	settingsTable.className = 'table-style1';
	settingsTable.width = '40%';
	settingsTable.innerHTML = `
	<tr style="background-color:grey;">
		<th>Setting</th>
		<th>Enabled</th>
	</tr>
	`;
	for (let key in settings)
	{
		const setting = settings[key];
		const settingId = settingIdPrefix + key;

		const row = settingsTable.insertRow(-1);
		row.classList.add('setting');
		if (setting.requiresReload)
		{
			row.classList.add('reload');
		}
		row.setAttribute('onclick', '');
		row.innerHTML = `
		<td>${setting.name}</td>
		<td><img src="${getCheckImageSrc(getSetting(key))}" id="${settingId}" class="image-icon-20"></td>
		`;

		const tooltipEl = ensureTooltip(settingId, row);
		tooltipEl.innerHTML = setting.title;
		if (setting.requiresReload)
		{
			tooltipEl.innerHTML += `<span style="color: hsla(20, 100%, 50%, 1); font-size: .9rem; display: block; margin-top: 0.5rem;">You have to reload the browser tab to apply changed settings.</span>`;
		}

		row.addEventListener('click', () =>
		{
			const newValue = !getSetting(key);
			setSetting(key, newValue);
			document.getElementById(settingId).src = getCheckImageSrc(newValue);
		});
	}
	insertAfter(settingsTable, settingsHeader);
}



/**
 * hide crafting recipes of lower tiers or of maxed machines
 */

function setRecipeVisibility(key, visible)
{
	const recipeRow = document.getElementById('crafting-' + key);
	if (recipeRow)
	{
		recipeRow.style.display = (!getSetting('hideCraftingRecipes') || visible) ? '' : 'none';
	}
}
function hideLeveledRecipes(max, getKey, init)
{
	const keys2Observe = [];
	let maxLevel = 0;
	for (let i = max-1; i >= 0; i--)
	{
		const level = i+1;
		const key = getKey(i);
		const boundKey = getBoundKey(key);
		keys2Observe.push(key);
		keys2Observe.push(boundKey);
		if (window[key] > 0 || window[boundKey] > 0)
		{
			maxLevel = Math.max(maxLevel, level);
		}

		setRecipeVisibility(key, level > maxLevel);
	}

	if (init)
	{
		observe(keys2Observe, () => hideLeveledRecipes(max, getKey, false));
	}
}
function hideToolRecipe(key, init)
{
	const emptyKey = getTierKey(key, 0);
	const keys2Observe = [emptyKey];
	let hasTool = window[emptyKey] > 0;
	for (let i = 0; i < tierLevels.length; i++)
	{
		const boundKey = getBoundKey(getTierKey(key, i));
		hasTool = hasTool || window[boundKey] > 0;
		keys2Observe.push(boundKey);
	}

	setRecipeVisibility(emptyKey, !hasTool);

	if (init)
	{
		observe(keys2Observe, () => hideToolRecipe(key, false));
	}
}
function hideRecipe(key, max, init)
{
	const maxValue = typeof max === 'function' ? max() : max;
	const boundKey = getBoundKey(key);
	const unbound = parseInt(window[key], 10);
	const bound = parseInt(window[boundKey], 10);

	setRecipeVisibility(key, (bound + unbound) < maxValue);

	if (init)
	{
		observe([key, boundKey], () => hideRecipe(key, max, false));
	}
}
function hideCraftedRecipes()
{
	function processRecipes(init)
	{
		// furnace
		hideLeveledRecipes(
			furnaceLevels.length
			, i => furnaceLevels[i] + 'Furnace'
			, init
		);
		// oil storage
		hideLeveledRecipes(
			7
			, i => 'oilStorage' + (i+1)
			, init
		);
		// oven recipes
		hideLeveledRecipes(
			ovenLevels.length
			, i => ovenLevels[i] + 'Oven'
			, init
		);
		// tools
		hideToolRecipe('axe', init);
		hideToolRecipe('hammer', init);
		hideToolRecipe('shovel', init);
		hideToolRecipe('pickaxe', init);
		hideToolRecipe('fishingRod', init);
		// drills
		hideRecipe('drills', 10, init);
		// crushers
		hideRecipe('crushers', 10, init);
		// oil pipe
		hideRecipe('oilPipe', 1, init);
		// boats
		hideRecipe('rowBoat', 1, init);
		hideRecipe('canoe', 1, init);

		if (init)
		{
			observeSetting('hideCraftingRecipes', () => processRecipes(false));
		}
	}
	processRecipes(true);

	const _processCraftingTab = window.processCraftingTab;
	window.processCraftingTab = () =>
	{
		const reinit = !!window.refreshLoadCraftingTable;
		_processCraftingTab();

		if (reinit)
		{
			processRecipes(false);
		}
	};
}



/**
 * improve item boxes
 */

function hideNumberInItemBox(key, setVisibility)
{
	const itemBox = document.getElementById('item-box-' + key);
	const numberElement = itemBox.lastElementChild;
	if (setVisibility)
	{
		numberElement.style.visibility = 'hidden';
	}
	else
	{
		numberElement.style.display = 'none';
	}
}
function addSpan2ItemBox(key)
{
	hideNumberInItemBox(key);

	const itemBox = document.getElementById('item-box-' + key);
	const span = document.createElement('span');
	itemBox.appendChild(span);
	return span;
}
function setOilPerSecond(span, oil)
{
	span.innerHTML = `+ ${formatNumber(oil)} L/s <img src="images/oil.png" class="image-icon-20" style="margin-top: -2px;">`;
}
function improveItemBoxes()
{
	// show capacity of furnace
	for (let i = 0; i < furnaceLevels.length; i++)
	{
		const key = furnaceLevels[i] + 'Furnace';
		const capacitySpan = addSpan2ItemBox(getBoundKey(key));
		capacitySpan.className = 'capacity';
		capacitySpan.textContent = 'Capacity: ' + formatNumber(furnaceCapacity[i]);
	}

	// show oil cap of oil storage
	for (let i = 0; i < maxOilStorageLevel; i++)
	{
		const key = 'oilStorage' + (i+1);
		const capSpan = addSpan2ItemBox(getBoundKey(key));
		capSpan.className = 'oil-cap';
		capSpan.textContent = 'Oil cap: ' + formatNumber(oilStorageSize[i]);
	}

	// show oil per second
	const handheldOilSpan = addSpan2ItemBox('handheldOilPump');
	setOilPerSecond(handheldOilSpan, 1*window.miner);
	observe('miner', () => setOilPerSecond(handheldOilSpan, 1*window.miner));
	const oilPipeSpan = addSpan2ItemBox('boundOilPipe');
	setOilPerSecond(oilPipeSpan, 50);

	// show current tier
	hideNumberInItemBox('emptyAnvil', true);
	hideNumberInItemBox('farmer', true);
	hideNumberInItemBox('planter', true);
	hideNumberInItemBox('cooksBook', true);
	hideNumberInItemBox('cooksPage', true);
	for (let tierItem of tierItemList)
	{
		for (let i = 0; i < tierLevels.length; i++)
		{
			const key = getTierKey(tierItem, i);
			const toolKey = tierItem == 'rake' ? key : getBoundKey(key);
			const tierSpan = addSpan2ItemBox(toolKey);
			tierSpan.className = 'tier';
			tierSpan.textContent = tierNames[i];
		}
	}

	// show boat progress
	const boatKeys = ['rowBoat', 'canoe'];
	const boatTimerKeys = boatKeys.map(k => k + 'Timer');
	function checkBoat(span, timerKey, init)
	{
		const isInTransit = window[timerKey] > 0;
		const otherInTransit = boatTimerKeys.some(k => k != timerKey && window[k] > 0);
		span.textContent = isInTransit ? 'In transit' : 'Ready';
		span.style.visibility = otherInTransit ? 'hidden' : '';

		if (init)
		{
			observe(boatTimerKeys, () => checkBoat(span, timerKey, false));
		}
	}
	for (let i = 0; i < boatKeys.length; i++)
	{
		const span = addSpan2ItemBox(getBoundKey(boatKeys[i]));
		checkBoat(span, boatTimerKeys[i], true);
	}
}



/**
 * fix wood cutting
 */

function fixWoodcutting()
{
	addStyle(`
img.woodcutting-tree-img
{
	border: 1px solid transparent;
}
	`);
}



/**
 * fix chat
 */

function isMuted(user)
{
	// return window.mutedPeople.some((name) => user.indexOf(name) > -1);
	return window.mutedPeople.includes(user);
}
function handleScrolling(chatbox)
{
	if (window.isAutoScrolling)
	{
		setTimeout(() => chatbox.scrollTop = chatbox.scrollHeight);
	}
}
const chatHistoryKey = 'chatHistory';
const maxChatHistoryLength = 100;
const TYPE_RELOAD = -1;
const TYPE_NORMAL = 0;
const TYPE_PM_FROM = 1;
const TYPE_PM_TO = 2;
const TYPE_SERVER_MSG = 3;
/**
 * The chunk hiding starts with at least 10 chunks.
 * So there are at least
 *	(chunkHidingMinChunks-1) * msgChunkSize + 1 = 9 * 100 + 1 = 901
 * messages before the chunk hiding mechanism starts.
 */
const chunkHidingMinChunks = 10;
const msgChunkSize = 100;
const reloadedChatData = {
	timestamp: 0
	, username: ''
	, userlevel: 0
	, icon: 0
	, tag: 0
	, type: TYPE_RELOAD
	, msg: '[...]'
};
// load chat history
let chatHistory = store.get(chatHistoryKey) || [];
// find index of last message which is not a pm
const lastNotPM = chatHistory.slice(0).reverse().find((d) =>
{
	return !isPM(d);
});
// insert a placeholder for a reloaded chat
if (lastNotPM && lastNotPM.type != TYPE_RELOAD)
{
	reloadedChatData.timestamp = (new Date()).getTime();
	chatHistory.push(reloadedChatData);
}
// for chat messages which arrive before DOMContentLoaded and can not be displayed since the DOM isn't ready
let chatInitialized = false;
function processChatData(username, icon, tag, msg, isPM)
{
	let userlevel = 0;
	let type = tag == 5 ? TYPE_SERVER_MSG : TYPE_NORMAL;
	if (isPM == 1)
	{
		const match = msg.match(/^\s*\[(.+) ([A-Za-z0-9 ]+)\]: (.+?)\s*$/) || ['', '', username, msg];
		type = match[1] == 'Sent to' ? TYPE_PM_TO : TYPE_PM_FROM;
		username = match[2];
		msg = match[3];
	}
	else if (tag != 5)
	{
		const match = msg.match(/^\s*\((\d+)\): (.+?)\s*$/);
		if (match)
		{
			userlevel = match[1];
			msg = match[2];
		}
		else
		{
			userlevel = window.getGlobalLevel();
		}
	}
	const data = {
		timestamp: now()
		, username: username
		, userlevel: userlevel
		, icon: icon
		, tag: tag
		, type: type
		, msg: msg
	};
	return data;
}
function add2ChatHistory(data)
{
	chatHistory.push(data);
	chatHistory = chatHistory.slice(-maxChatHistoryLength);
	store.persist(chatHistoryKey, chatHistory);
}
const chatBoxId = 'div-chat';
const generalChatTabId = 'tab-chat-general';
const generalChatDivId = 'div-chat-area';
const pmChatTabPrefix = 'tab-chat-pm-';
const pmChatDivPrefix = 'div-chat-pm-';
const chatInputId = 'chat-input-text';
function getChatTab(username)
{
	const id = username == '' ? generalChatTabId : pmChatTabPrefix + username.replace(/ /g, '_');
	let tab = document.getElementById(id);
	if (!tab)
	{
		tab = document.createElement('div');
		tab.className = 'chat-tab';
		tab.id = id;
		tab.dataset.username = username;
		tab.dataset.new = 0;
		tab.textContent = username;
		// thanks /u/Spino-Prime for pointing out this was missing
		const closeSpan = document.createElement('span');
		closeSpan.className = 'close';
		tab.appendChild(closeSpan);

		const chatTabs = document.getElementById('chat-tabs');
		const filler = chatTabs.querySelector('.filler');
		if (filler)
		{
			chatTabs.insertBefore(tab, filler);
		}
		else
		{
			chatTabs.appendChild(tab);
		}
	}
	return tab;
}
function getChatDiv(username)
{
	const id = username == '' ? generalChatDivId : pmChatDivPrefix + username.replace(/ /g, '_');
	let div = document.getElementById(id);
	if (!div)
	{
		div = document.createElement('div');
		div.setAttribute('disabled', 'disabled');
		div.id = id;
		div.className = 'div-chat-area';

		const height = document.getElementById(generalChatDivId).style.height;
		div.style.height = height;

		const generalChat = document.getElementById(generalChatDivId);
		generalChat.parentNode.insertBefore(div, generalChat);
	}
	return div;
}
function changeChatTab(oldTab, newTab)
{
	oldTab.classList.remove('selected');
	newTab.classList.add('selected');
	newTab.dataset.new = 0;

	const oldChatDiv = getChatDiv(oldTab.dataset.username);
	oldChatDiv.classList.remove('selected');
	const newChatDiv = getChatDiv(newTab.dataset.username);
	newChatDiv.classList.add('selected');

	const toUsername = newTab.dataset.username;
	const newTextPlaceholder = toUsername == '' ? window.username + ':' : 'PM to ' + toUsername + ':';
	document.getElementById(chatInputId).placeholder = newTextPlaceholder;

	if (window.isAutoScrolling)
	{
		setTimeout(() => newChatDiv.scrollTop = newChatDiv.scrollHeight);
	}
}
function closeChatTab(username)
{
	// TODO: maybe delete pms stored for that user?
	const oldTab = document.querySelector('#chat-tabs .chat-tab.selected');
	const tab2Close = getChatTab(username);
	if (oldTab.dataset.username == username)
	{
		const generalTab = getChatTab('');
		changeChatTab(tab2Close, generalTab);
	}
	tab2Close.parentElement.removeChild(tab2Close);
}
const chatIcons = [
	null
	, { key: 'halloween2015',	title: 'Halloween 2015' }
	, { key: 'christmas2015',	title: 'Chirstmas 2015' }
	, { key: 'easter2016',		title: 'Holiday' }
	, { key: 'halloween2016',	title: 'Halloween 2016' }
	, { key: 'christmas2016',	title: 'Chirstmas 2016' }
	, { key: 'dh1Max',			title: 'Max Level in DH1' }
	, { key: 'hardcore',		title: 'Hardcore Account' }
	, { key: 'quest',			title: 'Questmaster' }
];
const chatTags = [
	null
	, { key: 'donor', name: '' }
	, { key: 'contributor', name: 'Contributor' }
	, { key: 'mod', name: 'Moderator' }
	, { key: 'dev', name: 'Dev' }
	, { key: 'yell', name: 'Server Message' }
];
function isPM(data)
{
	return data.type == TYPE_PM_TO || data.type == TYPE_PM_FROM;
}
const locale = 'en-US';
const localeOptions = {
	hour12: false
	, year: 'numeric'
	, month: 'long'
	, day: 'numeric'
	, hour: '2-digit'
	, minute: '2-digit'
	, second: '2-digit'
};
const msgChunkMap = new Map();
let chatboxFragments = new Map();
function createMessageSegment(data)
{
	const isThisPm = isPM(data);
	const msgUsername = data.type == TYPE_PM_TO ? window.username : data.username;
	const historyIndex = chatHistory.indexOf(data);
	let isSameUser = null;
	let isSameTime = null;
	for (let i = historyIndex-1; i >= 0 && (isSameUser === null || isSameTime === null); i--)
	{
		const dataBefore = chatHistory[i];
		if (isThisPm && isPM(dataBefore) ||
			!isThisPm && !isPM(dataBefore))
		{
			if (isSameUser === null)
			{
				const beforeUsername = dataBefore.type == TYPE_PM_TO ? window.username : dataBefore.username;
				isSameUser = beforeUsername === msgUsername;
			}
			if (dataBefore.type != TYPE_RELOAD)
			{
				isSameTime = Math.floor(data.timestamp / 1000/60) - Math.floor(dataBefore.timestamp / 1000/60) === 0;
			}
		}
	}

	const d = new Date(data.timestamp);
	const hour = (d.getHours() < 10 ? '0' : '') +  d.getHours();
	const minute = (d.getMinutes() < 10 ? '0' : '') +  d.getMinutes();
	const icon = chatIcons[data.icon] || { key: '', title: '' };
	const tag = chatTags[data.tag] || { key: '', name: '' };
	// thanks aguyd (https://greasyfork.org/forum/profile/aguyd) for the vulnerability warning
	const formattedMsg = data.msg.replace(/(https?:\/\/[^\s"<>]+)/g, '<a target="_blank" href="$1">$1</a>');

	const msgTitle = data.type == TYPE_RELOAD ? 'Chat loaded on ' + d.toLocaleString(locale, localeOptions) : '';
	const user = data.type === TYPE_SERVER_MSG ? 'Server Message' : msgUsername;
	const levelAppendix = data.type == TYPE_NORMAL ? ' (' + data.userlevel + ')' : '';
	const userTitle = data.tag != 5 ? tag.name : '';
	return `<span class="chat-msg" data-type="${data.type}" data-tag="${tag.key}">`
		+ `<span
			class="timestamp"
			data-timestamp="${data.timestamp}"
			data-same-time="${isSameTime}">${hour}:${minute}</span>`
		+ `<span class="user" data-name="${msgUsername}" data-same-user="${isSameUser}">`
			+ `<span class="icon ${icon.key}" title="${icon.title}"></span>`
			+ `<span class="name chat-tag-${tag.key}" title="${userTitle}">${user}${levelAppendix}:</span>`
		+ `</span>`
		+ `<span class="msg" title="${msgTitle}">${formattedMsg}</span>`
	+ `</span>`;
}
function add2Chat(data)
{
	if (!chatInitialized)
	{
		return;
	}

	const isThisPm = isPM(data);
	// don't mute pms (you can just ignore pm-tab if you like)
	if (!isThisPm && isMuted(data.username))
	{
		return;
	}

	const userKey = isThisPm ? data.username : '';
	const chatTab = getChatTab(userKey);
	if (!chatTab.classList.contains('selected'))
	{
		chatTab.dataset.new = parseInt(chatTab.dataset.new, 10) + 1;
	}
	if (isThisPm)
	{
		window.lastPMUser = data.username;
	}

	// username is 3-12 characters long
	const chatbox = getChatDiv(userKey);
	let msgChunk = msgChunkMap.get(userKey);
	if (!msgChunk || msgChunk.children.length >= msgChunkSize)
	{
		msgChunk = document.createElement('div');
		msgChunk.className = 'msg-chunk';
		msgChunkMap.set(userKey, msgChunk);

		if (chatboxFragments != null)
		{
			if (!chatboxFragments.has(userKey))
			{
				chatboxFragments.set(userKey, document.createDocumentFragment());
			}
			chatboxFragments.get(userKey).appendChild(msgChunk);
		}
		else
		{
			chatbox.appendChild(msgChunk);
		}
	}

	const tmp = document.createElement('templateWrapper');
	tmp.innerHTML = createMessageSegment(data);
	msgChunk.appendChild(tmp.children[0]);

	handleScrolling(chatbox);
}
function applyChatStyle()
{
	addStyle(`
span.chat-msg
{
	display: flex;
	margin-bottom: 1px;
}
span.chat-msg:nth-child(2n)
{
	background-color: hsla(0, 0%, 90%, 1);
}
.chat-msg[data-type="${TYPE_RELOAD}"]
{
	font-size: 0.8rem;
}
.chat-msg .timestamp
{
	display: none;
}
.chat-msg:not([data-type="${TYPE_RELOAD}"]) .timestamp
{
	color: hsla(0, 0%, 50%, 1);
	display: inline-block;
	font-size: .9rem;
	margin: 0;
	margin-right: 5px;
	position: relative;
	width: 2.5rem;
}
.chat-msg .timestamp[data-same-time="true"]
{
	color: hsla(0, 0%, 50%, .1);
}
.chat-msg:not([data-type="${TYPE_RELOAD}"]) .timestamp:hover::after
{
	background-color: hsla(0, 0%, 12%, 1);
	border-radius: .2rem;
	content: attr(data-fulltime);
	color: hsla(0, 0%, 100%, 1);
	line-height: 1.35rem;
	padding: .4rem .8rem;
	position: absolute;
	left: 2.5rem;
	top: -0.4rem;
	text-align: center;
	white-space: nowrap;
}

.chat-msg[data-type="${TYPE_PM_FROM}"] { color: purple; }
.chat-msg[data-type="${TYPE_PM_TO}"] { color: purple; }
.chat-msg[data-type="${TYPE_SERVER_MSG}"] { color: blue; }
.chat-msg[data-tag="contributor"] { color: green; }
.chat-msg[data-tag="mod"] { color: #669999; }
.chat-msg[data-tag="dev"] { color: #666600; }
.chat-msg:not([data-type="${TYPE_RELOAD}"]) .user
{
	flex: 0 0 132px;
	margin-right: 5px;
	white-space: nowrap;
}
#${generalChatDivId} .chat-msg:not([data-type="${TYPE_RELOAD}"]) .user
{
	flex-basis: 182px;
	padding-left: 22px;
}
.chat-msg .user[data-same-user="true"]:not([data-name=""])
{
	opacity: 0;
}

.chat-msg .user .icon
{
	margin-left: -22px;
}
.chat-msg .user .icon::before
{
	background-size: 20px 20px;
	content: '';
	display: inline-block;
	margin-right: 2px;
	width: 20px;
	height: 20px;
	vertical-align: middle;
}
.chat-msg .user .icon.halloween2015::before	{ background-image: url('images/chat-icons/1.png'); }
.chat-msg .user .icon.christmas2015::before	{ background-image: url('images/chat-icons/2.png'); }
.chat-msg .user .icon.easter2016::before	{ background-image: url('images/chat-icons/3.png'); }
.chat-msg .user .icon.halloween2016::before	{ background-image: url('images/chat-icons/4.png'); }
.chat-msg .user .icon.christmas2016::before	{ background-image: url('images/chat-icons/5.png'); }
.chat-msg .user .icon.dh1Max::before		{ background-image: url('images/chat-icons/6.png'); }
.chat-msg .user .icon.hardcore::before		{ background-image: url('images/chat-icons/7.png'); }
.chat-msg .user .icon.quest::before			{ background-image: url('images/chat-icons/8.png'); }

.chat-msg .user .name
{
	color: rgba(0, 0, 0, 0.7);
	cursor: pointer;
}
.chat-msg .user .name.chat-tag-donor::before
{
	background-image: url('images/chat-icons/donor.png');
	background-size: 20px 20px;
	content: '';
	display: inline-block;
	height: 20px;
	width: 20px;
	vertical-align: middle;
}
.chat-msg .user .name.chat-tag-yell
{
	cursor: default;
}
.chat-msg .user .name.chat-tag-contributor,
.chat-msg .user .name.chat-tag-mod,
.chat-msg .user .name.chat-tag-dev,
.chat-msg .user .name.chat-tag-yell
{
	color: white;
	display: inline-block;
	font-size: 10pt;
	margin-top: -1px;
	padding-bottom: 0;
	text-align: center;
	/* 2px border, 10 padding */
	width: calc(100% - 2*1px - 2*5px);
}

.chat-msg[data-type="${TYPE_RELOAD}"] .user > *,
.chat-msg[data-type="${TYPE_PM_FROM}"] .user > .icon,
.chat-msg[data-type="${TYPE_PM_TO}"] .user > .icon
{
	display: none;
}

.chat-msg .msg
{
	min-width: 0;
	overflow: hidden;
	word-wrap: break-word;
}

#div-chat .div-chat-area
{
	width: 100%;
	height: 130px;
	display: none;
}
#div-chat .div-chat-area.selected
{
	display: block;
}
#chat-tabs
{
	display: flex;
	margin: 10px -6px -6px;
	flex-wrap: wrap;
}
#chat-tabs .chat-tab
{
	background-color: gray;
	border-top: 1px solid black;
	border-right: 1px solid black;
	cursor: pointer;
	display: inline-block;
	font-weight: normal;
	padding: 0.3rem .6rem;
	position: relative;
}
#chat-tabs .chat-tab.selected
{
	background-color: transparent;
	border-top-color: transparent;
}
#chat-tabs .chat-tab.filler
{
	background-color: hsla(0, 0%, 90%, 1);
	border-right: 0;
	box-shadow: inset 5px 5px 5px -5px rgba(0, 0, 0, 0.5);
	color: transparent;
	cursor: default;
	flex-grow: 1;
}
#chat-tabs .chat-tab::after
{
	color: white;
	content: '(' attr(data-new) ')';
	font-size: .9rem;
	font-weight: bold;
	margin-left: .4rem;
}
#chat-tabs .chat-tab[data-new="0"]::after
{
	color: inherit;
	font-weight: normal;
}
#chat-tabs .chat-tab:not(.general).selected::after,
#chat-tabs .chat-tab:not(.general):hover::after
{
	visibility: hidden;
}
#chat-tabs .chat-tab:not(.general).selected .close::after,
#chat-tabs .chat-tab:not(.general):hover .close::after
{
	content: '\xd7';
	font-size: 1.5rem;
	position: absolute;
	top: 0;
	right: .6rem;
	bottom: 0;
}
	`);
}
function addIntelligentScrolling()
{
	// add checkbox instead of button for toggling auto scrolling
	const btn = document.querySelector('input[value="Toggle Autoscroll"]');
	const checkboxId = 'chat-toggle-autoscroll';
	// create checkbox
	const toggleCheckbox = document.createElement('input');
	toggleCheckbox.type = 'checkbox';
	toggleCheckbox.id = checkboxId;
	toggleCheckbox.checked = true;
	// create label
	const toggleLabel = document.createElement('label');
	toggleLabel.htmlFor = checkboxId;
	toggleLabel.textContent = 'Autoscroll';
	btn.parentNode.insertBefore(toggleCheckbox, btn);
	btn.parentNode.insertBefore(toggleLabel, btn);
	btn.style.display = 'none';

	// add checkbox for intelligent scrolling
	const isCheckboxId = 'chat-toggle-intelligent-scroll';
	const intScrollCheckbox = document.createElement('input');
	intScrollCheckbox.type = 'checkbox';
	intScrollCheckbox.id = isCheckboxId;
	intScrollCheckbox.checked = true;
	// add label
	const intScrollLabel = document.createElement('label');
	intScrollLabel.htmlFor = isCheckboxId;
	intScrollLabel.textContent = 'Intelligent Scrolling';
	btn.parentNode.appendChild(intScrollCheckbox);
	btn.parentNode.appendChild(intScrollLabel);

	const chatArea = document.getElementById(generalChatDivId);
	let showScrollTextTimeout = null;
	function setAutoScrolling(value, full)
	{
		if (window.isAutoScrolling != value)
		{
			toggleCheckbox.checked = value;
			window.isAutoScrolling = value;
			const color = value ? 'lime' : 'red';
			const text = (value ? 'En' : 'Dis') + 'abled' + (full ? ' Autoscroll' : '');
			const scrollArgs = ['none', color, text];
			if (full)
			{
				window.clearTimeout(showScrollTextTimeout);
				showScrollTextTimeout = window.setTimeout(() => window.scrollText(...scrollArgs), 300);
			}
			else
			{
				window.scrollText(...scrollArgs);
			}
			return true;
		}
		return false;
	}
	toggleCheckbox.addEventListener('change', function ()
	{
		setAutoScrolling(this.checked);
		if (this.checked && intScrollCheckbox.checked)
		{
			chatArea.scrollTop = chatArea.scrollHeight - chatArea.clientHeight;
		}
	});

	const placeholderTemplate = document.createElement('div');
	placeholderTemplate.className = 'placeholder';
	const childStore = new WeakMap();
	function scrollHugeChat()
	{
		// # of children
		const chunkNum = chatArea.children.length;
		// start chunk hiding at a specific amount of chunks
		if (chunkNum < chunkHidingMinChunks)
		{
			return;
		}

		const visibleTop = chatArea.scrollTop;
		const visibleBottom = visibleTop + chatArea.clientHeight;
		const referenceTop = visibleTop - window.innerHeight;
		const referenceBottom = visibleBottom + window.innerHeight;
		let top = 0;
		// never hide the last element since its size may change at any time when a new message gets appended
		for (let i = 0; i < chunkNum-1; i++)
		{
			const child = chatArea.children[i];
			const height = child.clientHeight;
			const bottom = top + height;
			const isVisible = top >= referenceTop && top <= referenceBottom
				|| bottom >= referenceTop && bottom <= referenceBottom
				|| top < referenceTop && bottom > referenceBottom
			;
			const isPlaceholder = child.classList.contains('placeholder');
			if (!isVisible && !isPlaceholder)
			{
				const newPlaceholder = placeholderTemplate.cloneNode(false);
				newPlaceholder.style.height = height + 'px';
				chatArea.replaceChild(newPlaceholder, child);
				childStore.set(newPlaceholder, child);
			}
			else if (isVisible && isPlaceholder)
			{
				const oldChild = childStore.get(child);
				chatArea.replaceChild(oldChild, child);
				childStore.delete(child);
			}
			top = bottom;
		}
	}
	let timeouts = {};
	const timeoutDelay = 50;
	const maxDelay = 300;
	function startCancelableTimeout(key, handler)
	{
		let obj = timeouts[key] || {};
		const n = now();
		if (obj.start == null)
		{
			obj.start = n;
		}
		if (obj.start + maxDelay > n)
		{
			window.clearTimeout(obj.ref);
			obj.ref = window.setTimeout(() =>
			{
				obj.start = null;
				obj.ref = null;
				handler();
			}, timeoutDelay);
		}
		timeouts[key] = obj;
	}
	// does not consider pm tabs; may be changed in a future version?
	chatArea.addEventListener('scroll', () =>
	{
		if (intScrollCheckbox.checked)
		{
			const scrolled2Bottom = (chatArea.scrollTop + chatArea.clientHeight) >= chatArea.scrollHeight;
			setAutoScrolling(scrolled2Bottom, true);
		}

		startCancelableTimeout('scrollHugeChat', () => scrollHugeChat());
	});
}
function clickChatTab(newTab)
{
	const oldTab = document.querySelector('#chat-tabs .chat-tab.selected');
	if (newTab == oldTab)
	{
		return;
	}

	changeChatTab(oldTab, newTab);
}
function clickCloseChatTab(tab)
{
	const username = tab.dataset.username;
	const chatDiv = getChatDiv(username);
	if (chatDiv.children.length === 0 ||
		confirm(`Do you want to close the pm tab of "${username}"?`))
	{
		closeChatTab(username);
	}
}
function addChatTabs()
{
	const chatBoxArea = document.getElementById(chatBoxId);
	const chatTabs = document.createElement('div');
	chatTabs.id = 'chat-tabs';
	chatTabs.addEventListener('click', (event) =>
	{
		const newTab = event.target;
		if (newTab.classList.contains('close'))
		{
			return clickCloseChatTab(newTab.parentElement);
		}
		if (!newTab.classList.contains('chat-tab') || newTab.classList.contains('filler'))
		{
			return;
		}

		clickChatTab(newTab);
	});
	chatBoxArea.appendChild(chatTabs);

	const generalTab = getChatTab('');
	generalTab.classList.add('general');
	generalTab.classList.add('selected');
	generalTab.textContent = 'Server';
	const generalChatDiv = getChatDiv('');
	generalChatDiv.classList.add('selected');
	// works only if username length of 1 isn't allowed
	const fillerTab = getChatTab('f');
	fillerTab.classList.add('filler');
	fillerTab.textContent = '';

	const _sendChat = window.sendChat;
	window.sendChat = (inputEl) =>
	{
		let msg = inputEl.value;
		const selectedTab = document.querySelector('.chat-tab.selected');
		if (selectedTab.dataset.username != '' && msg[0] != '/')
		{
			inputEl.value = '/pm ' + selectedTab.dataset.username + ' ' + msg;
		}
		_sendChat(inputEl);
	};
}
function newAddToChatBox(username, icon, tag, msg, isPM)
{
	const data = processChatData(username, icon, tag, msg, isPM);
	add2ChatHistory(data);
	if (getSetting('useNewChat'))
	{
		add2Chat(data);
	}
	else
	{
		window.addToChatBox(username, icon, tag, msg, isPM);
	}
}
function newChat()
{
	addChatTabs();
	applyChatStyle();

	window.addToChatBox = newAddToChatBox;
	chatInitialized = true;

	const chatbox = document.getElementById(chatBoxId);
	chatbox.addEventListener('click', (event) =>
	{
		let target = event.target;
		while (target && target.id != chatBoxId && !target.classList.contains('user'))
		{
			target = target.parentElement;
		}
		if (!target || target.id == chatBoxId)
		{
			return;
		}

		const username = target.dataset.name;
		if (username == window.username || username == '')
		{
			return;
		}

		const userTab = getChatTab(username);
		clickChatTab(userTab);
		document.getElementById(chatInputId).focus();
	});
	chatbox.addEventListener('mouseover', (event) =>
	{
		const target = event.target;
		if (!target.classList.contains('timestamp') || !target.dataset.timestamp)
		{
			return;
		}

		const timestamp = parseInt(target.dataset.timestamp, 10);
		target.dataset.fulltime = (new Date(timestamp)).toLocaleDateString(locale, localeOptions);
		target.dataset.timestamp = '';
	});
}
const commands = ['pm', 'mute', 'ipmute'];
function addCommandSuggester()
{
	const input = document.getElementById(chatInputId);
	input.addEventListener('keyup', (event) =>
	{
		if (event.key != 'Backspace' && event.key != 'Delete' &&
			input.selectionStart == input.selectionEnd &&
			input.selectionStart == input.value.length &&
			input.value.startsWith('/'))
		{
			const value = input.value.substr(1);
			const suggestions = commands.filter(c => c.startsWith(value));
			if (suggestions.length == 1)
			{
				input.value = '/' + suggestions[0];
				input.selectionStart = 1 + value.length;
				input.selectionEnd = input.value.length;
			}
		}
	});
}
const tutorialCmd = 'tutorial';
function addOwnCommands()
{
	commands.push(tutorialCmd);

	const _doChatCommand = window.doChatCommand;
	window.doChatCommand = (value) =>
	{
		// thanks aguyd (https://greasyfork.org/forum/profile/aguyd) for the idea
		if (value.startsWith('/'))
		{
			const rest = value.substr(1);
			if (rest.startsWith(tutorialCmd))
			{
				const name = rest.substr(tutorialCmd.length).trim();
				let msg = 'https://www.reddit.com/r/DiamondHunt/comments/5vrufh/diamond_hunt_2_starter_faq/';
				if (name.length != 0)
				{
					// maybe add '@' before the name?
					msg = name + ', ' + msg;
				}
				return _doChatCommand(msg);
			}
		}
		return _doChatCommand(value);
	};
}
function initChat()
{
	if (!getSetting('useNewChat'))
	{
		return;
	}

	newChat();
	addIntelligentScrolling();
	addCommandSuggester();
	addOwnCommands();

	const _enlargeChat = window.enlargeChat;
	const chatBoxArea = document.getElementById(chatBoxId);
	function setChatBoxHeight(height)
	{
		document.getElementById(generalChatDivId).style.height = height;
		const chatDivs = chatBoxArea.querySelectorAll('div[id^="' + pmChatDivPrefix + '"]');
		for (let i = 0; i < chatDivs.length; i++)
		{
			chatDivs[i].style.height = height;
		}
	}
	window.enlargeChat = (enlargeB) =>
	{
		_enlargeChat(enlargeB);

		const height = document.getElementById(generalChatDivId).style.height;
		store.persist('chat.height', height);
		setChatBoxHeight(height);
	};
	setChatBoxHeight(store.get('chat.height'));

	// TEMP >>> (due to a naming issue, migrate the data)
	const oldChatHistoryKey = 'chatHistory2';
	const oldChatHistory = store.get(oldChatHistoryKey);
	if (oldChatHistory != null)
	{
		store.persist(chatHistoryKey, oldChatHistory);
		store.remove(oldChatHistoryKey);
	}
	// TEMP <<<

	// add history to chat
	chatHistory.forEach(d => add2Chat(d));
	chatboxFragments.forEach((fragment, key) =>
	{
		const chatbox = getChatDiv(key);
		chatbox.appendChild(fragment);
	});
	chatboxFragments = null;
	// reset the new counter for all tabs
	const tabs = document.querySelectorAll('.chat-tab');
	for (let i = 0; i < tabs.length; i++)
	{
		tabs[i].dataset.new = 0;
	}
}



/**
 * hopefully only temporary fixes
 */

function temporaryFixes()
{
	// fix grow time of some seeds
	const seeds = {
		'limeLeafSeeds': {
			replace: '1 hour'
			, replaceWith: '1 hour and 30 minutes'
		}
	};
	for (let seedName in seeds)
	{
		const tooltip = document.getElementById('tooltip-' + seedName);
		const timeNode = tooltip.lastElementChild.lastChild;
		const seed = seeds[seedName];
		timeNode.textContent = timeNode.textContent.replace(seed.replace, seed.replaceWith);
	}

	// fix exhaustion timer and updating brewing and cooking recipes
	const _clientGameLoop = window.clientGameLoop;
	window.clientGameLoop = () =>
	{
		_clientGameLoop();
		setHeroClickable();
		if (window.isInCombat() && combatCommenceTimer != 0)
		{
			document.getElementById('combat-countdown').style.display = '';
		}
		if (document.getElementById('tab-container-combat').style.display != 'none')
		{
			window.combatNotFightingTick();
		}
		if (currentOpenTab == 'brewing')
		{
			window.processBrewingTab();
		}
		if (currentOpenTab == 'cooksBook')
		{
			window.processCooksBookTab();
		}
	};

	// fix elements of scrollText (e.g. when joining the game and receiving xp at that moment)
	const textEls = document.querySelectorAll('div.scroller');
	for (let i = 0; i < textEls.length; i++)
	{
		const scroller = textEls[i];
		if (scroller.style.position != 'absolute')
		{
			scroller.style.display = 'none';
		}
	}

	// fix style of tooltips
	addStyle(`
body > div.tooltip > h2:first-child
{
	margin-top: 0;
	font-size: 20pt;
	font-weight: normal;
}
	`);

	// fix buiulding magic table dynamically
	window.refreshLoadMagicTable = true;
	const _processMagicTab = window.processMagicTab;
	window.processMagicTab = () =>
	{
		const _refreshLoadCraftingTable = window.refreshLoadCraftingTable;
		window.refreshLoadCraftingTable = window.refreshLoadMagicTable;
		_processMagicTab();
		window.refreshLoadCraftingTable = _refreshLoadCraftingTable;
	};

	// update hero being clickable in combat
	function setHeroClickable()
	{
		const heroArea = document.getElementById('hero-area');
		const equipment = heroArea.lastElementChild;
		equipment.style.pointerEvents = window.isInCombat() ? 'none' : '';
	}

	// fix crafting level of giant drills
	const _processCraftingTab = window.processCraftingTab;
	window.processCraftingTab = () =>
	{
		const reinit = !!window.refreshLoadCraftingTable;
		_processCraftingTab();

		if (reinit)
		{
			craftingRecipes.giantDrills.levelReq = 35;
			document.getElementById('recipe-level-req-giantDrills').textContent = '35';
		}
	};
}



/**
 * improve timer
 */

function improveTimer()
{
	window.formatTime = (seconds) =>
	{
		return formatTimer(seconds);
	};
	window.formatTimeShort2 = (seconds) =>
	{
		return formatTimer(seconds);
	};

	addStyle(`
#notif-smelting > span:not(.timer)
{
	display: none;
}
	`);
	const smeltingNotifBox = document.getElementById('notif-smelting');
	const smeltingTimerEl = document.createElement('span');
	smeltingTimerEl.className = 'timer';
	smeltingNotifBox.appendChild(smeltingTimerEl);
	function updateSmeltingTimer()
	{
		const totalTime = parseInt(window.smeltingPercD, 10);
		const elapsedTime = parseInt(window.smeltingPercN, 10);
		smeltingTimerEl.textContent = formatTimer(Math.max(totalTime - elapsedTime, 0));
	}
	observe('smeltingPercD', () => updateSmeltingTimer());
	observe('smeltingPercN', () => updateSmeltingTimer());
	updateSmeltingTimer();

	// add tree grow timer
	addStyle(`
/* hide timer elements of DH2QoL, because I can :P */
.woodcutting-tree > span,
.woodcutting-tree > br
{
	display: none;
}
.woodcutting-tree > div.timer
{
	color: white;
	margin-top: 5px;
	pointer-events: none;
	position: absolute;
	top: 0;
	left: 0;
	right: 0;
}
	`);
	const treeInfo = {
		1: {
			name: 'Normal tree'
			// 3h = 10800s
			, growTime: 3 * 60 * 60
		}
		, 2: {
			name: 'Oak tree'
			// 6h = 21600s
			, growTime: 6 * 60 * 60
		}
		, 3: {
			name: 'Willow tree'
			 // 8h = 28800s
			, growTime: 8 * 60 * 60
		}
		, 4: {
			name: 'Maple tree'
			// 12h = 43200s
			, growTime: 12 * 60 * 60
		}
	};
	function updateTreeInfo(place, infoElId, init)
	{
		const infoEl = document.getElementById(infoElId);
		const nameEl = infoEl.firstElementChild;
		const timerEl = infoEl.lastElementChild;
		const idKey = 'treeId' + place;
		const growTimerKey = 'treeGrowTimer' + place;
		const lockedKey = 'treeUnlocked' + place;

		const info = treeInfo[window[idKey]];
		if (!info)
		{
			const isLocked = place > 4 && window[lockedKey] == 0;
			nameEl.textContent = isLocked ? 'Locked' : 'Empty';
			timerEl.textContent = '';
		}
		else
		{
			nameEl.textContent = info.name;
			const remainingTime = info.growTime - parseInt(window[growTimerKey], 10);
			timerEl.textContent = remainingTime > 0 ? '(' + formatTimer(remainingTime) + ')' : 'Fully grown';
		}

		if (init)
		{
			observe(
				[idKey, growTimerKey, lockedKey]
				, () => updateTreeInfo(place, infoElId, false)
			);
		}
	}
	for (let i = 0; i < 6; i++)
	{
		const treePlace = i+1;
		const infoElId = 'wc-tree-timer-' + treePlace;
		const treeContainer = document.getElementById('wc-div-tree-' + treePlace);
		treeContainer.style.position = 'relative';
		const infoEl = document.createElement('div');
		infoEl.className = 'timer';
		infoEl.id = infoElId;
		const treeName = document.createElement('div');
		treeName.style.fontSize = '1.2rem';
		infoEl.appendChild(treeName);
		const treeTimer = document.createElement('div');
		infoEl.appendChild(treeTimer);
		treeContainer.appendChild(infoEl);

		updateTreeInfo(treePlace, infoElId, true);
	}

	// fix tooltip of whale/rainbowfish
	const tooltipTemplate = document.getElementById('tooltip-rawShark');
	function createRawFishTooltip(id, name)
	{
		const newTooltip = tooltipTemplate.cloneNode(true);
		newTooltip.id = 'tooltip-' + id;
		newTooltip.firstChild.textContent = name;
		newTooltip.lastChild.firstChild.textContent = '+? ';
		tooltipTemplate.parentElement.appendChild(newTooltip);
	}
	createRawFishTooltip('rawWhale', 'Raw Whale');
	createRawFishTooltip('rawRainbowFish', 'Raw Rainbowfish');
}



/**
 * improve smelting dialog
 */

const smeltingRequirements = {
	'glass': {
		sand: 1
		, oil: 10
	}
	, 'bronzeBar': {
		copper: 1
		, tin: 1
		, oil: 10
	}
	, 'ironBar': {
		iron: 1
		, oil: 100
	}
	, 'silverBar': {
		silver: 1
		, oil: 300
	}
	, 'goldBar': {
		gold: 1
		, oil: 1e3
	}
};
function improveSmelting()
{
	const amountInput = document.getElementById('input-smelt-bars-amount');
	amountInput.type = 'number';
	amountInput.min = 0;
	amountInput.step = 5;
	function onValueChange(event)
	{
		smeltingValue = null;
		window.selectBar('', '', amountInput, document.getElementById('smelting-furnace-capacity').value);
	}
	amountInput.addEventListener('mouseup', onValueChange);
	amountInput.addEventListener('keyup', onValueChange);
	amountInput.setAttribute('onkeyup', '');

	const _selectBar = window.selectBar;
	let smeltingValue = null;
	window.selectBar = (bar, inputElement, inputBarsAmountEl, capacity) =>
	{
		const requirements = smeltingRequirements[bar];
		let maxAmount = capacity;
		for (let key in requirements)
		{
			maxAmount = Math.min(Math.floor(window[key] / requirements[key]), maxAmount);
		}
		const value = parseInt(amountInput.value, 10);
		if (value > maxAmount)
		{
			smeltingValue = value;
			amountInput.value = maxAmount;
		}
		else if (smeltingValue != null)
		{
			amountInput.value = Math.min(smeltingValue, maxAmount);
			if (smeltingValue <= maxAmount)
			{
				smeltingValue = null;
			}
		}
		return _selectBar(bar, inputElement, inputBarsAmountEl, capacity);
	};

	const _openFurnaceDialogue = window.openFurnaceDialogue;
	window.openFurnaceDialogue = (furnace) =>
	{
		if (smeltingBarType == 0)
		{
			amountInput.max = getFurnaceCapacity(furnace);
		}
		return _openFurnaceDialogue(furnace);
	};
}



/**
 * add chance to time calculator
 */

/**
 * calculates the number of seconds until the event with the given chance happened at least once with the given
 * probability p (in percent)
 */
function calcSecondsTillP(chancePerSecond, p)
{
	return Math.round(Math.log(1 - p/100) / Math.log(1 - chancePerSecond));
}
function addChanceTooltip(headline, chancePerSecond, elId, targetEl)
{
	// ensure tooltip exists and is correctly binded
	const tooltipEl = ensureTooltip('chance-' + elId, targetEl);

	// set elements content
	const percValues = [1, 10, 20, 50, 80, 90, 99];
	let percRows = '';
	for (let p of percValues)
	{
		percRows += `
			<tr>
				<td>${p}%</td>
				<td>${formatTime2NearestUnit(calcSecondsTillP(chancePerSecond, p), true)}</td>
			</tr>`;
	}
	tooltipEl.innerHTML = `<h2>${headline}</h2>
		<table class="chance">
			<tr>
				<th>Probability</th>
				<th>Time</th>
			</tr>
			${percRows}
		</table>
	`;
}
function chance2TimeCalculator()
{
	addStyle(`
table.chance
{
	border-spacing: 0;
}
table.chance th
{
	border-bottom: 1px solid gray;
}
table.chance td:first-child
{
	border-right: 1px solid gray;
	text-align: center;
}
table.chance th,
table.chance td
{
	padding: 4px 8px;
}
table.chance tr:nth-child(2n) td
{
	background-color: white;
}
	`);

	const _clicksShovel = window.clicksShovel;
	window.clicksShovel = () =>
	{
		_clicksShovel();

		const shovelChance = document.getElementById('dialogue-shovel-chance');
		const titleEl = shovelChance.parentElement;
		const chance = 1/window.getChanceOfDiggingSand();
		addChanceTooltip('One sand every:', chance, 'shovel', titleEl);
	};

	// depends on fishingXp
	const _clicksFishingRod = window.clicksFishingRod;
	window.clicksFishingRod = () =>
	{
		_clicksFishingRod();

		const fishList = ['shrimp', 'sardine', 'tuna', 'swordfish', 'shark'];
		for (let fish of fishList)
		{
			const rawFish = 'raw' + fish[0].toUpperCase() + fish.substr(1);
			const row = document.getElementById('dialogue-fishing-rod-tr-' + rawFish);
			const chance = row.cells[4].textContent
				.replace(/[^\d\/]/g, '')
				.split('/')
				.reduce((p, c) => p / parseInt(c, 10), 1)
			;
			addChanceTooltip(`One raw ${fish} every:`, chance, rawFish, row);
		}
	};
}



/**
 * add tooltips for recipes
 */

function updateRecipeTooltips(recipeKey, recipes)
{
	const table = document.getElementById('table-' + recipeKey + '-recipe');
	const rows = table.rows;
	for (let i = 1; i < rows.length; i++)
	{
		const row = rows[i];
		const key = row.id.replace(recipeKey + '-', '');
		const recipe = recipes[key];
		const requirementCell = row.cells[3];
		requirementCell.title = recipe.recipe
			.map((name, i) =>
			{
				return formatNumber(recipe.recipeCost[i]) + ' '
					+ name.replace(/[A-Z]/g, (match) => ' ' + match.toLowerCase())
				;
			})
			.join(' + ')
		;
		window.$(requirementCell).tooltip();
	}
}
function updateTooltipsOnReinitRecipes(key)
{
	const capitalKey = key[0].toUpperCase() + key.substr(1);
	const processKey = 'process' + capitalKey + 'Tab';
	const _processTab = window[processKey];
	window[processKey] = () =>
	{
		const reinit = !!window['refreshLoad' + capitalKey + 'Table'];
		_processTab();

		if (reinit)
		{
			updateRecipeTooltips(key, window[key + 'Recipes']);
		}
	};
}
function addRecipeTooltips()
{
	updateTooltipsOnReinitRecipes('crafting');
	updateTooltipsOnReinitRecipes('brewing');
	updateTooltipsOnReinitRecipes('magic');
	updateTooltipsOnReinitRecipes('cooksBook');
}



/**
 * fix formatting of numbers
 */

function prepareRecipeForTable(recipe)
{
	// create a copy of the recipe to prevent requirement check from failing
	const newRecipe = JSON.parse(JSON.stringify(recipe));
	newRecipe.recipeCost = recipe.recipeCost.map(cost => formatNumber(cost));
	newRecipe.xp = formatNumber(recipe.xp);
	return newRecipe;
}
function fixNumberFormat()
{
	const _addRecipeToBrewingTable = window.addRecipeToBrewingTable;
	window.addRecipeToBrewingTable = (brewingRecipe) =>
	{
		_addRecipeToBrewingTable(prepareRecipeForTable(brewingRecipe));
	};

	const _addRecipeToMagicTable = window.addRecipeToMagicTable;
	window.addRecipeToMagicTable = (magicRecipe) =>
	{
		_addRecipeToMagicTable(prepareRecipeForTable(magicRecipe));
	};
}



/**
 * style tweaks
 */

function addTweakStyle(setting, style)
{
	const prefix = 'body.' + setting;
	addStyle(
		style
			.replace(/(^\s*|,\s*|\}\s*)([^\{\},]+)(,|\s*\{)/g, '$1' + prefix + ' $2$3')
	);
	document.body.classList.add(setting);
}
function tweakStyle()
{
	// tweak oil production/consumption
	addTweakStyle('tweak-oil', `
span#oil-flow-values
{
	margin-left: .5em;
	padding-left: 2rem;
	position: relative;
}
#oil-flow-values > span:nth-child(-n+2)
{
	font-size: 0px;
	position: absolute;
	left: 0;
	top: -0.75rem;
	visibility: hidden;
}
#oil-flow-values > span:nth-child(-n+2) > span
{
	font-size: 1rem;
	visibility: visible;
}
#oil-flow-values > span:nth-child(2)
{
	top: 0.75rem;
}
#oil-flow-values span[data-item-display="oilIn"]::before
{
	content: '+';
}
#oil-flow-values span[data-item-display="oilOut"]::before
{
	content: '-';
}
	`);
	// make room for oil cell on small devices
	const oilFlowValues = document.getElementById('oil-flow-values');
	oilFlowValues.parentElement.style.width = '30%';

	addTweakStyle('no-select', `
table.tab-bar,
span.item-box,
div.farming-patch,
div.farming-patch-locked,
div#tab-sub-container-combat > span,
table.top-links a,
#hero-area > div:last-child
{
	-webkit-user-select: none;
	-moz-user-select: none;
	-ms-user-select: none;
	user-select: none;
}
	`);
}



/**
 * init
 */

function init()
{
	initSettings();

	temporaryFixes();

	hideCraftedRecipes();
	improveItemBoxes();
	fixWoodcutting();
	initChat();
	improveTimer();
	improveSmelting();
	chance2TimeCalculator();
	addRecipeTooltips();

	fixNumberFormat();
	tweakStyle();
}
document.addEventListener('DOMContentLoaded', () =>
{
	const _doCommand = window.doCommand;
	window.doCommand = (data) =>
	{
		const values = data.split('=')[1];
		if (data.startsWith('REFRESH_ITEMS='))
		{
			const itemDataValues = values.split(';');
			const itemArray = [];
			for (var i = 0; i < itemDataValues.length; i++)
			{
				const [key, newValue] = itemDataValues[i].split('~');
				if (updateValue(key, newValue))
				{
					itemArray.push(key);
				}
			}

			window.refreshItemValues(itemArray, false);

			if (window.firstLoadGame)
			{
				window.loadInitial();
				window.firstLoadGame = false;
				init();
			}
			else
			{
				window.clientGameLoop();
			}
			return;
		}
		else if (data.startsWith('CHAT='))
		{
			var parts = data.substr(5).split('~');
			return newAddToChatBox(parts[0], parts[1], parts[2], parts[3], 0);
		}
		return _doCommand(data);
	};
});



/**
 * fix web socket errors
 */

function webSocketLoaded(event)
{
	if (window.webSocket == null)
	{
		console.error('no webSocket instance found!');
		return;
	}

	const messageQueue = [];
	const _onMessage = webSocket.onmessage;
	webSocket.onmessage = (event) => messageQueue.push(event);
	document.addEventListener('DOMContentLoaded', () =>
	{
		messageQueue.forEach(event => onMessage(event));
		webSocket.onmessage = _onMessage;
	});

	const commandQueue = [];
	const _sendBytes = window.sendBytes;
	window.sendBytes = (command) => commandQueue.push(command);
	const _onOpen = webSocket.onopen;
	webSocket.onopen = (event) =>
	{
		window.sendBytes = _sendBytes;
		commandQueue.forEach(command => window.sendBytes(command));
		return _onOpen(event);
	};
}
function isWebSocketScript(script)
{
	return script.src.includes('socket.js');
}
function fixWebSocketScript()
{
	if (!document.head)
	{
		return;
	}

	const scripts = document.head.querySelectorAll('script');
	let found = false;
	for (let i = 0; i < scripts.length; i++)
	{
		if (isWebSocketScript(scripts[i]))
		{
			// does this work?
			scripts[i].onload = webSocketLoaded;
			return;
		}
	}

	// create an observer instance
	const mutationObserver = new MutationObserver((mutationList) =>
	{
		mutationList.forEach((mutation) =>
		{
			if (mutation.addedNodes.length === 0)
			{
				return;
			}

			for (let i = 0; i < mutation.addedNodes.length; i++)
			{
				const node = mutation.addedNodes[i];
				if (node.tagName == 'SCRIPT' && isWebSocketScript(node))
				{
					mutationObserver.disconnect();
					node.onload = webSocketLoaded;
					return;
				}
			}
		});
	});
	mutationObserver.observe(document.head, {
		childList: true
	});
}
fixWebSocketScript();

// fix scrollText (e.g. when joining the game and receiving xp at that moment)
window.mouseX = window.innerWidth / 2;
window.mouseY = window.innerHeight / 2;
})();