Stats, Shards, XP, Dust, Quest, Res, Loot and Level tracker

Tracks XP, Dust, leveling speed, quest time, as well as shards, stats, and overhauls items entirely

// ==UserScript==
// @name         Stats, Shards, XP, Dust, Quest, Res, Loot and Level tracker
// @namespace    http://tampermonkey.net/
// @version      4.2.1
// @description  Tracks XP, Dust, leveling speed, quest time, as well as shards, stats, and overhauls items entirely
// @icon         https://www.google.com/s2/favicons?sz=64&domain=manarion.com
// @match        *://manarion.com/*
// @grant        none
// @author       Elnaeth
// @license      MIT
// ==/UserScript==

/*
 ======= Changelog =======
 v4.2.1
 Improved formatting of mastery in logs

 v4.2.0
 Levels per hour calculator added.

 v4.1.1
 Fixed TS items with +actions not showing those actions in item QoL

 v4.1.0
 Removed the external jQuery dependency, moved over to a smaller library with almost feature-parity for what I need, and inlined it.

 v4.0.2 to v4.0.6
 Added migrations to remove old stuff.
 Trying to hunt down a bug in settings initialization. Seems to be breaking the extension for some users on some chrome versions. GRR.
 Fixed enchant cap to use UTC time and not local time.
 Added more try/catch blocks and clearer errors for those specific areas where I suspect things are breaking.

 v4.0.1
 Added settings panel.
 Added item shard boosts to the QoL part as well (Crit chance, crit damage, etc)
 Added shard min/max drop tracker.
 Added jquery dependency for more dev QoL for me.
 Refactored almost all code to live in more isolated units, separated from each other.

 v3.0.0 - v3.0.2
 Inventory and profile screens QoL addon.
 You can now view which sets your items belong to in your inventory. 
 Also added visibility for main boosts on items, (missing and semi-capped) enchants on items, and item overall quality.
 Several of these features are added to profiles of other players.

 v2.8.2
 Fixed loot tracker reset button
 Made reset buttons alignment and spacing
 Reworked tick tracking to be faster and experience no more time drift, no longer tracking some ticks twice (happened rarely)

 v2.8.1
 Fix stat tracker sometimes completely screwing up all tracked numbers
*/

"use strict";

const globals = {
  lastGainedXP: 0,
  lastGainedDust: 0,
  lastGainedResource: 0,

  // TODO take 100% tax edge cases into account
};

