Pendoria+

Improve Pendoria with visual enhancements and statistics

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Pendoria+
// @description  Improve Pendoria with visual enhancements and statistics
// @namespace    http://pendoria.net/
// @version      0.6.2
// @author       Michael Owens (Xikeon)
// @match        *://pendoria.net/game*
// @match        *://www.pendoria.net/game*
// @grant        none
// @require      https://unpkg.com/[email protected]/dist/vue.min.js
// ==/UserScript==

(function () {
function __$styleInject(css, returnValue) {
  if (typeof document === 'undefined') {
    return returnValue;
  }
  css = css || '';
  var head = document.head || document.getElementsByTagName('head')[0];
  var style = document.createElement('style');
  style.type = 'text/css';
  head.appendChild(style);
  
  if (style.styleSheet){
    style.styleSheet.cssText = css;
  } else {
    style.appendChild(document.createTextNode(css));
  }
  return returnValue;
}

function __$strToBlobUri(str, mime, isBinary) {try {return window.URL.createObjectURL(new Blob([Uint8Array.from(str.split('').map(function(c) {return c.charCodeAt(0)}))], {type: mime}));} catch (e) {return "data:" + mime + (isBinary ? ";base64," : ",") + str;}}

let debug = false;

function log() {
  if (!debug) {
    return
  }

  console.log('[PendoriaPlus]', ...arguments);
}

function capitalize([first, ...rest]) {
  if (!first) {
    return ''
  }
  return first.toUpperCase() + rest.join('')
}

function guessType (setting) {
  let type = 'string';

  if (setting.type) {
    return setting.type
  }

  if ('options' in setting) {
    return 'select'
  }

  if ('constraint' in setting) {
    if ('min' in setting.constraint || 'max' in setting.constraint) {
      return 'number'
    }
  }

  if ('default' in setting && typeof setting.default === 'boolean') {
    return 'checkbox'
  }

  return type
}

function secondsToString (sec_num) {
  let hours   = Math.floor(sec_num / 3600);
  let minutes = Math.floor((sec_num - (hours * 3600)) / 60);
  let seconds = sec_num - (hours * 3600) - (minutes * 60);

  let str = '';
  if (hours > 0) {
    if (hours < 10) hours = '0' + hours;
    str += hours + 'h';
  }

  if (hours > 0 || minutes > 0) {
    if (minutes < 10) minutes = '0' + minutes;
    str += minutes + 'm';
  }

  if (seconds < 10) seconds = '0' + seconds;
  str += seconds + 's';

  return str
}

function ModuleSetting (setting) {
  const defaults = {
    type: guessType(setting),
    value: ('value' in setting ? setting.value : setting.default),
    toString () {
      return this.value || this.default
    }
  };

  let obj = Object.assign(defaults, setting);
  let oldValue = JSON.parse(JSON.stringify(obj.value));
  if ('onChange' in obj) {
    setInterval(() => {
      if (obj.value !== oldValue) {
        oldValue = JSON.parse(JSON.stringify(obj.value));
        obj.onChange(obj.value);
      }
    }, 10);
  }

  return obj
}

/*export class ModuleSetting {
  constructor (options) {
    const defaults = {
      type: this.guessType(options)
    }

    this.options = Object.assign({}, defaults, options)
  }

  get value() {
    return this.options.value
  }

  set value(value) {
    if (this.options.type === 'number') {
      this.options.value = parseInt(value, 10)
      return
    }

    this.options.value = value
  }

  guessType (options) {
    let type = 'string'

    if (options.type) {
      return options.type
    }

    if ('constraint' in options) {
      if ('min' in options.constraint || 'max' in options.constraint) {
        return 'number'
      }
    }

    if ('default' in options && typeof options.default === 'boolean') {
      return 'checkbox'
    }

    return type
  }

  toString () {
    return this.options.value
  }
}*/

/**
 * Hook onto jQuery.ajaxComplete and be able to attach events to ajax calls
 */

let AJAX_CALLBACKS = {};

var AjaxCallback = {
  init () {
    $(document).ajaxComplete((e, xhr, settings) => {
      let foundPath = Object.keys(AJAX_CALLBACKS).find(path => settings.url.match(path));
      if (!foundPath) {
        return
      }

      AJAX_CALLBACKS[foundPath].forEach(cb => {
        cb(e, xhr, settings);
      });
    });
  },

  on (path, fn) {
    if (!fn) {
      return
    }

    AJAX_CALLBACKS[path] = AJAX_CALLBACKS[path] || [];
    AJAX_CALLBACKS[path].push(fn);
  },

  off (path, fn) {
    if (!fn) {
      return
    }

    let cbs = AJAX_CALLBACKS[path];

    if (!cbs) {
      return
    }

    let i = cbs.findIndex(currentCallback => currentCallback === fn);

    if (i > -1) {
      AJAX_CALLBACKS[path].splice(i, 1);
      log('[AjaxCallback]', 'after splice', AJAX_CALLBACKS[path]);
    }
  }
};

// Needs a rewrite
let log$1 = (...args) => console.log(...args);
let $$1 = window.jQuery;
let inDepTarget = null;

let dotFromObject = function (obj, dotNotation) {
  return dotNotation.split('.').reduce((o,i) => {
    if (!(i in o)) {
      return ''
    }
    return o[i]
  }, obj)
};

let updateDotFromObject = function (obj, dotNotation, value) {
  var dots = dotNotation.split('.');
  var parentDataObject = dots.reduce((o, i, ci) => {
    if (ci === dots.length - 1) {
      return o
    }
    return o[i]
  }, obj);

  var lastdot = dots[dots.length - 1];
  parentDataObject[lastdot] = value;
};

let $template = function (input, ...values) {
  // compile html with tmp holders for nested nodes
  let strings = input;
  let html = '';
  let nodes = [];
  let varFilters = {};
  let lastuid = -1;

  if (typeof strings === String) {
    strings = strings.split('\n');
  }

  for (var i in strings) {
    html += strings[i];

    let value = values[i];
    if (value) {
      if (value instanceof Node || value instanceof jQuery) {
        html += `<div id="jQTpl-tmp-node-${i}"></div>`;
        nodes.push(i);
      } else {
        html += value;
      }
    }
  }

  // find variables
  html = html.replace(/{{(.*?)}}/g, function (m, key) {
    lastuid += 1;

    // save and strip filters
    let [cleanKey, ...filters] = key.split('|');

    if (filters) {
      varFilters[cleanKey] = varFilters[cleanKey] || {};
      varFilters[cleanKey][lastuid] = filters;
    }

    return `<span class="jQTpl-tmp-var-${cleanKey}" data-uid="${lastuid}"></span>`
  });

  // return function that will start the app
  return function ($data, $root) {
    let $dataFilters = {};
    if (typeof $data === 'object' && typeof $data._onChange !== 'function') {
      $dataFilters = $data.filters || {};
      $data = $data.scope;
    }

    $data = $data || null;
    $root = $root || $$1('#app');

    // add watcher for changes
    let $dataEls = {};
    let changeListeners = {};
    let showListeners = {};

    let applyFilters = function (value, datakey, uid) {
      let filters = varFilters[datakey][uid];

      if (Array.isArray(filters) && filters.length) {
        value = filters.reduce((acc, cur) => $dataFilters[cur](acc), value);
      }

      return value
    };

    if ($data) {
      $data._onChange((key, newValue, oldValue) => {
        // Update textNodes
        if (key in $dataEls) {
          $dataEls[key].forEach(node => {
            node.el.nodeValue = applyFilters(newValue, key, node.uid);
          });
        }

        // 2-way binding: from data to view
        if (key in changeListeners) {
          changeListeners[key].forEach(node => node.val(newValue));
        }

        // show/hide elements
        if (key in showListeners) {
          showListeners[key].forEach(node => node[0].toggle(node[1](newValue)));
        }
      });
    }

    let $doc = $$1(html);
    nodes.forEach((i) => {
      $doc.find(`#jQTpl-tmp-node-${i}`).replaceWith(values[i]);
    });

    $doc.find(`[class^=jQTpl-tmp-var-]`).each((k, el) => {
      let datakey = el.className.split('-').pop();
      let value = dotFromObject($data, datakey);
      let uid = el.getAttribute('data-uid');
      value = applyFilters(value, datakey, uid);
      let t = document.createTextNode(value);
      el.replaceWith(t);
      $dataEls[datakey] = $dataEls[datakey] || [];
      $dataEls[datakey].push({
        el: t,
        uid: uid
      });
    });

    let updatingRelatedRadios = false;
    $doc.tplModel = function (selector, model) {
      let cleanSelector = selector.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
      // log('[jQTpl]', 'cleanSelector', cleanSelector)
      let tplModelRegex = new RegExp('tplModel[\\s]*\\([\\s]*([\'"`])' + cleanSelector + '\\1[\s]*,[\s]*(.+?)\\)', 'g');
      let tplModelCall = tplModelRegex.exec(arguments.callee.caller.toString());

      if (!tplModelCall || tplModelCall.length < 3) {
        return this;
      }

      var dots = tplModelCall[2].split('.');
      dots.shift();
      var dataDotNotation = dots.join('.');
      log$1('[jQTpl]', 'get', dataDotNotation, 'in', $data);
      var value = dotFromObject($data, dataDotNotation);
      var $el = this.find(selector);
      // var $dataEl = $dataEls[dataDotNotation].el

      $el
        .val(value)
        .on('input change paste deselect', (e) => {
          if (e.target.type.includes('select') && e.type === 'input') {
            // Select boxes trigger input & event, ignore one of them
            return
          }

          if (updatingRelatedRadios) {
            $$1(e.target).prop('checked', false);
            return
          }

          let newValue = e.target.value;

          if (e.target.type === 'radio' && !updatingRelatedRadios) {
            // update other radios with same name
            let name = $el.attr('name');
            updatingRelatedRadios = true;
            // $(`[type="radio"][jq-model="${dataDotNotation}"]`).not(e.target).change()
            updatingRelatedRadios = false;
            newValue = $$1(e.target).prop('checked', true).val();
          }

          if (e.target.type === 'checkbox') {
            newValue = e.target.checked;
          }

          updateDotFromObject($data, dataDotNotation, newValue);
        }); // this doesn't work for dot notation of course

      if (['radio', 'checkbox'].includes($el[0].type)) {
        $el.prop('checked', value);
      } else {
        $el.val(value);
      }

      changeListeners[dataDotNotation] = changeListeners[dataDotNotation] || [];
      changeListeners[dataDotNotation].push($el);

      return this
    };

    $doc.tplShow = function (selector, model, validator) {
      if (!validator) {
        validator = (value) => value;
      }

      if (typeof model === 'undefined') {
        model = selector;
        selector = null;
      }

      let tplShowCall;
      if (selector) {
        let cleanSelector = selector.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
        let tplShowRegex = new RegExp('tplShow[\\s]*\\([\\s]*([\'"`])' + cleanSelector + '\\1[\\s]*,[\\s]*(.+)[\\s]*\\)', 'g');
        tplShowCall = tplShowRegex.exec(arguments.callee.caller.toString());
      }  else {
        let tplShowRegex = new RegExp('tplShow[\\s]*\\([\\s]*(.+)[\\s]*\\)', 'g');
        tplShowCall = tplShowRegex.exec(arguments.callee.caller.toString());
      }

      if (!tplShowCall || tplShowCall.length < 3) {
        return this;
      }

      let lastParams = tplShowCall[2].split(',');
      let m = lastParams.shift();

      let dots = m.split('.');
      dots.shift();
      let dataDotNotation = dots.join('.');
      let $el = (selector ? this.find(selector) : this);

      showListeners[dataDotNotation] = showListeners[dataDotNotation] || [];
      showListeners[dataDotNotation].push([$el, validator, model]);

      $el.toggle(validator(model));

      return this
    };

    $root.append($doc);

    return $doc
  }
};

let observable = function (obj) {
  let listeners = [];

  const handler = function (root) {
    root = root || '';
    if (root) root += '.';

    let deps = {};

    return {
      set(target, key, value, receiver) {
        // extend proxify to appended nested object
        if(({}).toString.call(value) === "[object Object]") {
          value = deepApply(key, value);
        }

        let oldValue = target[key];
        target[key] = value;

        listeners.forEach(cb => cb(`${root}${key}`, target[key], oldValue));

        if (key in deps && deps[key]) {
          deps[key].forEach(changeFunc => {
            setTimeout(changeFunc);
          });
        }

        return Reflect.set(target, key, value, receiver)
      },
      get(target, key, receiver) {
        if (key === 'toJSON') {
          return function() { return target; }
        }

        if(!(key in target)) {
          target[key] = null; // new Proxy(null, handler())
        }

        if (inDepTarget) {
          deps[key] = deps[key] || [];

          if (deps[key].indexOf(inDepTarget) == -1) {
            deps[key].push(inDepTarget);
          }
        }

        return Reflect.get(target, key, receiver)
      },
      deleteProperty(target, key) {
        delete target[key];
      },
      has: function(target, prop) {
        if (prop === '_onChange') {
          return false
        }

        return prop in target
      }
    }
  };

  let deepApply = function (property, data)
  {
    var proxy = new Proxy({}, handler(property));
    var props = Object.keys(data);
    var size = props.length;

    for (var i = 0; i < size; i++)
    {
      property = props[i];
      proxy[property] = data[property];
    }
    return proxy
  };

  Object.defineProperty(obj, '_onChange', {
    configurable: false,
    writable: false,
    enumerable: false, // hide it from for..in
    value: function (cb) {
      listeners.push(cb); //console.log('_onChange registered')
    }
  });

  Object.keys(obj).forEach(k => {
    let v = obj[k];
    if(({}).toString.call(v) === "[object Object]") {
      v = deepApply(k, v);
    }

    obj[k] = v;
  });

  let p = new Proxy(obj || {}, handler());

  Object.keys(obj).forEach(key => {
    if (typeof obj[key] !== 'function') {
      return
    }

    let f = obj[key]; //.bind(p)
    let value;
    let onDependencyUpdated = function () {
      let oldValue = value;
      value = f(p);
      listeners.forEach(cb => cb(key, value, oldValue));
    };

    Object.defineProperty(p, key, {
      get: function () {
        inDepTarget = onDependencyUpdated;
        value = f(p);
        inDepTarget = null;

        return value
      }
    });
  });

  return p
};

/**
 * Color resources red/green based on if you hit the goal (Guild Buildings & Scraptown)
 */

var Global = {
  settings: {
    enabled: ModuleSetting({
      label: 'Enabled',
      default: true,
    }),
    colorCodingEnabled: ModuleSetting({
      label: 'Color-code resources remaining (scraptown, guild buildings)',
      default: true,
      onChange (value) {
        if (this.settings.enabled.value) {
          this.toggleColorCoding(value);
        }
      }
    }),
  },

  init () {
    // do I need this?
    if (!PendoriaPlus.isInitialized) {
      console.error('Module VisualResourceStatus loaded before PendoriaPlus was initialized!');
      return
    }

    log('[VisualResourceStatus]', 'init');

    this.ajaxGuildBuildings = this.ajaxGuildBuildings.bind(this);
    this.ajaxScraptownDetails = this.ajaxScraptownDetails.bind(this);

    this.ranEnable = false;

    if (this.settings.enabled.value) {
      this.enable();
    }
  },

  enable () {
    if (this.ranEnable) {
      return
    }

    this.ranEnable = true;

    if (this.settings.colorCodingEnabled.value) {
      this.setEventHandlers();
    }
  },

  disable () {
    if (!this.ranEnable) {
      return
    }

    this.ranEnable = false;
    this.removeEventHandlers();
  },

  setEventHandlers () {
    AjaxCallback.on('/guild/buildings', this.ajaxGuildBuildings);
    AjaxCallback.on('/scraptown/details/*', this.ajaxScraptownDetails);
  },

  removeEventHandlers () {
    AjaxCallback.off('/guild/buildings', this.ajaxGuildBuildings);
    AjaxCallback.off('/scraptown/details/*', this.ajaxScraptownDetails);
  },

  toggleColorCoding (value) {
    if (typeof value == 'undefined') {
      value = !this.settings.colorCodingEnabled;
    }

    value = !!value;
    this[value ? 'setEventHandlers' : 'removeEventHandlers']();
  },

  coloredElement (from, to) {
    let goalReached = from >= to;
    let $statSpan = $('<span>');

    return $statSpan
      .text(from.toLocaleString())
      .css('color', (goalReached ? 'rgb(29, 166, 87)' : 'red'))
  },

  ajaxGuildBuildings () {
    $('.guild-overview .guild-section:nth-child(2) table tr').each((k, el) => {
      let $el = $(el);
      let $columns = $(el).find('td');

      // check if building is activated
      if ($columns.length < 2) {
        return
      }

      let $statsCol = $columns.eq(1);
      let statsText = $statsCol.text();

      // check if row has stats (x / x,xxx)
      if (!statsText.includes('/')) {
        return
      }

      let [from, to] = statsText.split(' / ').map(str => +str.replace(/[,.]/g, ''));
      let $statSpan = this.coloredElement(from, to);

      $statsCol.text('').append(
        $statSpan,
        ' / ' + to.toLocaleString()
      );
    });
  },

  // Phat arrow to keep scope
  ajaxScraptownDetails () {
    $('.display-item span:eq(1) div:not(.dotted)').each((k, el) => {
      let $el = $(el);
      let [from, _, to, type] = $el.text().split(' ');

      from = +from.replace(/[,.]/g, '');
      to = +to.replace(/[,.]/g, '');

      let $statSpan = this.coloredElement(from, to);

      $el.text('').append(
        $statSpan,
        ' / ' + to.toLocaleString() + ' ' + type
      );
    });
  }
};

__$styleInject("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n.pp_settings_module_header {\n  border-bottom: 1px solid white;\n}\n",undefined);

var settingsView = {render: function(){var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{attrs:{"id":"pp_settings"}},[_c('ul',{staticClass:"nav nav-tabs"},[_c('li',{class:{active: _vm.tab == 'settings'}},[_c('a',{on:{"click":function($event){_vm.tab = 'settings';}}},[_vm._v("Settings")])]),_vm._v(" "),_c('li',{class:{active: _vm.tab == 'about'}},[_c('a',{on:{"click":function($event){_vm.tab = 'about';}}},[_vm._v("About Pendoria+")])])]),_vm._v(" "),_c('div',{staticClass:"tab-game-content"},[(_vm.tab == 'settings')?_c('div',[_vm._l((_vm.modulesWithSettings),function(module,name){return _c('div',[_c('h2',{staticClass:"pp_settings_module_header",on:{"click":function($event){_vm.toggleModuleSettings(name);}}},[('enabled' in module.settings)?_c('input',{directives:[{name:"model",rawName:"v-model",value:(module.settings.enabled.value),expression:"module.settings.enabled.value"}],attrs:{"type":"checkbox"},domProps:{"checked":Array.isArray(module.settings.enabled.value)?_vm._i(module.settings.enabled.value,null)>-1:(module.settings.enabled.value)},on:{"click":function($event){_vm.toggleModule(name, $event);},"change":function($event){var $$a=module.settings.enabled.value,$$el=$event.target,$$c=$$el.checked?(true):(false);if(Array.isArray($$a)){var $$v=null,$$i=_vm._i($$a,$$v);if($$el.checked){$$i<0&&(module.settings.enabled.value=$$a.concat([$$v]));}else{$$i>-1&&(module.settings.enabled.value=$$a.slice(0,$$i).concat($$a.slice($$i+1)));}}else{_vm.$set(module.settings.enabled, "value", $$c);}}}}):_vm._e(),_vm._v(" "+_vm._s(name)+" ")]),_vm._v(" "),_c('div',{directives:[{name:"show",rawName:"v-show",value:(_vm.modulesOpened.includes(name) || true),expression:"modulesOpened.includes(name) || true"}]},_vm._l((module.settings),function(value,setting){return (setting != 'enabled')?_c('div',{staticStyle:{"min-height":"31px"}},[_c('label',{attrs:{"for":setting}},[_vm._v(" "+_vm._s(value.label)+" ")]),_vm._v(" "),(value.type == 'checkbox')?_c('input',{directives:[{name:"model",rawName:"v-model",value:(value.value),expression:"value.value"}],attrs:{"id":setting,"type":"checkbox"},domProps:{"checked":Array.isArray(value.value)?_vm._i(value.value,null)>-1:(value.value)},on:{"change":function($event){var $$a=value.value,$$el=$event.target,$$c=$$el.checked?(true):(false);if(Array.isArray($$a)){var $$v=null,$$i=_vm._i($$a,$$v);if($$el.checked){$$i<0&&(value.value=$$a.concat([$$v]));}else{$$i>-1&&(value.value=$$a.slice(0,$$i).concat($$a.slice($$i+1)));}}else{_vm.$set(value, "value", $$c);}}}}):_vm._e(),_vm._v(" "),(value.type == 'number')?_c('div',{staticStyle:{"display":"inline-block","width":"40%"}},[_c('input',{directives:[{name:"model",rawName:"v-model.number",value:(value.value),expression:"value.value",modifiers:{"number":true}}],attrs:{"id":setting,"type":"range","min":value.constraint.min,"max":value.constraint.max},domProps:{"value":(value.value)},on:{"__r":function($event){_vm.$set(value, "value", _vm._n($event.target.value));},"blur":function($event){_vm.$forceUpdate();}}}),_vm._v(" "+_vm._s(value.value)+" ")]):_vm._e(),_vm._v(" "),(value.type == 'select')?_c('select',{directives:[{name:"model",rawName:"v-model",value:(value.value),expression:"value.value"}],attrs:{"id":setting},on:{"change":function($event){var $$selectedVal = Array.prototype.filter.call($event.target.options,function(o){return o.selected}).map(function(o){var val = "_value" in o ? o._value : o.value;return val}); _vm.$set(value, "value", $event.target.multiple ? $$selectedVal : $$selectedVal[0]);}}},_vm._l((value.options),function(option){return _c('option',{domProps:{"value":option}},[_vm._v(" "+_vm._s(_vm._f("capitalize")(option))+" ")])})):_vm._e(),_vm._v(" "),(setting === 'sound')?_c('button',{on:{"click":_vm.playSound}},[_vm._v("►")]):_vm._e()]):_vm._e()}))])}),_vm._v(" "),_c('button',{on:{"click":_vm.saveSettings}},[_vm._v("Save settings")])],2):_vm._e(),_vm._v(" "),(_vm.tab == 'about')?_c('div',[_vm._m(0),_vm._v(" "),_c('p',[_vm._v(" Pendoria+ is a combination of visual improvements and enhancements to the overal Pendoria experience. Created by Xikeon. ")])]):_vm._e()])])},staticRenderFns: [function(){var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('p',[_vm._v(" Thanks for using "),_c('strong',[_vm._v("Pendoria+")]),_vm._v("! ")])}],
  filters: {capitalize},

  data () {
    return {
      modulesOpened: []
    }
  },

  computed: {
    modulesWithSettings () {
      return Object.keys(this.modules)
        .filter(k => 'settings' in this.modules[k] && Object.keys(this.modules[k].settings).length > 0)
        .reduce((res, k) => (res[k] = this.modules[k], res), {})
    }
  },

  methods: {
    toggleModule (name, value) {
      // Note: this couldn't be a deep watcher due to Vue internally giving a stack overflow
      if (value instanceof MouseEvent) {
        value = value.target.checked;
      }

      if (value) {
        setTimeout(() => {
          this.modules[name].enable();
        }, 50);
      } else {
        this.$nextTick(() => {
          this.modules[name].disable();
        });
      }
    },

    toggleModuleSettings (name) {
      return
      const idx = this.modulesOpened.indexOf(name);
      if (idx !== -1) {
        this.modulesOpened.splice(idx, 1);
      } else {
        this.modulesOpened.push(name);
      }
    },

    saveSettings () {
      ModuleManager.saveSettings();
    }
  }
};

__$styleInject("#pp_settings label {\r\n  width: 50%;\r\n  max-width: 40%;\r\n  /*height: 20px;*/\r\n}\r\n\r\n#pp_settings input[type=\"range\"] {\r\n  display: inline-block;\r\n  width: 75%;\r\n  margin-right: 5px;\r\n}",undefined);

const defaultScope = {
  test_version: '0.1',
  modules: {},
  tab: 'settings',
  text: '',
  radio: '',
  checkbox: '',
  select: '',
};

// TODO: load from localstorage?
let scope$1 = defaultScope;

var Settings = {
  methods: {
    playSound () {
      log('[Settings]', 'test sound');
      StatsPanel.playSound();
    }
  },

  init () {
    log('[Settings]', 'init');
    this.addModules();
    this.addMenuItem();
  },

  addModules () {
    let modules = ModuleManager.get();
    Object.keys(modules).forEach(name => {
      log('[Settings]', 'add module', name);
      scope$1.modules[name] = modules[name];
    });
  },

  addMenuItem () {
    let $item = $('<li><a href="#" id="pendoriaplus-button"> <i class="fa fa-wrench"> </i>Pendoria+</a></li>');

    $item.find('a').on('click', this.open.bind(this));

    // $("#gameframe-menu ul li:first-child").before($item)
    $("#menu ul li:last-child").before($item);
  },

  open () {
    if (this.vm) {
      this.vm.$destroy();
      this.vm = null;
      setTimeout(this.open.bind(this), 1); // give Vue time to destroy
      return
    }

    this.$wrapper = $('<div id="pendoriaplus_settings"></div>');
    $('#gameframe-battle').hide();
    $('#gameframe-content').show().html(this.$wrapper);

    this.initView();
  },

  initView () {
    const ViewCtor = Vue.extend(settingsView);
    this.vm = new ViewCtor({
      el: this.$wrapper[0],
      // template: settingsView,
      data: scope$1,
      methods: this.methods,
      // methods: methods
    });
  },
};

__$styleInject("#pendoriaplus_stats {\r\n  position: relative;\r\n  background: rgba(0, 0, 0, .8);\r\n  color: #fff;\r\n  margin-bottom: 20px;\r\n}\r\n\r\n.pendoriaplus_stats_content {\r\n  padding: 15px 15px 15px 15px;\r\n}\r\n\r\n.pendoriaplus_stats_content label {\r\n  margin: 0;\r\n}",undefined);

var appView = "<div>\r\n  <div class=\"frame frame-vertical-left\"></div>\r\n  <div class=\"frame frame-vertical-right\"></div>\r\n  <div class=\"frame frame-horizontal-top\"></div>\r\n  <div class=\"frame frame-horizontal-bottom\"></div>\r\n  <div class=\"frame frame-top-left\"></div>\r\n  <div class=\"frame frame-top-right\"></div>\r\n  <div class=\"frame frame-bottom-right\"></div>\r\n  <div class=\"frame frame-bottom-left\"></div>\r\n  <div class=\"pendoriaplus_stats_content\">\r\n    <div><a href=\"#\" class=\"pendoriaplus_reset_stats\">Reset</a></div>\r\n\r\n    <div>\r\n      <label>Actions:</label>\r\n      {{stats.actions|toLocaleString}}\r\n    </div>\r\n    <div>\r\n      <label>Exp gained:</label>\r\n      {{stats.exp|toLocaleString}}\r\n    </div>\r\n\r\n    <div id=\"pendoriaplus_stats_battle\">\r\n      <div class=\"pendoriaplus_ts_only\">\r\n        <label>Win / Loss:</label>\r\n        {{stats.wins|toLocaleString}} / {{stats.losses|toLocaleString}} ({{winPercentage}}%)\r\n      </div>\r\n      <div class=\"pendoriaplus_ts_only\">\r\n        <label>Gold gained:</label>\r\n        {{stats.gold|toLocaleString}}\r\n      </div>\r\n      <div class=\"pendoriaplus_ts_only\">\r\n        <label>Gold p/h:</label>\r\n        {{goldPerHour|toLocaleString}}\r\n      </div>\r\n    </div>\r\n\r\n    <div id=\"pendoriaplus_stats_ts\">\r\n      <div class=\"pendoriaplus_ts_only\">\r\n        <label>Quint procs:</label>\r\n        {{stats.quints|toLocaleString}} ({{quintsPercentage}}%)\r\n      </div>\r\n      <div class=\"pendoriaplus_ts_only\">\r\n        <label>{{skill|ucfirst}} gained:</label>\r\n        {{stats.resources|toLocaleString}}\r\n      </div>\r\n      <div class=\"pendoriaplus_ts_only\">\r\n        <label>{{skill|ucfirst}}/h:</label>\r\n        {{resourcesPerHour|toLocaleString}}\r\n      </div>\r\n    </div>\r\n  </div>\r\n</div>";

let sounds = {
  plucky: 'https://notificationsounds.com/soundfiles/1728efbda81692282ba642aafd57be3a/file-sounds-1101-plucky.wav',
  openEnded: 'https://notificationsounds.com/soundfiles/8eefcfdf5990e441f0fb6f3fad709e21/file-sounds-1100-open-ended.wav',
  ping: 'https://notificationsounds.com/soundfiles/4e4b5fbbbb602b6d35bea8460aa8f8e5/file-sounds-1096-light.wav',
};

function perHour(total, actions) {
  let hours = (actions * 6) / 3600;
  return Math.round(total / hours)
}

let scope$$1 = observable({
  type: 'battle',
  skill: '',
  stats: {
    actions: 0,
    exp: 0,
    wins: 0,
    losses: 0,
    gold: 0,
    quints: 0,
    resources: 1,
  },
  resetStatsDate: +new Date(),
  winPercentage (data) {
    const totalBattles = data.stats.wins + data.stats.losses;
    if (totalBattles === 0) {
      return 0
    }
    return (100 / totalBattles * data.stats.wins).toFixed(2)
  },
  quintsPercentage (data) {
    if (data.stats.actions === 0 || data.stats.quints === 0) {
      return 0
    }

    return (100 / data.stats.actions * data.stats.quints).toFixed(2)
  },
  resourcesPerHour (data) {
    // let passedHours = Math.abs(new Date(data.resetStatsDate) - new Date()) / 36e5
    // return Math.round(data.stats.resources / passedHours);
    if (data.stats.actions === 0) {
      return 0
    }

    return perHour(data.stats.resources, data.stats.actions)
  },
  goldPerHour (data) {
    // let passedHours = Math.abs(new Date(data.resetStatsDate) - new Date()) / 36e5
    // return Math.round(data.stats.gold / passedHours);
    if (data.stats.gold === 0) {
      return 0
    }

    return perHour(data.stats.gold, data.stats.actions)
  },
});

let filters = {
  toLocaleString: (value) => value.toLocaleString(),
  ucfirst: capitalize
};

var StatsPanel = {
  settings: {
    enabled: ModuleSetting({
      label: 'Enabled',
      default: true,
    }),
    timeToLevel: ModuleSetting({
      label: 'Show estimated time to next level',
      default: true,
      onChange (value) {
        if (!this.settings.enabled.value) {
          return
        }

        this.$nextLevel.toggle(value);
      }
    }),
    panelEnabled: ModuleSetting({
      label: 'Show panel under mini profile',
      default: true,
      onChange (value) {
        if (!this.settings.enabled.value) {
          return
        }

        this[value ? 'initPanel' : 'removePanel']();
      },
    }),
    lowActionSoundEnabled: ModuleSetting({
      label: 'Low action sound enabled',
      default: true,
    }),
    lowActions: ModuleSetting({
      label: 'Low action sound at actions remaining',
      default: 50,
      constraint: {
        min: 1,
        max: 100,
      },
    }),
    lowActionRepeat: ModuleSetting({
      label: 'Repeat sound every 6 seconds',
      default: true,
    }),
    sound: ModuleSetting({
      label: 'Low action sound',
      default: 'plucky',
      options: Object.keys(sounds)
    }),
    volume: ModuleSetting({
      label: 'Low action sound volume',
      default: 50,
      constraint: {
        min: 0,
        max: 100,
      },
    }),
  },

  init () {
    log('[StatsPanel]', 'init');
    log('[StatsPanel]', 'sounds', sounds);

    this.ranEnable = false;
    this.soundTimeout = null;

    this.$nextLevel = $('<div style="text-align: center; margin-top: 3px;"></div>').insertAfter('#exp');

    this.onTradeskillData = this.onTradeskillData.bind(this);
    this.onBattleData = this.onBattleData.bind(this);

    if (this.settings.enabled.value) {
      this.enable();
    }
  },

  enable () {
    if (this.ranEnable) {
      return
    }

    this.ranEnable = true;

    this.bindSocketMessages();
    this.initPanel();
    this.setSound(this.settings.sound.value);
    this.setVolume(this.settings.volume.value);

    this.$nextLevel.toggle(this.settings.timeToLevel.value);
  },

  disable () {
    if (!this.ranEnable) {
      return
    }

    this.ranEnable = false;

    this.unbindSocketMessages();
    this.removePanel();
    this.removeSound();
    this.$nextLevel.toggle(false);
  },

  bindSocketMessages () {
    socket.on('tradeskill data', this.onTradeskillData);
    socket.on('battle data', this.onBattleData);
  },

  unbindSocketMessages () {
    socket.off('tradeskill data', this.onTradeskillData);
    socket.off('battle data', this.onBattleData);
  },

  setSound (name) {
    this.audio = new Audio(sounds[name]);
    this.audio.setAttribute('name', name);
  },

  setVolume (volume) {
    if (volume < 0 || volume > 100) {
      return
    }

    this.audio.volume = volume / 100;
  },

  playSound() {
    if (!this.audio || !this.settings.lowActionSoundEnabled.value) {
      return
    }

    if (this.audio.getAttribute('name') != this.settings.sound.value) {
      this.setSound(this.settings.sound.value);
    }

    if (this.audio.volume !== this.settings.volume.value / 100) {
      this.setVolume(this.settings.volume.value);
    }

    this.audio.play();
  },

  removeSound () {
    this.audio = null;
  },

  initPanel () {
    if (this.$wrapper || !this.settings.panelEnabled.value) {
      return
    }

    this.$wrapper = $('<div id="pendoriaplus_stats"></div>').insertAfter($('#profile'));
    let app = $template(appView);
    this.$app = app({scope: scope$$1, filters}, this.$wrapper);

    this.initPanelBindings();
  },

  removePanel () {
    if (!this.$wrapper) {
      return
    }

    this.$wrapper.remove();
    this.$wrapper = null;
    this.$app = null;
  },

  initPanelBindings () {
    this.$app.find('.pendoriaplus_reset_stats').on('click', e => {
      e.preventDefault();
      Object.keys(scope$$1.stats).forEach(k => {
          scope$$1.stats[k] = 0;
      });
      scope$$1.resetStatsDate = +new Date();
    });

    this.$app
      // .tplShow('> div', this.settings.panelEnabled.value)
      .tplShow('#pendoriaplus_stats_battle', scope$$1.type, type => type === 'battle')
      .tplShow('#pendoriaplus_stats_ts', scope$$1.type, type => type === 'tradeskill');
  },

  checkActionsRemaining (data) {
    if (this.soundTimeout) {
      clearTimeout(this.soundTimeout);
      this.soundTimeout = null;
    }

    // TODO: improve check so if it skips a number magically, it will still play?
    if (data.actionsRemaining === this.settings.lowActions.value ||
      (this.settings.lowActionRepeat.value && data.actionsRemaining <= this.settings.lowActions.value)) {
      this.playSound();
    }

    if (data.actionsRemaining <= 0) {
      this.soundTimeout = setTimeout(() => {
        this.checkActionsRemaining(data);
      }, 6000);
    }
  },

  onTradeskillData (data) {
    if (scope$$1.type !== 'tradeskill') {
      this.resetStats();
    }

    this.checkActionsRemaining(data);
    this.calculateTimeToLevel(data.expToLevel, data.exp, data.gainedExp);

    scope$$1.type = 'tradeskill';
    scope$$1.skill = data.skill;
    scope$$1.stats.actions += 1;

    if (data.quintProc) {
        scope$$1.stats.quints += 1;
    }

    if (data.gainedAmount) {
        scope$$1.stats.resources += data.gainedAmount;
        scope$$1.stats.exp += data.gainedExp;
    }
  },

  onBattleData (data) {
    if (scope$$1.type !== 'battle') {
      this.resetStats();
    }

    this.checkActionsRemaining(data);
    this.calculateTimeToLevel(data.expToLevel, data.exp, data.gainedexp);

    scope$$1.type = 'battle';
    scope$$1.stats.actions += 1;

    if (data.victory) {
      scope$$1.stats.wins++;
    } else {
      scope$$1.stats.losses++;
    }

    if (data.gainedgold) {
      scope$$1.stats.gold += data.gainedgold;
      scope$$1.stats.exp += data.gainedexp;
    }
  },

  calculateTimeToLevel (max, totalGained, gained) {
    // TODO: maybe use saved stats, to be more accurate for battlers who can e.g. win 90%
    if (!this.settings.timeToLevel.value) {
      return
    }

    let timeToLevel = secondsToString(Math.round(((max - totalGained) / gained) * 6));

    if (!gained) {
      timeToLevel = '?';
    }

    setTimeout(() => {
      this.$nextLevel.text(`Next level: ${timeToLevel}`);
    }, 10);
  },

  resetStats () {
    Object.keys(scope$$1.stats).forEach(stat => scope$$1.stats[stat] = 0);
  }
};

__$styleInject("#chat_wrapper {\r\n  position: absolute;\r\n  bottom: 0;\r\n  left: 50%;\r\n  transform: translateX(-50%);\r\n  padding: 0 20px;\r\n  height: 30%;\r\n  width: 100%;\r\n  max-width: 1100px;\r\n  pointer-events: none;\r\n}\r\n\r\n#chat_wrapper #chat {\r\n  position: relative;\r\n  margin: 0 0 0 auto;\r\n  height: 100%;\r\n  width: calc(80%);\r\n  pointer-events: all;\r\n}\r\n\r\n/* Not so much an aside anymore, is it */\r\n#chat.with-tabs #chat-content {\r\n  width: 100%;\r\n  margin: 0;\r\n}\r\n\r\n#chat.with-tabs .wrapper {\r\n  display: grid;\r\n  grid-template-rows: min-content auto;\r\n}\r\n\r\n#chat.with-tabs aside {\r\n  float: none;\r\n  overflow: hidden;\r\n  height: auto;\r\n  width: 100%;\r\n  padding: 10px 0px 0 0;\r\n}\r\n\r\n#chat.with-tabs aside ul {\r\n  float: left;\r\n  margin: 0;\r\n}\r\n\r\n#chat.with-tabs aside li {\r\n  display: inline-block;\r\n  margin-right: 10px;\r\n}\r\n",undefined);

var Chat = {
  // ranEnable: false,

  settings: {
    enabled: ModuleSetting({
      label: 'Enabled',
      default: true,
    }),
    size: ModuleSetting({
      label: 'Chat size & position',
      default: 'content',
      options: ['default', 'content', 'side-by-side'],
      onChange (value) {
        this.setSize(value);
      }
    }),
    tabs: ModuleSetting({
      label: 'Channels as tabs',
      default: true,
      onChange (value) {
        this.toggleTabs(value);
      },
    })
  },

  init () {
    this.ranEnable = false;
    this.$wrapper = $('<div id="chat_wrapper"></div>');
    this.$chat = $(document).find('#chat');

    this.isDragging = false;
    this.onMouseMove = this.onMouseMove.bind(this);

    $(document).on('mouseup', () => {
      this.isDragging = false;
    });
    $(document).on('mousemove', this.onMouseMove);
    this.$chat.find('#dragable').on('mousedown', (e) => {
      if (this.settings.size.value === 'content') {
        e.stopPropagation();
        window.isDragging = false;
        this.isDragging = true;
      }
    });

    if (this.settings.enabled) {
      this.enable();
    }
  },

  enable () {
    if (this.ranEnable) {
      return
    }

    this.ranEnable = true;

    this.setSize();
    this.toggleTabs(this.settings.tabs.value);
  },

  disable () {
    if (!this.ranEnable) {
      return
    }

    this.ranEnable = false;

    this.setSize('default');
    this.toggleTabs(false);
  },

  setSize (value) {
    if (typeof value === 'undefined') {
      value = this.settings.size.value;
    }

    if (value === 'content') {
      this.$chat.wrap(this.$wrapper);
      this.$chat
        .prepend('<div class="frame frame-vertical-left"></div>')
        .prepend('<div class="frame frame-vertical-right"></div>');
    } else {
      this.$chat.unwrap();
      this.$chat.find('.frame-vertical-left, .frame-vertical-right').remove();
    }

    $('body')[value === 'side-by-side' ? 'addClass' : 'removeClass']('pp_chat_side_by_side');
  },

  onMouseMove (e) {
    if (!this.isDragging) {
      return
    }

    if (e && e.target.tagName !== 'input') {
      e.stopPropagation();
    }

    var height = (1 - (e.clientY / $(window).height())) * 100;
    if(height > 85) {
      height = '85%';
    } else if (height < 30) {
      height = '30%';
    } else {
      height = height + '%';
    }
    $('#chat_wrapper').css('height', height);
  },

  toggleTabs (value) {
    if (typeof value === 'undefined') {
      value = !this.settings.tabs.value;
    }

    value = !!value;
    this.$chat[value ? 'addClass' : 'removeClass']('with-tabs');
  },
};

__$styleInject("#pp_timeRemaining {\r\n  color: #fff;\r\n  margin-right: 10px;\r\n}",undefined);

var Quests = {
  settings: {
    enabled: ModuleSetting({
      label: 'Enabled',
      default: true,
    }),
    showTimeRemaning: ModuleSetting({
      label: 'Show est. time remaining',
      default: true,
    }),
  },

  init () {
    log('[StatsPanel]', 'init');

    this.ranEnable = false;
    this.$timeRemaining = null;

    this.onSocketData = this.onSocketData.bind(this);

    if (this.settings.enabled.value) {
      this.enable();
    }
  },

  enable () {
    if (this.ranEnable) {
      return
    }

    this.ranEnable = true;

    this.bindSocketMessages();

    if (this.settings.showTimeRemaning.value) {
      this.initTimeRemaning();
    }
  },

  disable () {
    if (!this.ranEnable) {
      return
    }

    this.ranEnable = false;

    this.unbindSocketMessages();
    this.removeTimeRemaining();
  },

  initTimeRemaning () {
    this.$timeRemaining = $('<span id="pp_timeRemaining"></span>').insertAfter('#quest_prog');
  },

  removeTimeRemaining () {
    if (!this.$timeRemaining) {
      return
    }

    this.$timeRemaining.remove();
    this.$timeRemaining = null;
  },

  bindSocketMessages () {
    socket.on('tradeskill data', this.onSocketData);
    socket.on('battle data', this.onSocketData);
  },

  unbindSocketMessages () {
    socket.off('tradeskill data', this.onSocketData);
    socket.off('battle data', this.onSocketData);
  },

  onSocketData (data) {
    if (data.quest_status !== 2) {
      this.$timeRemaining.text('');
      return
    }

    this.setRemainingTime(data.quest_count);
  },

  setRemainingTime (actions) {
    const sec_num = actions * 6;
    const str = secondsToString(sec_num);

    log('[Quests]', 'setRemainingTime', actions, str);
    this.$timeRemaining.text(str ? `(${str})` : '');
  },
};

const modules = {
  AjaxCallback,
  StatsPanel,
  Chat,
  Quests,
  Global,
  Settings,
};

window.pp_modules = modules;

var ModuleManager = {
  init () {
    log('[ModuleManager]', 'init');
  },

  saveSettings () {
    log('[ModuleManager]', 'saveSettings');
    let settings = {};
    Object.keys(modules).forEach(moduleName => {
      if ('settings' in modules[moduleName]) {
        settings[moduleName] = {};

        let moduleSettings = modules[moduleName].settings;
        Object.keys(moduleSettings).forEach(settingName => {
          settings[moduleName][settingName] = moduleSettings[settingName].value;
        });
      }
    });

    localStorage.setItem('PendoriaPlus', JSON.stringify(settings));
  },

  loadSettings () {
    let settings = localStorage.getItem('PendoriaPlus');

    if (settings) {
      log('[ModuleManager]', 'Loading settings');
      try {
        settings = JSON.parse(settings);
        log('[ModuleManager]', settings);
        Object.keys(settings).forEach(moduleName => {
          const moduleSettings = settings[moduleName];
          Object.keys(moduleSettings).forEach(settingName => {
            modules[moduleName].settings[settingName].value = moduleSettings[settingName];
          });
        });
      } catch (e) {
        log('[ModuleManager]', 'Error loading settings:', e.toString());
      }
    }
  },

  get (name = '') {
    if (!name) {
      return modules
    }

    if (!(name in modules)) {
      return null
    }

    return modules[name]
  },

  add (name, module) {
    if (!name) {
      log('[ModuleManager]', 'tried to add empty module');
      return
    }

    if (!module || typeof module !== 'object') {
      log('[ModuleManager]', 'tried to add invalid module:', name);
      return
    }

    modules[name] = module;
  }
};

__$styleInject("#gameframe-menu {\r\n  width: 80%;\r\n}\r\n\r\n.pp_chat_side_by_side #content\r\n{\r\n  right: auto;\r\n  width: 60%;\r\n  height: 90%;\r\n}\r\n\r\n.pp_chat_side_by_side #chat\r\n{\r\n  left: auto;\r\n  width: 42%;\r\n  height: 94%;\r\n  top: 60px;\r\n}\r\n\r\n@media only screen and (max-width: 980px) {\r\n  #gameframe-menu li a {\r\n    font-size: 12px;\r\n  }\r\n}\r\n",undefined);

var PendoriaPlus = {
  isInitialized: false,

  init () {
    log('Initializing');

    this.isInitialized = true;

    this.initModules();
  },

  initModules () {
    ModuleManager.loadSettings();

    Object.values(ModuleManager.get())
      .forEach(module => {
        if ('settings' in module) {
          Object.keys(module.settings)
            .forEach(key => {
              let setting = module.settings[key];
              if (setting.onChange) {
                setting.onChange = setting.onChange.bind(module);
              }
            });
        }
        module.init();
      });
  },

  getSocket () {
    return window.socket
  }
};

(function ($) {
  if (typeof $ === 'undefined') {
    console.error('PendoriaPlus could not load, jQuery was not found. Is the website even working?');
    return
  }

  $(function () {
    if (!window.hasPendoriaPlus) {
      window.hasPendoriaPlus = true;
      PendoriaPlus.init();
    }
  });
})(jQuery);

}());