/* minified absolutely smallest version of cashJS, a dep aimed at providing the smallest possible jQuery-like experience  */
(function(){"use strict";var C=document,D=window,st=C.documentElement,L=C.createElement.bind(C),ft=L("div"),q=L("table"),Mt=L("tbody"),ot=L("tr"),H=Array.isArray,S=Array.prototype,Dt=S.concat,U=S.filter,at=S.indexOf,ct=S.map,Bt=S.push,ht=S.slice,z=S.some,_t=S.splice,Pt=/^#(?:[\w-]|\\.|[^\x00-\xa0])*$/,Ht=/^\.(?:[\w-]|\\.|[^\x00-\xa0])*$/,$t=/<.+>/,jt=/^\w+$/;function J(t,n){var r=It(n);return!t||!r&&!A(n)&&!c(n)?[]:!r&&Ht.test(t)?n.getElementsByClassName(t.slice(1).replace(/\\/g,"")):!r&&jt.test(t)?n.getElementsByTagName(t):n.querySelectorAll(t)}var dt=function(){function t(n,r){if(n){if(Y(n))return n;var i=n;if(g(n)){var e=r||C;if(i=Pt.test(n)&&A(e)?e.getElementById(n.slice(1).replace(/\\/g,"")):$t.test(n)?yt(n):Y(e)?e.find(n):g(e)?o(e).find(n):J(n,e),!i)return}else if(O(n))return this.ready(n);(i.nodeType||i===D)&&(i=[i]),this.length=i.length;for(var s=0,f=this.length;s<f;s++)this[s]=i[s]}}return t.prototype.init=function(n,r){return new t(n,r)},t}(),u=dt.prototype,o=u.init;o.fn=o.prototype=u,u.length=0,u.splice=_t,typeof Symbol=="function"&&(u[Symbol.iterator]=S[Symbol.iterator]);function Y(t){return t instanceof dt}function B(t){return!!t&&t===t.window}function A(t){return!!t&&t.nodeType===9}function It(t){return!!t&&t.nodeType===11}function c(t){return!!t&&t.nodeType===1}function Ft(t){return!!t&&t.nodeType===3}function Wt(t){return typeof t=="boolean"}function O(t){return typeof t=="function"}function g(t){return typeof t=="string"}function v(t){return t===void 0}function P(t){return t===null}function lt(t){return!isNaN(parseFloat(t))&&isFinite(t)}function G(t){if(typeof t!="object"||t===null)return!1;var n=Object.getPrototypeOf(t);return n===null||n===Object.prototype}o.isWindow=B,o.isFunction=O,o.isArray=H,o.isNumeric=lt,o.isPlainObject=G;function d(t,n,r){if(r){for(var i=t.length;i--;)if(n.call(t[i],i,t[i])===!1)return t}else if(G(t))for(var e=Object.keys(t),i=0,s=e.length;i<s;i++){var f=e[i];if(n.call(t[f],f,t[f])===!1)return t}else for(var i=0,s=t.length;i<s;i++)if(n.call(t[i],i,t[i])===!1)return t;return t}o.each=d,u.each=function(t){return d(this,t)},u.empty=function(){return this.each(function(t,n){for(;n.firstChild;)n.removeChild(n.firstChild)})};function $(){for(var t=[],n=0;n<arguments.length;n++)t[n]=arguments[n];var r=Wt(t[0])?t.shift():!1,i=t.shift(),e=t.length;if(!i)return{};if(!e)return $(r,o,i);for(var s=0;s<e;s++){var f=t[s];for(var a in f)r&&(H(f[a])||G(f[a]))?((!i[a]||i[a].constructor!==f[a].constructor)&&(i[a]=new f[a].constructor),$(r,i[a],f[a])):i[a]=f[a]}return i}o.extend=$,u.extend=function(t){return $(u,t)};var qt=/\S+/g;function j(t){return g(t)?t.match(qt)||[]:[]}u.toggleClass=function(t,n){var r=j(t),i=!v(n);return this.each(function(e,s){c(s)&&d(r,function(f,a){i?n?s.classList.add(a):s.classList.remove(a):s.classList.toggle(a)})})},u.addClass=function(t){return this.toggleClass(t,!0)},u.removeAttr=function(t){var n=j(t);return this.each(function(r,i){c(i)&&d(n,function(e,s){i.removeAttribute(s)})})};function Ut(t,n){if(t){if(g(t)){if(arguments.length<2){if(!this[0]||!c(this[0]))return;var r=this[0].getAttribute(t);return P(r)?void 0:r}return v(n)?this:P(n)?this.removeAttr(t):this.each(function(e,s){c(s)&&s.setAttribute(t,n)})}for(var i in t)this.attr(i,t[i]);return this}}u.attr=Ut,u.removeClass=function(t){return arguments.length?this.toggleClass(t,!1):this.attr("class","")},u.hasClass=function(t){return!!t&&z.call(this,function(n){return c(n)&&n.classList.contains(t)})},u.get=function(t){return v(t)?ht.call(this):(t=Number(t),this[t<0?t+this.length:t])},u.eq=function(t){return o(this.get(t))},u.first=function(){return this.eq(0)},u.last=function(){return this.eq(-1)};function zt(t){return v(t)?this.get().map(function(n){return c(n)||Ft(n)?n.textContent:""}).join(""):this.each(function(n,r){c(r)&&(r.textContent=t)})}u.text=zt;function T(t,n,r){if(c(t)){var i=D.getComputedStyle(t,null);return r?i.getPropertyValue(n)||void 0:i[n]||t.style[n]}}function E(t,n){return parseInt(T(t,n),10)||0}function gt(t,n){return E(t,"border".concat(n?"Left":"Top","Width"))+E(t,"padding".concat(n?"Left":"Top"))+E(t,"padding".concat(n?"Right":"Bottom"))+E(t,"border".concat(n?"Right":"Bottom","Width"))}var X={};function Jt(t){if(X[t])return X[t];var n=L(t);C.body.insertBefore(n,null);var r=T(n,"display");return C.body.removeChild(n),X[t]=r!=="none"?r:"block"}function vt(t){return T(t,"display")==="none"}function pt(t,n){var r=t&&(t.matches||t.webkitMatchesSelector||t.msMatchesSelector);return!!r&&!!n&&r.call(t,n)}function I(t){return g(t)?function(n,r){return pt(r,t)}:O(t)?t:Y(t)?function(n,r){return t.is(r)}:t?function(n,r){return r===t}:function(){return!1}}u.filter=function(t){var n=I(t);return o(U.call(this,function(r,i){return n.call(r,i,r)}))};function x(t,n){return n?t.filter(n):t}u.detach=function(t){return x(this,t).each(function(n,r){r.parentNode&&r.parentNode.removeChild(r)}),this};var Yt=/^\s*<(\w+)[^>]*>/,Gt=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,mt={"*":ft,tr:Mt,td:ot,th:ot,thead:q,tbody:q,tfoot:q};function yt(t){if(!g(t))return[];if(Gt.test(t))return[L(RegExp.$1)];var n=Yt.test(t)&&RegExp.$1,r=mt[n]||mt["*"];return r.innerHTML=t,o(r.childNodes).detach().get()}o.parseHTML=yt,u.has=function(t){var n=g(t)?function(r,i){return J(t,i).length}:function(r,i){return i.contains(t)};return this.filter(n)},u.not=function(t){var n=I(t);return this.filter(function(r,i){return(!g(t)||c(i))&&!n.call(i,r,i)})};function R(t,n,r,i){for(var e=[],s=O(n),f=i&&I(i),a=0,y=t.length;a<y;a++)if(s){var h=n(t[a]);h.length&&Bt.apply(e,h)}else for(var p=t[a][n];p!=null&&!(i&&f(-1,p));)e.push(p),p=r?p[n]:null;return e}function bt(t){return t.multiple&&t.options?R(U.call(t.options,function(n){return n.selected&&!n.disabled&&!n.parentNode.disabled}),"value"):t.value||""}function Xt(t){return arguments.length?this.each(function(n,r){var i=r.multiple&&r.options;if(i||Ot.test(r.type)){var e=H(t)?ct.call(t,String):P(t)?[]:[String(t)];i?d(r.options,function(s,f){f.selected=e.indexOf(f.value)>=0},!0):r.checked=e.indexOf(r.value)>=0}else r.value=v(t)||P(t)?"":t}):this[0]&&bt(this[0])}u.val=Xt,u.is=function(t){var n=I(t);return z.call(this,function(r,i){return n.call(r,i,r)})},o.guid=1;function w(t){return t.length>1?U.call(t,function(n,r,i){return at.call(i,n)===r}):t}o.unique=w,u.add=function(t,n){return o(w(this.get().concat(o(t,n).get())))},u.children=function(t){return x(o(w(R(this,function(n){return n.children}))),t)},u.parent=function(t){return x(o(w(R(this,"parentNode"))),t)},u.index=function(t){var n=t?o(t)[0]:this[0],r=t?this:o(n).parent().children();return at.call(r,n)},u.closest=function(t){var n=this.filter(t);if(n.length)return n;var r=this.parent();return r.length?r.closest(t):n},u.siblings=function(t){return x(o(w(R(this,function(n){return o(n).parent().children().not(n)}))),t)},u.find=function(t){return o(w(R(this,function(n){return J(t,n)})))};var Kt=/^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g,Qt=/^$|^module$|\/(java|ecma)script/i,Vt=["type","src","nonce","noModule"];function Zt(t,n){var r=o(t);r.filter("script").add(r.find("script")).each(function(i,e){if(Qt.test(e.type)&&st.contains(e)){var s=L("script");s.text=e.textContent.replace(Kt,""),d(Vt,function(f,a){e[a]&&(s[a]=e[a])}),n.head.insertBefore(s,null),n.head.removeChild(s)}})}function kt(t,n,r,i,e){i?t.insertBefore(n,r?t.firstChild:null):t.nodeName==="HTML"?t.parentNode.replaceChild(n,t):t.parentNode.insertBefore(n,r?t:t.nextSibling),e&&Zt(n,t.ownerDocument)}function N(t,n,r,i,e,s,f,a){return d(t,function(y,h){d(o(h),function(p,M){d(o(n),function(b,W){var rt=r?M:W,it=r?W:M,m=r?p:b;kt(rt,m?it.cloneNode(!0):it,i,e,!m)},a)},f)},s),n}u.after=function(){return N(arguments,this,!1,!1,!1,!0,!0)},u.append=function(){return N(arguments,this,!1,!1,!0)};function tn(t){if(!arguments.length)return this[0]&&this[0].innerHTML;if(v(t))return this;var n=/<script[\s>]/.test(t);return this.each(function(r,i){c(i)&&(n?o(i).empty().append(t):i.innerHTML=t)})}u.html=tn,u.appendTo=function(t){return N(arguments,this,!0,!1,!0)},u.wrapInner=function(t){return this.each(function(n,r){var i=o(r),e=i.contents();e.length?e.wrapAll(t):i.append(t)})},u.before=function(){return N(arguments,this,!1,!0)},u.wrapAll=function(t){for(var n=o(t),r=n[0];r.children.length;)r=r.firstElementChild;return this.first().before(n),this.appendTo(r)},u.wrap=function(t){return this.each(function(n,r){var i=o(t)[0];o(r).wrapAll(n?i.cloneNode(!0):i)})},u.insertAfter=function(t){return N(arguments,this,!0,!1,!1,!1,!1,!0)},u.insertBefore=function(t){return N(arguments,this,!0,!0)},u.prepend=function(){return N(arguments,this,!1,!0,!0,!0,!0)},u.prependTo=function(t){return N(arguments,this,!0,!0,!0,!1,!1,!0)},u.contents=function(){return o(w(R(this,function(t){return t.tagName==="IFRAME"?[t.contentDocument]:t.tagName==="TEMPLATE"?t.content.childNodes:t.childNodes})))},u.next=function(t,n,r){return x(o(w(R(this,"nextElementSibling",n,r))),t)},u.nextAll=function(t){return this.next(t,!0)},u.nextUntil=function(t,n){return this.next(n,!0,t)},u.parents=function(t,n){return x(o(w(R(this,"parentElement",!0,n))),t)},u.parentsUntil=function(t,n){return this.parents(n,t)},u.prev=function(t,n,r){return x(o(w(R(this,"previousElementSibling",n,r))),t)},u.prevAll=function(t){return this.prev(t,!0)},u.prevUntil=function(t,n){return this.prev(n,!0,t)},u.map=function(t){return o(Dt.apply([],ct.call(this,function(n,r){return t.call(n,r,n)})))},u.clone=function(){return this.map(function(t,n){return n.cloneNode(!0)})},u.offsetParent=function(){return this.map(function(t,n){for(var r=n.offsetParent;r&&T(r,"position")==="static";)r=r.offsetParent;return r||st})},u.slice=function(t,n){return o(ht.call(this,t,n))};var nn=/-([a-z])/g;function K(t){return t.replace(nn,function(n,r){return r.toUpperCase()})}u.ready=function(t){var n=function(){return setTimeout(t,0,o)};return C.readyState!=="loading"?n():C.addEventListener("DOMContentLoaded",n),this},u.unwrap=function(){return this.parent().each(function(t,n){if(n.tagName!=="BODY"){var r=o(n);r.replaceWith(r.children())}}),this},u.offset=function(){var t=this[0];if(t){var n=t.getBoundingClientRect();return{top:n.top+D.pageYOffset,left:n.left+D.pageXOffset}}},u.position=function(){var t=this[0];if(t){var n=T(t,"position")==="fixed",r=n?t.getBoundingClientRect():this.offset();if(!n){for(var i=t.ownerDocument,e=t.offsetParent||i.documentElement;(e===i.body||e===i.documentElement)&&T(e,"position")==="static";)e=e.parentNode;if(e!==t&&c(e)){var s=o(e).offset();r.top-=s.top+E(e,"borderTopWidth"),r.left-=s.left+E(e,"borderLeftWidth")}}return{top:r.top-E(t,"marginTop"),left:r.left-E(t,"marginLeft")}}};var Et={class:"className",contenteditable:"contentEditable",for:"htmlFor",readonly:"readOnly",maxlength:"maxLength",tabindex:"tabIndex",colspan:"colSpan",rowspan:"rowSpan",usemap:"useMap"};u.prop=function(t,n){if(t){if(g(t))return t=Et[t]||t,arguments.length<2?this[0]&&this[0][t]:this.each(function(i,e){e[t]=n});for(var r in t)this.prop(r,t[r]);return this}},u.removeProp=function(t){return this.each(function(n,r){delete r[Et[t]||t]})};var rn=/^--/;function Q(t){return rn.test(t)}var V={},en=ft.style,un=["webkit","moz","ms"];function sn(t,n){if(n===void 0&&(n=Q(t)),n)return t;if(!V[t]){var r=K(t),i="".concat(r[0].toUpperCase()).concat(r.slice(1)),e="".concat(r," ").concat(un.join("".concat(i," "))).concat(i).split(" ");d(e,function(s,f){if(f in en)return V[t]=f,!1})}return V[t]}var fn={animationIterationCount:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0};function wt(t,n,r){return r===void 0&&(r=Q(t)),!r&&!fn[t]&&lt(n)?"".concat(n,"px"):n}function on(t,n){if(g(t)){var r=Q(t);return t=sn(t,r),arguments.length<2?this[0]&&T(this[0],t,r):t?(n=wt(t,n,r),this.each(function(e,s){c(s)&&(r?s.style.setProperty(t,n):s.style[t]=n)})):this}for(var i in t)this.css(i,t[i]);return this}u.css=on;function Ct(t,n){try{return t(n)}catch{return n}}var an=/^\s+|\s+$/;function St(t,n){var r=t.dataset[n]||t.dataset[K(n)];return an.test(r)?r:Ct(JSON.parse,r)}function cn(t,n,r){r=Ct(JSON.stringify,r),t.dataset[K(n)]=r}function hn(t,n){if(!t){if(!this[0])return;var r={};for(var i in this[0].dataset)r[i]=St(this[0],i);return r}if(g(t))return arguments.length<2?this[0]&&St(this[0],t):v(n)?this:this.each(function(e,s){cn(s,t,n)});for(var i in t)this.data(i,t[i]);return this}u.data=hn;function Tt(t,n){var r=t.documentElement;return Math.max(t.body["scroll".concat(n)],r["scroll".concat(n)],t.body["offset".concat(n)],r["offset".concat(n)],r["client".concat(n)])}d([!0,!1],function(t,n){d(["Width","Height"],function(r,i){var e="".concat(n?"outer":"inner").concat(i);u[e]=function(s){if(this[0])return B(this[0])?n?this[0]["inner".concat(i)]:this[0].document.documentElement["client".concat(i)]:A(this[0])?Tt(this[0],i):this[0]["".concat(n?"offset":"client").concat(i)]+(s&&n?E(this[0],"margin".concat(r?"Top":"Left"))+E(this[0],"margin".concat(r?"Bottom":"Right")):0)}})}),d(["Width","Height"],function(t,n){var r=n.toLowerCase();u[r]=function(i){if(!this[0])return v(i)?void 0:this;if(!arguments.length)return B(this[0])?this[0].document.documentElement["client".concat(n)]:A(this[0])?Tt(this[0],n):this[0].getBoundingClientRect()[r]-gt(this[0],!t);var e=parseInt(i,10);return this.each(function(s,f){if(c(f)){var a=T(f,"boxSizing");f.style[r]=wt(r,e+(a==="border-box"?gt(f,!t):0))}})}});var Rt="___cd";u.toggle=function(t){return this.each(function(n,r){if(c(r)){var i=vt(r),e=v(t)?i:t;e?(r.style.display=r[Rt]||"",vt(r)&&(r.style.display=Jt(r.tagName))):i||(r[Rt]=T(r,"display"),r.style.display="none")}})},u.hide=function(){return this.toggle(!1)},u.show=function(){return this.toggle(!0)};var xt="___ce",Z=".",k={focus:"focusin",blur:"focusout"},Nt={mouseenter:"mouseover",mouseleave:"mouseout"},dn=/^(mouse|pointer|contextmenu|drag|drop|click|dblclick)/i;function tt(t){return Nt[t]||k[t]||t}function nt(t){var n=t.split(Z);return[n[0],n.slice(1).sort()]}u.trigger=function(t,n){if(g(t)){var r=nt(t),i=r[0],e=r[1],s=tt(i);if(!s)return this;var f=dn.test(s)?"MouseEvents":"HTMLEvents";t=C.createEvent(f),t.initEvent(s,!0,!0),t.namespace=e.join(Z),t.___ot=i}t.___td=n;var a=t.___ot in k;return this.each(function(y,h){a&&O(h[t.___ot])&&(h["___i".concat(t.type)]=!0,h[t.___ot](),h["___i".concat(t.type)]=!1),h.dispatchEvent(t)})};function Lt(t){return t[xt]=t[xt]||{}}function ln(t,n,r,i,e){var s=Lt(t);s[n]=s[n]||[],s[n].push([r,i,e]),t.addEventListener(n,e)}function At(t,n){return!n||!z.call(n,function(r){return t.indexOf(r)<0})}function F(t,n,r,i,e){var s=Lt(t);if(n)s[n]&&(s[n]=s[n].filter(function(f){var a=f[0],y=f[1],h=f[2];if(e&&h.guid!==e.guid||!At(a,r)||i&&i!==y)return!0;t.removeEventListener(n,h)}));else for(n in s)F(t,n,r,i,e)}u.off=function(t,n,r){var i=this;if(v(t))this.each(function(s,f){!c(f)&&!A(f)&&!B(f)||F(f)});else if(g(t))O(n)&&(r=n,n=""),d(j(t),function(s,f){var a=nt(f),y=a[0],h=a[1],p=tt(y);i.each(function(M,b){!c(b)&&!A(b)&&!B(b)||F(b,p,h,n,r)})});else for(var e in t)this.off(e,t[e]);return this},u.remove=function(t){return x(this,t).detach().off(),this},u.replaceWith=function(t){return this.before(t).remove()},u.replaceAll=function(t){return o(t).replaceWith(this),this};function gn(t,n,r,i,e){var s=this;if(!g(t)){for(var f in t)this.on(f,n,r,t[f],e);return this}return g(n)||(v(n)||P(n)?n="":v(r)?(r=n,n=""):(i=r,r=n,n="")),O(i)||(i=r,r=void 0),i?(d(j(t),function(a,y){var h=nt(y),p=h[0],M=h[1],b=tt(p),W=p in Nt,rt=p in k;b&&s.each(function(it,m){if(!(!c(m)&&!A(m)&&!B(m))){var et=function(l){if(l.target["___i".concat(l.type)])return l.stopImmediatePropagation();if(!(l.namespace&&!At(M,l.namespace.split(Z)))&&!(!n&&(rt&&(l.target!==m||l.___ot===b)||W&&l.relatedTarget&&m.contains(l.relatedTarget)))){var ut=m;if(n){for(var _=l.target;!pt(_,n);)if(_===m||(_=_.parentNode,!_))return;ut=_}Object.defineProperty(l,"currentTarget",{configurable:!0,get:function(){return ut}}),Object.defineProperty(l,"delegateTarget",{configurable:!0,get:function(){return m}}),Object.defineProperty(l,"data",{configurable:!0,get:function(){return r}});var bn=i.call(ut,l,l.___td);e&&F(m,b,M,n,et),bn===!1&&(l.preventDefault(),l.stopPropagation())}};et.guid=i.guid=i.guid||o.guid++,ln(m,b,M,n,et)}})}),this):this}u.on=gn;function vn(t,n,r,i){return this.on(t,n,r,i,!0)}u.one=vn;var pn=/\r?\n/g;function mn(t,n){return"&".concat(encodeURIComponent(t),"=").concat(encodeURIComponent(n.replace(pn,`\r
`)))}var yn=/file|reset|submit|button|image/i,Ot=/radio|checkbox/i;u.serialize=function(){var t="";return this.each(function(n,r){d(r.elements||[r],function(i,e){if(!(e.disabled||!e.name||e.tagName==="FIELDSET"||yn.test(e.type)||Ot.test(e.type)&&!e.checked)){var s=bt(e);if(!v(s)){var f=H(s)?s:[s];d(f,function(a,y){t+=mn(e.name,y)})}}})}),t.slice(1)},typeof exports<"u"?module.exports=o:D.cash=D.$=o})();

/*
    Some versions can/will cause breakage because we might have leftover stuff from previous versions left in localStorage. 
*/

(function () {
  if (localStorage.getItem("elnaeth-items-log-visible")) localStorage.removeItem("elnaeth-items-log-visible");
  if (localStorage.getItem("elnaeth-stats-log-visible")) localStorage.removeItem("elnaeth-stats-log-visible");
})();

const BoostTypes = Object.freeze({
  BASE_SPELLPOWER: { id: 1, name: "Base Spellpower" },
  BASE_WARD: { id: 2, name: "Base Ward" },

  STAT_INTELLECT: { id: 3, name: "Intellect" },
  STAT_STAMINA: { id: 4, name: "Stamina" },
  STAT_FOCUS: { id: 5, name: "Focus" },
  STAT_SPIRIT: { id: 6, name: "Spirit" },
  STAT_MANA: { id: 7, name: "Mana" },

  FIRE_MASTERY: { id: 10, name: "Fire Mastery" },
  WATER_MASTERY: { id: 11, name: "Water Mastery" },
  NATURE_MASTERY: { id: 12, name: "Nature Mastery" },

  DAMAGE: { id: 40, name: "Damage", short: "Da" },
  MULTICAST: { id: 41, name: "Multicast", short: "MC" },
  CRIT_CHANCE: { id: 42, name: "Critical hit chance", short: "CC" },
  CRIT_DAMAGE: { id: 43, name: "Critical hit damage", short: "CD" },
  HASTE: { id: 44, name: "Haste", short: "Ha" },
  HEALTH: { id: 45, name: "Health", short: "Hp" },
  WARD: { id: 46, name: "Ward", short: "Wa" },
  FOCUS: { id: 47, name: "Focus", short: "Fo" },
  MANA: { id: 48, name: "Mana", short: "Ma" },
  OVERLOAD: { id: 49, name: "Overload", short: "OL" },
  TIME_DILATION: { id: 50, name: "Time dilation", short: "TD" },

  ENCHANT_INFERNO_RANK: { id: 60, name: "Inferno Enchant Rank" },
  ENCHANT_TIDAL_WRATH_RANK: { id: 61, name: "Tidal Wrath Enchant Rank" },
  ENCHANT_WILDHEART_RANK: { id: 62, name: "Wildheart Enchant Rank" },
  ENCHANT_FIRE_RESISTANCE_RANK: { id: 63, name: "Fire Resistance Enchant Rank" },
  ENCHANT_WATER_RESISTANCE_RANK: { id: 64, name: "Water Resistance Enchant Rank" },
  ENCHANT_NATURE_RESISTANCE_RANK: { id: 65, name: "Nature Resistance Enchant Rank" },
  ENCHANT_INSIGHT_RANK: { id: 66, name: "Insight Enchant Rank" },
  ENCHANT_BOUNTIFUL_HARVEST_RANK: { id: 67, name: "Bountiful Harvest Enchant Rank" },
  ENCHANT_PROSPERITY_RANK: { id: 68, name: "Prosperity Enchant Rank" },
  ENCHANT_FORTUNE_RANK: { id: 69, name: "Fortune Enchant Rank" },
  ENCHANT_GROWTH_RANK: { id: 70, name: "Growth Enchant Rank" },
  ENCHANT_VITALITY_RANK: { id: 71, name: "Vitality Enchant Rank" },

  INFERNO_VALUE: { id: 80, name: "Inferno" },
  TIDAL_WRATH_VALUE: { id: 81, name: "Tidal Wrath" },
  WILDHEART_VALUE: { id: 82, name: "Wildheart" },
  FIRE_RESISTANCE_VALUE: { id: 83, name: "Fire res" },
  WATER_RESISTANCE_VALUE: { id: 84, name: "Water res" },
  NATURE_RESISTANCE_VALUE: { id: 85, name: "Nature res" },
  VITALITY_VALUE: { id: 86, name: "Vitality" },

  BASE_EXPERIENCE: { id: 100, name: "Base Experience" },
  BASE_MANA_DUST: { id: 101, name: "Base Mana Dust" },
  DROP_BOOST: { id: 102, name: "Drop Boost" },
  MULTISTAT: { id: 103, name: "Multistat Boost" },
  ACTIONS: { id: 105, name: "Actions", short: "actions" },
  BASE_RESOURCE: { id: 106, name: "Base Resource" },

  BATTLE_EXPERIENCE_BOOST: { id: 120, name: "Battle Experience Boost", short: "exp" },
  MANA_DUST_BOOST: { id: 121, name: "Mana Dust Boost", short: "dust" },
  ELEMENTAL_SHARD_BOOST: { id: 122, name: "Elemental Shard Boost", short: "shards" },
  STAT_DROP: { id: 123, name: "Stat Drop", short: "stats" },
  BASE_RESOURCE_AMOUNT: { id: 124, name: "Base Resource Amount", short: "res" },
});

const ItemTypes = Object.freeze({
  MANA_DUST: { id: 1, name: "Mana Dust", rarity: "common" },
  ELEMENTAL_SHARDS: { id: 2, name: "Elemental Shards", rarity: "common" },
  CODEX: { id: 3, name: "Codex", rarity: "epic" },

  FIRE_ESSENCE: { id: 4, name: "Fire Essence", rarity: "rare" },
  WATER_ESSENCE: { id: 5, name: "Water Essence", rarity: "rare" },
  NATURE_ESSENCE: { id: 6, name: "Nature Essence", rarity: "rare" },

  FISH: { id: 7, name: "Fish", rarity: "common" },
  WOOD: { id: 8, name: "Wood", rarity: "common" },
  IRON: { id: 9, name: "Iron", rarity: "common" },

  ASBESTOS: { id: 10, name: "Asbestos", rarity: "uncommon" },
  IRONBARK: { id: 11, name: "Ironbark", rarity: "uncommon" },
  FISH_SCALES: { id: 12, name: "Fish Scales", rarity: "uncommon" },

  TOME_OF_FIRE: { id: 13, name: "Tome of Fire", rarity: "uncommon" },
  TOME_OF_WATER: { id: 14, name: "Tome of Water", rarity: "uncommon" },
  TOME_OF_NATURE: { id: 15, name: "Tome of Nature", rarity: "uncommon" },

  TOME_OF_MANA_SHIELD: { id: 16, name: "Tome of Mana Shield", rarity: "epic" },

  ENCHANT_FIRE_RESISTANCE: { id: 17, name: "Formula: Fire Resistance", rarity: "epic" },
  ENCHANT_WATER_RESISTANCE: { id: 18, name: "Formula: Water Resistance", rarity: "epic" },
  ENCHANT_NATURE_RESISTANCE: { id: 19, name: "Formula: Nature Resistance", rarity: "epic" },
  ENCHANT_INFERNO: { id: 20, name: "Formula: Inferno", rarity: "epic" },
  ENCHANT_TIDAL_WRATH: { id: 21, name: "Formula: Tidal Wrath", rarity: "epic" },
  ENCHANT_WILDHEART: { id: 22, name: "Formula: Wildheart", rarity: "epic" },
  ENCHANT_INSIGHT: { id: 23, name: "Formula: Insight", rarity: "epic" },
  ENCHANT_BOUNTIFUL_HARVEST: { id: 24, name: "Formula: Bountiful Harvest", rarity: "epic" },
  ENCHANT_PROSPERITY: { id: 25, name: "Formula: Prosperity", rarity: "epic" },
  ENCHANT_FORTUNE: { id: 26, name: "Formula: Fortune", rarity: "epic" },
  ENCHANT_GROWTH: { id: 27, name: "Formula: Growth", rarity: "epic" },
  ENCHANT_VITALITY: { id: 28, name: "Formula: Vitality", rarity: "epic" },

  REAGENT_ELDERWOOD: { id: 29, name: "Elderwood", rarity: "uncommon" },
  REAGENT_LODESTONE: { id: 30, name: "Lodestone", rarity: "uncommon" },
  REAGENT_WHITE_PEARL: { id: 31, name: "White Pearl", rarity: "uncommon" },
  REAGENT_FOUR_LEAF_CLOVER: { id: 32, name: "Four Leaf Clover", rarity: "uncommon" },
  REAGENT_ENCHANTED_DROPLET: { id: 33, name: "Enchanted Droplet", rarity: "uncommon" },
  REAGENT_INFERNAL_HEART: { id: 34, name: "Infernal Heart", rarity: "uncommon" },

  ORB_OF_POWER: { id: 35, name: "Orb of Power", rarity: "rare" },
  ORB_OF_CHAOS: { id: 36, name: "Orb of Chaos", rarity: "epic" },
  ORB_OF_DIVINITY: { id: 37, name: "Orb of Divinity", rarity: "legendary" },

  SUNPETAL: { id: 39, name: "Sunpetal", rarity: "rare" },
  SAGEROOT: { id: 40, name: "Sageroot", rarity: "common" },
  BLOOMWELL: { id: 41, name: "Bloomwell", rarity: "common" },
});

const utils = {
  gatheringActions: ["mining", "fishing", "woodcutting"],

  // function that formats huge numbers humanly-readable
  formatNumber: (num) => {
    if (num >= 1e18) return (num / 1e18).toFixed(2) + "Qi";
    if (num >= 1e15) return (num / 1e15).toFixed(2) + "Qa";
    if (num >= 1e12) return (num / 1e12).toFixed(2) + "T";
    if (num >= 1e9) return (num / 1e9).toFixed(2) + "B";
    if (num >= 1e6) return (num / 1e6).toFixed(2) + "M";
    if (num >= 1e3) return (num / 1e3).toFixed(2) + "K";

    // if it's a result like 1.00 or 2.00 we trim the zeroes
    const result = (num / 1).toFixed(2).toString();

    return result.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
  },

  // converts minutes to 1h 30m strings
  formatTime: (minutesTotal) => {
    const hours = Math.floor(minutesTotal / 60);
    const minutes = Math.floor(minutesTotal % 60);
    return `${hours}h ${minutes}m`;
  },

  sortByRarity: (a, b) => {
    const rarityOrder = {
      common: 1,
      uncommon: 2,
      rare: 3,
      epic: 4,
      legendary: 5,
    };

    return rarityOrder[a.rarity] - rarityOrder[b.rarity];
  },

  // count number of mondays since enchanting cap implementation
  calculateEnchantingCap: () => {
    const startDate = new Date(Date.UTC(2025, 3, 24));
    const today = new Date();
    let count = 22; // initial count of 22, when the patch was implemented
    let currentDate = new Date(startDate);

    // Loop until today
    while (currentDate <= today) {
      if (currentDate.getDay() === 1) {
        // 1 = Monday
        count++;
      }
      currentDate.setDate(currentDate.getDate() + 1);
    }

    return count;
  },

  isBattleActivity: (activity) => activity === "battle",
  isGatherActivity: (activity) => utils.gatheringActions.includes(activity),

  // keep track of what kind of thing we're doing right now
  isBattling: () => utils.isBattleActivity(manarion.player.ActionType),
  isGathering: () => utils.isGatherActivity(manarion.player.ActionType),
};

const settings = {
  __localStorageKey: "elnaeth-settings",

  // this holds the current configuration (starts out empty)
  configuration: {},

  // these are the default settings
  defaults: {
    // general settings
    showLootTracker: true,
    showStatsTracker: true,
    showStatsLog: true,

    // the main item qol settings
    enableItemQol: true,
    enableItemQolProfilePage: true,

    // which items to parse
    parseRareItems: true,
    parseEpicItems: true,
    parseLegendaryItems: true,

    // various other item QoL settings
    showItemCodexBoosts: true,
    showItemShardBoosts: true,
    showItemEnchants: true,
    showItemSets: true,
    showItemQuality: true,

    // dev settings (huehue that's me silly)
    debugMode: false,
  },

  loadFromStorage: () => {
    // clone the defaults
    const newConfiguration = { ...settings.defaults };

    const loaded = localStorage.getItem(settings.__localStorageKey);
    if (!loaded) {
      console.warn("[settings] using default settings");
      settings.configuration = newConfiguration;
      return;
    }

    try {
      const parsed = JSON.parse(loaded);
      if (!parsed) {
        console.warn("[settings] Malformed JSON in localStorage, falling back to defaults:", loaded);
        settings.configuration = newConfiguration;
        return;
      }

      // overwrite any defaults, but keep any that have never been saved
      for (const setting in parsed) {
        newConfiguration[setting] = parsed[setting];
      }

      // set the internal storage to what we just loaded
      settings.configuration = newConfiguration;
    } catch (error) {
      console.error("[settings] Could not parse settings from localstorage:", error);
    }

    logger.debug("[settings] Loaded from storage");
  },

  saveToStorage: () => {
    const newSettings = settings.serializeForm();

    // overwrite existing configuration
    for (const setting in newSettings) {
      settings.configuration[setting] = newSettings[setting];
    }

    const json = JSON.stringify(settings.configuration);
    localStorage.setItem(settings.__localStorageKey, json);

    // because settings might have changed visibility, re-render the UI
    ui.render();
    itemQOL.render();

    logger.debug("[settings] Saved to storage");
  },

  setCheckboxValues: (settings) => {
    for (const setting in settings) {
      $(`#settings-form input[name="${setting}"]`).prop("checked", settings[setting]);
    }
  },

  serializeForm: () => {
    const settings = {};

    $('#settings-form input[type="checkbox"]').each(function () {
      settings[this.name] = this.checked;
    });

    return settings;
  },

  initialize: () => {
    const style = document.createElement("style");
    style.textContent = `
        button#elnaeth-settings-button {
          position: fixed;
          bottom: 20px;
          right: 30px;
          z-index: 9999;
        }

        button#elnaeth-settings-button, div#elnaeth-modal-overlay button {
          padding: 10px 15px;
          background: #333;
          color: white;
          border: none;
          border-radius: 6px;
          cursor: pointer;
          font-size: 14px;        
        }

        div#elnaeth-modal-overlay {
          position: fixed;
          top: 0; left: 0; right: 0; bottom: 0;
          background: rgba(0, 0, 0, 0.4);
          display: none;
          align-items: center;
          justify-content: center;
          z-index: 9998;
        }

        div#elnaeth-modal-overlay label {
          cursor: pointer;
          
          height: 20px;
          line-height: 20px;
        }

        div#elnaeth-modal-overlay input {
          margin-right: 15px;
          transform: scale(1.25);
        }

        div#elnaeth-modal-overlay div.flex.items-center.justify-between:hover {
          box-shadow: 0 0 0 1px var(--ring);
        }

        div#elnaeth-modal-overlay hr {
          border-top: 1px solid var(--ring); 
          margin: 10px 0 10px 0;
        }

        div#elnaeth-modal-overlay h1, h2 {
          font-size: 1.5em;
        }
        
        div#elnaeth-modal-overlay div#tracker-modal {
          background-color: #000;
          margin: 30px auto;
          padding: 20px;
          border-radius: 10px;
          border-color: var(--ring);
          border-width: 2px;
          min-width: 300px;
          max-width: 75%;
          min-height: 500px;
          max-height: 75%;
          overflow-y: auto;
        }

        div#close-tracker-modal {
          float: right;
          cursor: pointer;
          font-weight: bold;
          font-size: 16px;
        }
    `;

    document.head.appendChild(style);

    $("body").append(`
      <button id="elnaeth-settings-button">
        Tracker settings
      </button>

      <div id="elnaeth-modal-overlay" style="display:none;">
        <div id="tracker-modal">
          <div class="pt-4 text-right float-right">
            <button id="close-tracker-modal" type="button"> X </button>
          </div>

          <h1 class="text-left float-left">Settings</h1>
          
          <br/>

          <form id="settings-form" class="space-y-2">
            <h2>General settings</h2>

            <div class="flex items-center justify-between">
              <label for="show-stats-tracker">Show stats tracker (lower left)</label>
              <input type="checkbox" id="show-stats-tracker" name="showStatsTracker" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="show-stats-log">Show individual stat gains log (upper right)</label>
              <input type="checkbox" id="show-stats-log" name="showStatsLog" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="show-loot-tracker">Show enhanced loot tracker (upper right)</label>
              <input type="checkbox" id="show-loot-tracker" name="showLootTracker" class="toggle">
            </div>

            <hr>

            <h2>Item QoL settings</h2>

            <div class="flex items-center justify-between">
              <label for="enable-item-qol">Enable item QoL as a whole</label>
              <input type="checkbox" id="enable-item-qol" name="enableItemQol" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="enable-item-qol-profile-page">Enable item QoL on profile pages</label>
              <input type="checkbox" id="enable-item-qol-profile-page" name="enableItemQolProfilePage" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="parse-rare-items">Parse rare items</label>
              <input type="checkbox" id="parse-rare-items" name="parseRareItems" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="parse-epic-items">Parse epic items</label>
              <input type="checkbox" id="parse-epic-items" name="parseEpicItems" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="parse-legendary-items">Parse legendary items</label>
              <input type="checkbox" id="parse-legendary-items" name="parseLegendaryItems" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="show-codex-boosts">Show codex boosts (such as exp, stat, mana dust, etc)</label>
              <input type="checkbox" id="show-codex-boosts" name="showItemCodexBoosts" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="show-shard-boosts">Show shard boosts (such as crit chance, crit damage, etc)</label>
              <input type="checkbox" id="show-shard-boosts" name="showItemShardBoosts" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="show-enchants">Show (missing) enchants</label>
              <input type="checkbox" id="show-enchants" name="showItemEnchants" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="show-item-sets">Show which sets items belong to</label>
              <input type="checkbox" id="show-item-sets" name="showItemSets" class="toggle">
            </div>

            <div class="flex items-center justify-between">
              <label for="show-item-quality">Show item quality</label>
              <input type="checkbox" id="show-item-quality" name="showItemQuality" class="toggle">
            </div>

            <hr>

            <div class="flex items-center justify-between">
              <label for="debug-mode">Enable extensive debug output (developer mode)</label>
              <input type="checkbox" id="debug-mode" name="debugMode" class="toggle">
            </div>

            <br/>
            <br/>

            <button type="submit" id="save-tracker-settings">
              Save settings
            </button>
          </form>
        </div>
      </div>
    `);

    // load from storage (or use defaults) and then pre-check all checkboxes with the corresponding value
    settings.loadFromStorage();
    settings.setCheckboxValues(settings.configuration);

    const openModal = () => $("#elnaeth-modal-overlay").show();

    const closeModal = () => $("#elnaeth-modal-overlay").hide();

    // pressing escape also closes it
    $(document).on("keydown", function (e) {
      if (e.key === "Escape") closeModal();
    });

    // clicking outside the middle part closest it too
    $("div#elnaeth-modal-overlay").on("click", function (e) {
      if ($(e.target).closest("div#tracker-modal").length === 0) closeModal();
    });

    $("#elnaeth-settings-button").on("click", () => {
      openModal();
    });

    $("#close-tracker-modal").on("click", () => {
      closeModal();
    });

    // save all and close modal after
    $("#save-tracker-settings").on("click", (e) => {
      e.preventDefault();
      settings.saveToStorage();
      closeModal();
    });

    // save all settings upon toggle of a checkbox, but keep modal open
    $('#settings-form input[type="checkbox"]').on("change", settings.saveToStorage);
  },
};

const logger = {
  __getDate: () => {
    return new Date().toLocaleString("nl-NL");
  },

  info: (...params) => {
    console.info(logger.__getDate(), ...params);
  },

  debug: (...params) => {
    if (settings.configuration.debugMode) console.info(logger.__getDate(), ...params);
  },

  warn: (...params) => {
    console.warn(logger.__getDate(), ...params);
  },
};

const statTracker = {
  __localStorageKey: "elnaeth-stats-history",

  // keep track of found stats in a nicer format for the visible stat tracker
  log: [],

  // keep track of player gained stats in absolute values
  internalStore: null,

  getStatByName: (partialName) => {
    return statTracker.internalStore.TrackedStats.find((stat) => stat.definition.name.toLowerCase().includes(partialName.toLowerCase()));
  },

  reset: () => {
    statTracker.log = [];
    statTracker.internalStore = statTracker.cleanStats();
    logger.debug("[stats] Stat tracker has been reset");
    statTracker.saveToStorage();
    statTracker.parseTick();
  },

  cleanStats: () => ({
    StartTime: new Date(),
    EndTime: new Date(),

    TotalStats: 0,

    TrackedStats: [
      // base stats
      { definition: BoostTypes.STAT_INTELLECT, start: 0, current: 0, gained: 0, mastery: false },
      { definition: BoostTypes.STAT_STAMINA, start: 0, current: 0, gained: 0, mastery: false },
      { definition: BoostTypes.STAT_SPIRIT, start: 0, current: 0, gained: 0, mastery: false },
      { definition: BoostTypes.STAT_FOCUS, start: 0, current: 0, gained: 0, mastery: false },
      { definition: BoostTypes.STAT_MANA, start: 0, current: 0, gained: 0, mastery: false },

      // masteries
      { definition: BoostTypes.WATER_MASTERY, start: 0, current: 0, gained: 0, mastery: true, active: false },
      { definition: BoostTypes.FIRE_MASTERY, start: 0, current: 0, gained: 0, mastery: true, active: false },
      { definition: BoostTypes.NATURE_MASTERY, start: 0, current: 0, gained: 0, mastery: true, active: false },
    ],
  }),

  loadFromStorage: () => {
    statTracker.internalStore = statTracker.cleanStats();

    // retrieve from storage
    const loadedStats = localStorage.getItem(statTracker.__localStorageKey);
    if (loadedStats) {
      try {
        const parsed = JSON.parse(loadedStats);
        statTracker.internalStore = parsed;
        statTracker.internalStore.StartTime = new Date(parsed.StartTime);
        statTracker.internalStore.EndTime = new Date(parsed.EndTime);
      } catch (error) {
        console.error("[stats] Could not parse loot from localstorage:", error);
      }
    }

    logger.debug("[stats] Loaded from storage");
  },

  saveToStorage: () => {
    const json = JSON.stringify(statTracker.internalStore);
    localStorage.setItem(statTracker.__localStorageKey, json);
    logger.debug("[stats] Saved to storage");
  },

  // parse base stat values from game memory
  parseTick: () => {
    if (!statTracker.internalStore) statTracker.loadFromStorage();
    const stats = statTracker.internalStore;

    // each iteration, set the end time to right now
    stats.EndTime = new Date();

    const player = manarion.player;
    if (!player) return;

    // find the spell type the player is currently using and set that as active
    const currentMastery = stats.TrackedStats.find((x) => x.definition.name.toLowerCase().includes(player.MagicType));
    currentMastery.active = true;

    const playerBoosts = manarion.player.Boosts;
    if (!playerBoosts) return;

    // loop over all keys, which are the ID of the boost
    for (const boostID in playerBoosts) {
      const trackedStat = stats.TrackedStats.find((x) => x.definition.id === parseInt(boostID));
      if (!trackedStat) continue;

      const currentBoost = playerBoosts[boostID];

      // if the current boost is 0, we don't want to track it, possibly this happens
      // when the game + extension have loaded before the websocket has let us know our stats
      if (!currentBoost) return;

      // check if this is the first iteration
      if (!trackedStat.current || !stats.StartTime) {
        trackedStat.start = currentBoost;
        trackedStat.current = currentBoost;
        trackedStat.gained = 0;
      } else {
        // first calculate the difference, then set the new value for next iteration
        let gained = currentBoost - trackedStat.current;
        trackedStat.current = currentBoost;

        // before updating the tracked gains, see if we gained this iteration and log it
        if (gained > 0) {
          trackedStat.gained += gained;

          // do not add to total stats if this is a mastery stat, and if it IS mastery, change the format for the statlog
          if (!trackedStat.mastery) stats.TotalStats += gained;
          else gained = (gained / 100).toFixed(2) + "%";

          // prepare an entry for the stat log that shows individual stat drops
          const statLogEntry = {
            line: `+${gained} ${trackedStat.definition.name}`,
            timestamp: new Date().toLocaleTimeString("en-US", {
              hour: "2-digit",
              minute: "2-digit",
              second: "2-digit",
              hour12: false,
            }),
          };

          // prepend this
          statTracker.log.unshift(statLogEntry);

          // remove the oldest entries, keeping the log to a maximum of 200 entries
          if (statTracker.log.length > 200) {
            statTracker.log.length = 200;
          }
        }
      }
    }

    // this might be the first time we're tracking, or it has been reset manually by the player,
    // so start tracking from this point onwards
    if (!stats.StartTime) stats.StartTime = new Date();

    statTracker.internalStore = stats;
    statTracker.saveToStorage(stats);
  },
};

const lootTracker = {
  __localStorageKey: "elnaeth-item-drops",

  // keep track of loots
  internalStore: {
    first: new Date(),
    last: new Date(),
    elapsedSeconds: 0,
    items: [],
  },

  // TODO make this configurable
  // we do not track literally all items, but it's close
  trackedItems: [
    ItemTypes.MANA_DUST,
    ItemTypes.CODEX,
    ItemTypes.ELEMENTAL_SHARDS,

    ItemTypes.WOOD,
    ItemTypes.FISH,
    ItemTypes.IRON,

    ItemTypes.TOME_OF_FIRE,
    ItemTypes.TOME_OF_WATER,
    ItemTypes.TOME_OF_NATURE,

    ItemTypes.TOME_OF_MANA_SHIELD,

    ItemTypes.ORB_OF_CHAOS,
    ItemTypes.ORB_OF_POWER,
    ItemTypes.ORB_OF_DIVINITY,

    ItemTypes.ENCHANT_FIRE_RESISTANCE,
    ItemTypes.ENCHANT_WATER_RESISTANCE,
    ItemTypes.ENCHANT_NATURE_RESISTANCE,
    ItemTypes.ENCHANT_INFERNO,
    ItemTypes.ENCHANT_TIDAL_WRATH,
    ItemTypes.ENCHANT_WILDHEART,
    ItemTypes.ENCHANT_INSIGHT,
    ItemTypes.ENCHANT_BOUNTIFUL_HARVEST,
    ItemTypes.ENCHANT_PROSPERITY,
    ItemTypes.ENCHANT_FORTUNE,
    ItemTypes.ENCHANT_GROWTH,
    ItemTypes.ENCHANT_VITALITY,

    ItemTypes.IRONBARK,
    ItemTypes.ASBESTOS,
    ItemTypes.FISH_SCALES,

    ItemTypes.WATER_ESSENCE,
    ItemTypes.NATURE_ESSENCE,
    ItemTypes.FIRE_ESSENCE,

    ItemTypes.REAGENT_ELDERWOOD,
    ItemTypes.REAGENT_LODESTONE,
    ItemTypes.REAGENT_WHITE_PEARL,
    ItemTypes.REAGENT_FOUR_LEAF_CLOVER,
    ItemTypes.REAGENT_ENCHANTED_DROPLET,
    ItemTypes.REAGENT_INFERNAL_HEART,

    ItemTypes.SAGEROOT,
    ItemTypes.BLOOMWELL,
    ItemTypes.SUNPETAL,
  ],

  getCurrentState: () => {
    return lootTracker.internalStore;
  },

  loadFromStorage: () => {
    const stored = localStorage.getItem(lootTracker.__localStorageKey);
    if (stored) {
      try {
        const parsed = JSON.parse(stored);
        if (parsed.first) parsed.first = new Date(parsed.first);
        if (parsed.last) parsed.last = new Date(parsed.last);
        lootTracker.internalStore.first = parsed.first;
        lootTracker.internalStore.last = parsed.last;
        lootTracker.internalStore.elapsedSeconds = parsed.elapsedSeconds;
        lootTracker.internalStore.items = parsed.items || [];
      } catch (error) {
        console.error("[loot] Could not parse loot from localstorage:", error);
      }
      logger.debug("[loot] Loaded from storage");
    }
  },

  saveToStorage: () => {
    const json = JSON.stringify(lootTracker.internalStore);
    localStorage.setItem(lootTracker.__localStorageKey, json);
    logger.debug("[loot] Saved to storage");
  },

  reset: () => {
    lootTracker.internalStore = {
      first: new Date(),
      last: new Date(),
      elapsedSeconds: 0,
      items: [],
    };

    logger.debug("[loot] Reset internal storage");

    lootTracker.saveToStorage();
    lootTracker.parseTick();
  },

  resetSingleItem: (itemID) => {
    lootTracker.internalStore.items = lootTracker.internalStore.items.filter((i) => i.id !== itemID);
    lootTracker.saveToStorage();

    logger.debug(`[loot] Removed item with ID ${itemID} from internal storage`);
  },

  // keep track of loot results for each gather or battle
  parseTick: () => {
    if (!lootTracker.internalStore.elapsedSeconds) lootTracker.loadFromStorage();

    // now that we know we've got either a fresh set of lootOutput, or a reloaded one from storage
    // we can set the last known date to right now, since we're tracking from start to end
    lootTracker.internalStore.last = new Date();

    if (utils.isBattling()) {
      const lastBattle = manarion.battle;
      if (!lastBattle) return;

      lootTracker.parseLootResults(lastBattle.Loot);

      // TODO also track lastBattle.Items for gear drops
    }

    if (utils.isGathering()) {
      const lastGather = manarion.gather;
      if (!lastGather) return;

      lootTracker.parseLootResults(lastGather.Loot);

      // TODO also track lastGather.Items for gear drops
    }

    // calculate elapsed time
    if (lootTracker.internalStore.first && lootTracker.internalStore.last) {
      lootTracker.internalStore.elapsedSeconds = (lootTracker.internalStore.last - lootTracker.internalStore.first) / 1000;
    }

    // save to localStorage for next iteration
    lootTracker.saveToStorage();
  },

  parseLootResults: (latestLoot) => {
    if (!latestLoot) return;

    const lootIDs = Object.keys(latestLoot);
    for (const lootID of lootIDs) {
      const lootDefinition = lootTracker.trackedItems.find((definition) => definition.id === parseInt(lootID));
      if (!lootDefinition) continue; // this is not a tracked loot type

      // we could let the number util do it instead and keep this pristine,
      // but the accuracy on the floats is just so insanely high that this saves space
      const amount = parseInt(latestLoot[lootID]);

      // find the item in the internal store
      const existingEntry = lootTracker.internalStore.items.find((entry) => entry.id === lootDefinition.id);
      if (!existingEntry) {
        // create new entry with this amount
        lootTracker.internalStore.items.push({
          id: lootDefinition.id,
          name: lootDefinition.name,
          rarity: lootDefinition.rarity,
          amount: amount,
        });
      } else {
        // add found amount to existing entry
        existingEntry.amount += amount;
      }
    }
  },
};

const shardTracker = {
  // parse the entire content of the loot tracker for all shard entries and calculate rates
  parse: () => {
    const output = {
      first: null,
      last: new Date(), // the last drop of the day is always statically taken as current system time for more accuracy

      highestFound: 0,
      lowestFound: 0,

      elapsedSeconds: 0,
      total: 0,
    };

    // loop through game loot tracker
    for (const lootDrop of manarion.lootTracker.entries) {
      // regardless of the loot, always set the first found loot to the earliest parsed loot
      const date = new Date(lootDrop.Timestamp * 1000);
      if (date < output.first || !output.first) {
        output.first = date;
      }

      // skip if not a shard drop
      if (lootDrop.LootID !== ItemTypes.ELEMENTAL_SHARDS.id) continue;

      const amount = parseInt(lootDrop.Amount);

      // keep track of highest found amount of shards
      if (amount > output.highestFound) output.highestFound = amount;

      // also keep track of lowest found amount of shards
      if (output.lowestFound === 0 || amount < output.lowestFound) output.lowestFound = amount;

      output.total += amount;
    }

    // calculate the time between first and last tracked shard drop
    if (output.first && output.last) {
      output.elapsedSeconds = (output.last - output.first) / 1000;
    }

    return output;
  },
};

const questTracker = {
  getQuestTime: () => {
    if (utils.isBattling()) {
      const battleProgress = manarion.player.BattleQuestProgress;
      const battleQuestGoal = manarion.player.BattleQuestCompleted;

      const remaining = battleQuestGoal - battleProgress;
      const seconds = remaining * 3;
      return utils.formatTime(seconds / 60);
    }

    if (utils.isGathering()) {
      const gatherProgress = manarion.player.GatherQuestProgress;
      const gatherQuestGoal = manarion.player.GatherQuestCompleted;

      const remaining = gatherQuestGoal - gatherProgress;
      const seconds = remaining * 3;
      return utils.formatTime(seconds / 60);
    }

    // weird fallback but ok
    return utils.formatTime(0);
  },
};

const levelTracker = {
  // calculates time to next level
  getTimeNextLevel: () => {
    if (utils.isBattling()) {
      const current = manarion.player.Experience;
      const next = manarion.player.ExperienceToLevel;

      return {
        remaining: next - current,
        remainingPercent: (((next - current) / next) * 100).toFixed(2),
      };
    }

    if (utils.isGathering()) {
      let current;
      let next;
      switch (manarion.player.ActionType) {
        case "mining":
          current = manarion.player.MiningExperience;
          next = manarion.player.MiningExperienceToLevel;
          break;

        case "fishing":
          current = manarion.player.FishingExperience;
          next = manarion.player.FishingExperienceToLevel;
          break;

        case "woodcutting":
          current = manarion.player.WoodcuttingExperience;
          next = manarion.player.WoodcuttingExperienceToLevel;
          break;
      }

      return {
        remaining: next - current,
        remainingPercent: (((next - current) / next) * 100).toFixed(2),
      };
    }
  },

  getLevelsPerHour: () => {
    if (!globals.lastGainedXP) return 0;

    switch (manarion.player.ActionType) {
      case "battle":
        return (globals.lastGainedXP * 1200) / manarion.player.ExperienceToLevel;

      case "mining":
        return (globals.lastGainedXP * 1200) / manarion.player.MiningExperienceToLevel;

      case "fishing":
        return (globals.lastGainedXP * 1200) / manarion.player.FishingExperienceToLevel;

      case "woodcutting":
        return (globals.lastGainedXP * 1200) / manarion.player.WoodcuttingExperienceToLevel;
    }
  },

  parseBattleTick: () => {
    globals.lastGainedXP = 0;
    globals.lastGainedDust = 0;
    globals.lastGainedResource = 0;

    if (!manarion.battle) return;
    const lastBattle = manarion.battle;

    globals.lastGainedXP = lastBattle.ExperienceGained ? parseInt(lastBattle.ExperienceGained) : 0;
    globals.lastGainedDust = lastBattle.Loot ? parseInt(lastBattle.Loot[ItemTypes.MANA_DUST.id]) : 0;
  },

  parseGatherTick: () => {
    globals.lastGainedXP = 0;
    globals.lastGainedDust = 0;
    globals.lastGainedResource = 0;

    if (!manarion.gather) return;
    const lastGather = manarion.gather;

    globals.lastGainedXP = lastGather.ExperienceGained ? parseFloat(lastGather.ExperienceGained) : 0;

    switch (manarion.player.ActionType) {
      case "mining":
        globals.lastGainedResource = lastGather.Loot ? parseFloat(lastGather.Loot[ItemTypes.IRON.id]) : 0;
        break;

      case "fishing":
        globals.lastGainedResource = lastGather.Loot ? parseFloat(lastGather.Loot[ItemTypes.FISH.id]) : 0;
        break;

      case "woodcutting":
        globals.lastGainedResource = lastGather.Loot ? parseFloat(lastGather.Loot[ItemTypes.WOOD.id]) : 0;
        break;
    }
  },
};

const itemQOL = {
  // the codex specific boosts on items, their main property
  shownCodexBoosts: [
    BoostTypes.BATTLE_EXPERIENCE_BOOST,
    BoostTypes.MANA_DUST_BOOST,
    BoostTypes.ELEMENTAL_SHARD_BOOST,
    BoostTypes.STAT_DROP,
    BoostTypes.BASE_RESOURCE_AMOUNT,
    BoostTypes.ACTIONS,
  ],

  // all possible suffixes on items, also shown
  shownShardBoosts: [
    BoostTypes.DAMAGE,
    BoostTypes.MULTICAST,
    BoostTypes.CRIT_CHANCE,
    BoostTypes.CRIT_DAMAGE,
    BoostTypes.HASTE,
    BoostTypes.HEALTH,
    BoostTypes.WARD,
    BoostTypes.FOCUS,
    BoostTypes.MANA,
    BoostTypes.OVERLOAD,
    BoostTypes.TIME_DILATION,
  ],

  shownItemEnchants: [
    { slot: 1, battler: true, gatherer: false, value: BoostTypes.INFERNO_VALUE, maxRank: BoostTypes.ENCHANT_INFERNO_RANK, name: "Inferno" },
    { slot: 1, battler: true, gatherer: false, value: BoostTypes.TIDAL_WRATH_VALUE, maxRank: BoostTypes.ENCHANT_TIDAL_WRATH_RANK, name: "Tidal Wrath" },
    { slot: 1, battler: true, gatherer: false, value: BoostTypes.WILDHEART_VALUE, maxRank: BoostTypes.ENCHANT_WILDHEART_RANK, name: "Wildheart" },

    { slot: 2, battler: true, gatherer: false, value: BoostTypes.BASE_MANA_DUST, maxRank: BoostTypes.ENCHANT_PROSPERITY_RANK, name: "Prosperity" },
    { slot: 3, battler: true, gatherer: true, value: BoostTypes.MULTISTAT, maxRank: BoostTypes.ENCHANT_GROWTH_RANK, name: "Growth" },
    { slot: 4, battler: true, gatherer: true, value: BoostTypes.DROP_BOOST, maxRank: BoostTypes.ENCHANT_FORTUNE_RANK, name: "Fortune" },
    { slot: 5, battler: true, gatherer: true, value: BoostTypes.BASE_EXPERIENCE, maxRank: BoostTypes.ENCHANT_INSIGHT_RANK, name: "Insight" },

    { slot: 6, battler: true, gatherer: false, value: BoostTypes.FIRE_RESISTANCE_VALUE, maxRank: BoostTypes.ENCHANT_FIRE_RESISTANCE_RANK, name: "Fire res" },
    { slot: 6, battler: true, gatherer: false, value: BoostTypes.WATER_RESISTANCE_VALUE, maxRank: BoostTypes.ENCHANT_WATER_RESISTANCE_RANK, name: "Water res" },
    { slot: 6, battler: true, gatherer: false, value: BoostTypes.NATURE_RESISTANCE_VALUE, maxRank: BoostTypes.ENCHANT_NATURE_RESISTANCE_RANK, name: "Nature res" },

    { slot: 7, battler: false, gatherer: true, value: BoostTypes.BASE_RESOURCE, maxRank: BoostTypes.ENCHANT_BOUNTIFUL_HARVEST_RANK, name: "Bountiful Harvest" },
    { slot: 8, battler: true, gatherer: false, value: BoostTypes.VITALITY_VALUE, maxRank: BoostTypes.ENCHANT_VITALITY_RANK, name: "Vitality" },
  ],

  // construct item selectors based on settings
  getItemSelectors: () => {
    const itemSelectors = [];
    if (settings.configuration.parseRareItems) itemSelectors.push(".rarity-rare");
    if (settings.configuration.parseEpicItems) itemSelectors.push(".rarity-epic");
    if (settings.configuration.parseLegendaryItems) itemSelectors.push(".rarity-legendary");
    return itemSelectors.join(", ");
  },

  // processes the current players own inventory page
  processPrivateInventoryPage: () => {
    // only trigger on the inventory page
    if (!window.location.pathname.includes("/inventory")) return;

    // check if game has loaded all the data we need
    if (!manarion || !manarion.player || !manarion.inventory || !manarion.equipmentSets) return;

    // try to locate the inventory screen
    const playerInventory = document.querySelector("main > div.space-y-4");
    if (!playerInventory) {
      logger.warn("Inventory window not found, cannot parse items");
      return;
    }

    itemQOL.processItems(playerInventory, manarion.inventory.items, true, manarion.player.ActionType);

    // console.log("test");
    // $(playerInventory).before('<div class="bg-gray-200 p-2">Extra info</div>');
    // console.log("test 2");
  },

  processProfilePage: () => {
    // only trigger on profile pages
    if (!window.location.pathname.includes("/profile")) return;

    // check if game has loaded all the data we need
    if (!manarion || !manarion.player || !manarion.profile) return;

    // try to locate the profile screen items
    const profileInventory = document.querySelector("main div[data-slot=card-content] div.space-y-1");
    if (!profileInventory) {
      logger.warn("Profile window not found, cannot parse items");
      return;
    }

    itemQOL.processItems(profileInventory, manarion.profile.Equipment, false, manarion.profile.ActionType);
  },

  // does the heavy lifting of processing items in the targeted area
  processItems: (searchArea, itemsSource, includeItemSets = false, currentActivity = "battle") => {
    const currentEnchantCap = utils.calculateEnchantingCap();

    // because a user can actually disable all item rarities via settings, we need this check
    const selector = itemQOL.getItemSelectors();
    if (!selector) return;

    // look for either equipped or items in the search area
    const items = searchArea.querySelectorAll(selector);
    items.forEach((item) => {
      if (item.classList.contains("item-processed")) return;

      // pass over it once, so we don't add the set name multiple times
      item.classList.add("item-processed");

      // find the item name, remove the brackets, and then look for it in the game's inventory
      const itemName = item.textContent.trim().replace("[", "").replace("]", "");
      const foundItem = itemsSource.find((item) => item.Name === itemName);

      // couldn't find it, might be a loot item like orbs or formulas etc
      if (!foundItem) return;

      // create a container to hold all the various texts we want to show
      const container = document.createElement("div");
      container.className = "item-qol-container text-sm";
      container.style.marginBottom = "10px";
      item.insertAdjacentElement("afterend", container);

      // keep track of whether we found any enchants for this item
      let hasEnchants = false;

      // show the main codex boost for the item, as well as quality and any enchants
      const itemBoosts = Object.keys(foundItem.Boosts);
      itemBoosts.forEach((itemBoost) => {
        const itemBoostID = parseInt(itemBoost);

        // show codex boosts that show up on gear
        if (settings.configuration.showItemCodexBoosts) {
          const codexBoost = itemQOL.shownCodexBoosts.find((boost) => boost.id === itemBoostID);
          if (codexBoost) {
            const boostSpan = document.createElement("span");
            boostSpan.style.marginRight = "5px";
            boostSpan.title = codexBoost.name;

            let infusedBoost = Math.floor((1 + (foundItem.Infusions * 5) / 100) * foundItem.Boosts[itemBoostID]);
            if (codexBoost === BoostTypes.BASE_RESOURCE_AMOUNT) {
              infusedBoost = (infusedBoost / 100).toFixed(2);
              boostSpan.textContent = `${codexBoost.short}: +${infusedBoost}`;
            } else if (codexBoost === BoostTypes.ACTIONS) {
              boostSpan.textContent = `${codexBoost.short}: +${infusedBoost}`;
            } else {
              boostSpan.textContent = `${codexBoost.short}: ${infusedBoost}%`;
            }

            container.insertBefore(boostSpan, container.firstChild);
          }
        }

        // show shard boosts that show up on gear
        if (settings.configuration.showItemShardBoosts) {
          const shardBoost = itemQOL.shownShardBoosts.find((boost) => boost.id === itemBoostID);
          if (shardBoost) {
            const infusedBoost = (1 + (foundItem.Infusions * 5) / 100) * (foundItem.Boosts[itemBoostID] * 0.02);

            const boostSpan = document.createElement("span");
            boostSpan.style.marginRight = "5px";
            boostSpan.title = shardBoost.name;
            boostSpan.textContent = `${shardBoost.short}: ${infusedBoost.toFixed(2)}%`;
            container.appendChild(boostSpan);
          }
        }

        // show enchantments on the item (and how close they are to your max rank)
        if (settings.configuration.showItemEnchants) {
          const enchant = itemQOL.shownItemEnchants.find((enchant) => enchant.value.id === itemBoostID);
          if (enchant) {
            hasEnchants = true;
            const currentValue = foundItem.Boosts[itemBoostID];
            const yourRank = manarion.player.Boosts[enchant.maxRank.id];
            const isCapped = currentValue >= currentEnchantCap;

            // skip if enchant is capped to the current gamewide max
            if (isCapped) return;

            // see if we can personally still improve it
            const isPersonalCapped = currentValue >= yourRank;

            const enchantSpan = document.createElement("span");
            enchantSpan.style.marginRight = "5px";
            enchantSpan.style.color = isPersonalCapped ? "green" : "red"; // green if capped, red if not
            enchantSpan.textContent = ` ${currentValue} / ${yourRank} (${currentEnchantCap}) ${enchant.name.toLowerCase()} `;
            enchantSpan.title = isPersonalCapped
              ? `You can't improve this enchant further, but other enchanters probably can`
              : `You can still improve this enchantment to rank ${yourRank}`;
            container.insertBefore(enchantSpan, container.firstChild);
          }
        }
      });

      // show quality of the item
      if (settings.configuration.showItemQuality) {
        const qualitySpan = document.createElement("span");
        qualitySpan.style.marginRight = "5px";
        qualitySpan.textContent = `Q: ${(foundItem.Quality * 100).toFixed(2)}%`;
        qualitySpan.title = "Item Quality";
        container.appendChild(qualitySpan);
      }

      // if no enchant was found, first check if we'd even want to put any on there
      if (settings.configuration.showItemEnchants && !hasEnchants) {
        const isBattler = utils.isBattleActivity(currentActivity);
        const isGatherer = utils.isGatherActivity(currentActivity);

        const applicableEnchants = itemQOL.shownItemEnchants.filter(
          (enchant) => enchant.slot === foundItem.Slot && !foundItem.Boosts[enchant.value.id] && (enchant.battler === isBattler || enchant.gatherer === isGatherer)
        );
        if (applicableEnchants && applicableEnchants.length > 0) {
          const missingEnchant = document.createElement("span");
          missingEnchant.className = "text-red-500";
          missingEnchant.style.marginRight = "5px";
          missingEnchant.textContent = "Missing enchant";
          container.insertBefore(missingEnchant, container.firstChild);
        }
      }

      // find all sets that contain this item and add the set name after the item
      if (settings.configuration.showItemSets && includeItemSets) {
        // this is a dirty hack that's needed because item sets apparently load later
        setTimeout(() => {
          manarion.equipmentSets.sets.forEach((set) => {
            const itemInSet = set.items.find((setItem) => setItem === foundItem.ID);
            if (!itemInSet) return;

            const setNamesSpan = document.createElement("span");
            setNamesSpan.style.marginRight = "5px";
            setNamesSpan.style.color = "var(--input)";
            setNamesSpan.textContent = `<${set.name.toLowerCase()} set>`;
            container.appendChild(setNamesSpan);
          });
        }, 250);
      }
    });
  },

  removeContainers: () => {
    const processedItems = document.querySelectorAll(".item-processed");
    processedItems.forEach((item) => item.classList.remove("item-processed"));

    const qolContainers = document.querySelectorAll(".item-qol-container");
    qolContainers.forEach((container) => container.remove());
  },

  render: () => {
    // remove containers upon render to clear the way for the ones that are still enabled
    itemQOL.removeContainers();
    if (settings.configuration.enableItemQol) {
      // this is just base functionality
      itemQOL.processPrivateInventoryPage();

      // see if the user also wants these functions in profile pages
      if (settings.configuration.enableItemQolProfilePage) itemQOL.processProfilePage();
    }
  },

  initialize: () => {
    const targetNode = document.querySelector("main");
    if (!targetNode) {
      logger.warn("Main element not found, observer not initialized.");
      return;
    }

    const observerConfig = {
      childList: true,
      subtree: true,
      characterData: true,
      attributes: true,
    };

    const isItemMutation = (mutation) => {
      // same thing here, a user can select zero rarities so we need this failsafe
      const selectors = itemQOL.getItemSelectors();
      if (!selectors) return false;

      if (mutation.type === "childList") {
        const nodes = [...mutation.addedNodes, ...mutation.removedNodes];
        return nodes.some((node) => node.nodeType === 1 && (node.matches(selectors) || node.querySelector?.(selectors)));
      }

      if (mutation.type === "characterData") {
        const parent = mutation.target.parentElement;
        return parent?.matches(selectors);
      }

      if (mutation.type === "attributes") {
        return mutation.target.matches(selectors);
      }

      return false;
    };

    const observer = new MutationObserver((mutationsList) => {
      const shouldTrigger = mutationsList.some(isItemMutation);
      if (shouldTrigger) {
        observer.disconnect();
        try {
          itemQOL.render();
        } finally {
          observer.observe(targetNode, observerConfig);
        }
      }
    });

    observer.observe(targetNode, observerConfig);
  },
};

const ui = {
  // track ticks that have passed
  trackedTicks: 0,

  createMainTracker: (leftMenu) => {
    const tracker = document.createElement("div");
    tracker.innerHTML = `
      <div id="elnaeth-tracker" style="display: none;"></div>

      <hr width="100%" style="border-top: 1px solid var(--ring); margin: 2px 0 2px 0;" />

      <div class="grid grid-cols-4 gap-x-4 gap-y-1 p-2 my-1 text-sm lg:grid-cols-2" title="Kindly provided by Elnaeth. Tips appreciated!">
        <div class="flex col-span-2 justify-between"><span>Quest Timer:</span><span id="quest-timer">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Time to Level:</span><span id="xp-tnl">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span title="This space brought to you, and sponsored by, Autumn">Levels / Hr:</span><span id="levels-per-hour">Autumning...</span></div>
        
        <div title="If this stays at calculating, check if you're at 100% tax" class="flex col-span-2 justify-between"><span>XP / Hr:</span><span id="xp-rate">Calculating...</span></div>
        <div title="If this stays at calculating, check if you're at 100% tax" class="flex col-span-2 justify-between"><span>XP / Day:</span><span id="xp-day-rate">Calculating...</span></div>

        <div title="If this stays at calculating, check if you're at 100% tax" class="battle-specific flex col-span-2 justify-between"><span>Mana Dust / Hr:</span><span id="dust-rate">Calculating...</span></div>
        <div title="If this stays at calculating, check if you're at 100% tax" class="battle-specific flex col-span-2 justify-between"><span>Mana Dust / Day:</span><span id="dust-day-rate">Calculating...</span></div>
        
        <div title="If this stays at calculating, check if you're at 100% tax" class="gather-specific flex col-span-2 justify-between"><span>Resource / Hr:</span><span id="resource-rate">Calculating...</span></div>
        <div title="If this stays at calculating, check if you're at 100% tax" class="gather-specific flex col-span-2 justify-between"><span>Resource / Day:</span><span id="resource-day-rate">Calculating...</span></div>
        
        <div class="flex col-span-2 justify-between"><span>Shards / Hr:</span><span id="shard-rate">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Shards / Day:</span><span id="shard-day-rate">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Shards range:</span><span id="shard-drop-range">Calculating...</span></div>
      </div>

      <hr width="100%" style="border-top: 1px solid var(--ring); margin: 2px 0 2px 0;" class="elnaeth-stats-tracker" />

      <div class="elnaeth-stats-tracker grid grid-cols-4 gap-x-4 gap-y-1 p-2 my-1 text-sm lg:grid-cols-2" title="Kindly provided by Elnaeth. Tips appreciated!">
        <div class="flex col-span-2 justify-between"><span>Intellect gained:</span><span id="intellect-gained">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Stamina gained:</span><span id="stamina-gained">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Spirit gained:</span><span id="spirit-gained">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Focus gained:</span><span id="focus-gained">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Mana gained:</span><span id="mana-gained">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Mastery gained:</span><span id="mastery-gained">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Tracked time:</span><span id="tracked-time">Calculating...</span></div>
        <div class="flex col-span-2 justify-between"><span>Total stats:</span><span id="stats-hr">Calculating...</span></div>
        
        <div class="flex col-span-4 lg:col-span-2 justify-between text-center">
          <div></div>
          <button id="reset-stat-tracker" class="m-0 p-1 col-span-2 bg-red-500 text-sm text-white rounded cursor-pointer">Reset</button>
          <div></div>
        </div>
      </div>`;

    leftMenu.insertAdjacentElement("afterend", tracker);

    document.getElementById("reset-stat-tracker").addEventListener("click", () => {
      // Reset the stat tracker and re-render ui immediately
      statTracker.reset();
      ui.render();
    });

    logger.debug("[ui] Main tracker injected");
  },

  createStatsTracker: (lootTracker) => {
    // Create the stat tracker header
    const header = document.createElement("div");
    header.className = "elnaeth-stats-log text-center text-lg";
    header.title = "Kindly provided by Elnaeth. Tips appreciated!";
    header.textContent = "Stat Drop Tracker";

    // add the header to the DOM, right before the loot tracker
    lootTracker.previousSibling.insertAdjacentElement("beforebegin", header);

    // Create the stats log container for the actual lines
    const statsLogContainer = document.createElement("div");
    statsLogContainer.id = "elnaeth-stats-log";
    statsLogContainer.className = "elnaeth-stats-log scrollbar-none hover:scrollbar-thin scrollbar-track-transparent h-15 max-h-15 grow-1 overflow-x-hidden overflow-y-auto";
    statsLogContainer.style.maxHeight = "200px"; // extra control for max height

    // add the stats log container to the DOM, right after the header
    header.insertAdjacentElement("afterend", statsLogContainer);

    logger.debug("[ui] Stats tracker injected");
  },

  createItemsTracker: (lootTrackerElement) => {
    // Create the item drop tracker header
    const header = document.createElement("div");
    header.className = "elnaeth-items-log text-center text-lg";
    header.title = "Kindly provided by Elnaeth. Tips appreciated!";
    header.textContent = "Item Drop Tracker";

    const resetButton = document.createElement("button");
    resetButton.textContent = "Reset";
    resetButton.className = "elnaeth-items-log m-0 p-1 bg-red-500 text-sm text-white rounded cursor-pointer";
    resetButton.addEventListener("click", () => {
      // Reset the item drops and re-render ui immediately
      lootTracker.reset();
      ui.render();
    });

    header.appendChild(document.createElement("br"));
    header.appendChild(resetButton);

    // add the header to the DOM, right before the loot tracker
    lootTrackerElement.previousSibling.insertAdjacentElement("beforebegin", header);

    // Create the items log container
    const itemsLogContainer = document.createElement("div");
    itemsLogContainer.id = "elnaeth-items-log";
    itemsLogContainer.className = "elnaeth-items-log scrollbar-none hover:scrollbar-thin scrollbar-track-transparent h-15 max-h-15 grow-1 overflow-x-hidden overflow-y-auto";
    itemsLogContainer.style.maxHeight = "200px";

    header.insertAdjacentElement("afterend", itemsLogContainer);

    logger.debug("[ui] Items tracker injected");
  },

  createTrackerContainers: () => {
    // Look for the entire menu on the left of the screen
    const leftMenu = document.querySelector("div.grid.grid-cols-4");
    if (!leftMenu) return;

    // find the native game UI loot tracker
    const lootTrackerElement = document.querySelector("div.scrollbar-none.scrollbar-track-transparent.h-60.grow-1.overflow-x-hidden.overflow-y-auto");
    if (!lootTrackerElement) return;

    // add the main tracker container on the left menu
    let exists = document.getElementById("elnaeth-tracker");
    if (!exists) {
      ui.createMainTracker(leftMenu);
    }

    // create our stats tracker window above the loot tracker
    exists = document.getElementById("elnaeth-stats-log");
    if (!exists) {
      ui.createStatsTracker(lootTrackerElement);
    }

    // create our items tracker window above the loot tracker
    exists = document.getElementById("elnaeth-items-log");
    if (!exists) {
      ui.createItemsTracker(lootTrackerElement);
      document.getElementById("elnaeth-items-log").addEventListener("click", function (event) {
        if (event.target.matches("i.elnaeth-delete-row")) {
          const itemId = parseInt(event.target.dataset.itemId);
          lootTracker.resetSingleItem(itemId);
          ui.render();
        }
      });
    }
  },

  render: () => {
    // show/hide battler specific stats depending on if we're battling
    const battleContainers = document.getElementsByClassName("battle-specific");
    for (let container of battleContainers) {
      container.style.display = utils.isBattling() ? "flex" : "none";
    }

    // show/hide gatherer specific stats depending on if we're gathering
    const gatherContainers = document.getElementsByClassName("gather-specific");
    for (let container of gatherContainers) {
      container.style.display = utils.isGathering() ? "flex" : "none";
    }

    // show/hide the lower left stats tracker based on settings
    if (!settings.configuration.showStatsTracker) $(".elnaeth-stats-tracker").hide();
    else $(".elnaeth-stats-tracker").show();

    // show/hide the upper right stats log based on settings
    if (!settings.configuration.showStatsLog) $(".elnaeth-stats-log").hide();
    else $(".elnaeth-stats-log").show();

    // show/hide the upper right items log based on settings
    if (!settings.configuration.showLootTracker) $(".elnaeth-items-log").hide();
    else $(".elnaeth-items-log").show();

    // update stats section
    const logTracker = document.getElementById("elnaeth-stats-log");
    if (logTracker) {
      // clear the log tracker and re-render
      logTracker.innerHTML = "";

      statTracker.log.forEach((stat) => {
        const statDiv = document.createElement("div");
        statDiv.innerHTML = `
        <div class="max-w-full overflow-hidden align-middle text-sm text-ellipsis whitespace-nowrap">
             <span class="text-foreground/50 text-xs"> ${stat.timestamp} </span>
             <span class="rarity-uncommon"> ${stat.line} </span>
        </div>`;

        logTracker.appendChild(statDiv);
      });
    }

    // Calculate rates
    const xpPerHr = globals.lastGainedXP ? globals.lastGainedXP * 1200 : 0;
    const xpPerDay = xpPerHr * 24;

    const dustPerHr = globals.lastGainedDust ? globals.lastGainedDust * 1200 : 0;
    const dustPerDay = dustPerHr * 24;

    const resourcePerHr = globals.lastGainedResource ? globals.lastGainedResource * 1200 : 0;
    const resourcePerDay = resourcePerHr * 24;

    // Calculate time to next level
    const tnl = levelTracker.getTimeNextLevel();
    const minutesToLevel = xpPerHr > 0 && tnl ? (tnl.remaining / xpPerHr) * 60 : null;

    const levelsPerHour = levelTracker.getLevelsPerHour();

    // Calculate quest completion time
    const questTime = questTracker.getQuestTime();

    // Calculate percentage to next level
    let percentageToLevel = "N/A";
    if (tnl && xpPerHr > 0) {
      percentageToLevel = tnl.remainingPercent + "%";
    }

    // update level calculation and quest timer
    document.getElementById("xp-tnl").textContent = minutesToLevel ? `${utils.formatTime(minutesToLevel)} (${percentageToLevel})` : "Calculating...";
    document.getElementById("levels-per-hour").textContent = levelsPerHour ? levelsPerHour.toFixed(2) : "Autumning...";
    document.getElementById("levels-per-hour").title = "This space brought to you, and sponsored by, Autumn";
    document.getElementById("quest-timer").textContent = questTime ?? "N/A";

    // update xp, dust and resource rates
    document.getElementById("xp-rate").textContent = xpPerHr ? utils.formatNumber(xpPerHr) : "Calculating...";
    document.getElementById("xp-day-rate").textContent = xpPerDay ? utils.formatNumber(xpPerDay) : "Calculating...";

    document.getElementById("dust-rate").textContent = dustPerHr ? utils.formatNumber(dustPerHr) : "Calculating...";
    document.getElementById("dust-day-rate").textContent = dustPerDay ? utils.formatNumber(dustPerDay) : "Calculating...";

    document.getElementById("resource-rate").textContent = resourcePerHr ? utils.formatNumber(resourcePerHr) : "Calculating...";
    document.getElementById("resource-day-rate").textContent = resourcePerDay ? utils.formatNumber(resourcePerDay) : "Calculating...";

    // calculate stats per hour
    if (ui.trackedTicks > 0) {
      const stats = statTracker.internalStore;
      const secondsPassed = (stats.EndTime - stats.StartTime) / 1000 ?? 0;
      const hoursPassed = secondsPassed / 3600;
      const statsPerHour = stats.TotalStats / hoursPassed ?? 0;
      const tookTime = secondsPassed > 0 ? utils.formatTime(secondsPassed / 60) : "0h 0m";

      // show stats per hour and total
      document.getElementById("stats-hr").textContent = (stats.TotalStats ?? 0) + " (" + statsPerHour.toFixed(2) + " / hr)";
      document.getElementById("tracked-time").textContent = tookTime;

      // show shards per hour and total if we can
      const shards = shardTracker.parse();
      if (shards.elapsedSeconds > 0) {
        const shardsPerHour = shards.total / (shards.elapsedSeconds / 3600);
        const shardsPerDay = shards.total / (shards.elapsedSeconds / 86400);
        document.getElementById("shard-rate").textContent = utils.formatNumber(shardsPerHour);
        document.getElementById("shard-day-rate").textContent = utils.formatNumber(shardsPerDay);
        document.getElementById("shard-drop-range").textContent = `${utils.formatNumber(shards.lowestFound)} to ${utils.formatNumber(shards.highestFound)}`;
      }
    }

    // write out stat gains
    document.getElementById("intellect-gained").textContent = statTracker.getStatByName("intellect").gained;
    document.getElementById("stamina-gained").textContent = statTracker.getStatByName("stamina").gained;
    document.getElementById("spirit-gained").textContent = statTracker.getStatByName("spirit").gained;
    document.getElementById("focus-gained").textContent = statTracker.getStatByName("focus").gained;
    document.getElementById("mana-gained").textContent = statTracker.getStatByName("mana").gained;

    // write out current mastery gains
    if (statTracker.getStatByName("fire").active) document.getElementById("mastery-gained").textContent = statTracker.getStatByName("fire").gained / 100 + "%";
    if (statTracker.getStatByName("water").active) document.getElementById("mastery-gained").textContent = statTracker.getStatByName("water").gained / 100 + "%";
    if (statTracker.getStatByName("nature").active) document.getElementById("mastery-gained").textContent = statTracker.getStatByName("nature").gained / 100 + "%";

    // Render item drops in the item tracker container
    const itemsLog = document.getElementById("elnaeth-items-log");
    if (itemsLog) {
      itemsLog.innerHTML = "";

      const itemDrops = lootTracker.getCurrentState();

      // Calculate elapsed time for per-hour rate
      const elapsedHours = itemDrops.elapsedSeconds > 0 ? itemDrops.elapsedSeconds / 3600 : 1;

      // render the items, sorted by rarity, rarest first
      const sortedByRarity = Array.from(itemDrops.items)
        .sort((a, b) => utils.sortByRarity(a, b))
        .reverse();
      sortedByRarity.forEach((item) => {
        const perHour = (item.amount / elapsedHours).toFixed(2);
        const itemDiv = document.createElement("div");
        itemDiv.innerHTML = `
          <div class="max-w-full overflow-hidden align-middle text-sm text-ellipsis whitespace-nowrap">
            <i data-item-id="${item.id}" class="elnaeth-delete-row cursor-pointer text-xs">[-]</i>

            <span class="rarity-${item.rarity}">${item.name}:</span> <span>${utils.formatNumber(item.amount)}</span>
            <span class="text-foreground/50 text-xs">(${utils.formatNumber(perHour)}/HR)</span>
          </div>`;

        itemsLog.appendChild(itemDiv);
      });
    }

    ui.trackedTicks++;
  },

  condenseMainMenu: () => {
    document.querySelectorAll("div.grid.grid-cols-4").forEach((grid) => {
      if (grid.classList.contains("gap-y-2")) {
        grid.classList.remove("gap-y-2");
      }
    });

    logger.debug("[ui] Main menu condensed");
  },
};

(function () {
  function waitForGameReady(maxWaitMs = 10000) {
    return new Promise((resolve, reject) => {
      const interval = 100;
      let waited = 0;

      const check = () => {
        if (typeof manarion !== "undefined" && manarion.player && manarion.player.Boosts && manarion.inventory && manarion.equipmentSets) {
          resolve(true);
          return;
        }

        waited += interval;
        if (waited >= maxWaitMs) {
          reject("manarion not ready in time");
          return;
        }

        setTimeout(check, interval);
      };

      check();
    });
  }

  $(async () => {
    try {
      await waitForGameReady();

      // add the settings UI and load all settings
      settings.initialize();

      // make the left menu not use up as much vertical spacing
      ui.condenseMainMenu();

      // create the tracker HTML elements
      ui.createTrackerContainers();

      // start listening for item QoL events a little bit after page load
      itemQOL.initialize();

      let playerTotalActions = 0;
      const monitorTicks = () => {
        // get the current player total actions
        const remainingActions = manarion.player.Actions;
        if (remainingActions > 0 && remainingActions !== playerTotalActions) {
          logger.debug("[main] Tick processing");

          // this tick is now processed, save it for the next iteration
          playerTotalActions = remainingActions;

          try {
            // keep track of gains, like exp and dust, or incoming resources, for both action types
            if (utils.isBattling()) levelTracker.parseBattleTick();
            if (utils.isGathering()) levelTracker.parseGatherTick();

            // calculate stats for this tick
            statTracker.parseTick();

            // parse the latest action for any loot drops
            lootTracker.parseTick();
          } catch (error) {
            console.error("[main] Error during tick parsing:", error);
          }

          // finally, render UI
          ui.render();

          logger.debug("[main] Tick processed");
        }
      };

      // run tick monitor every 500ms
      setInterval(monitorTicks, 500);
    } catch (err) {
      console.warn("[main] Game never finished loading:", err);
    }
  });
})();