MediaWiki:Common.js

From Winds Of Valen Wiki
Revision as of 09:47, 30 August 2026 by Life (talk | contribs) (Automated page publish)
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
$(document).ready(function() {
    $('#ca-nstab-main a').text('Article');
    $('#ca-history a').text('History');
    $('#ca-view a').text('Read');
});
// ==========================================================================
// Valen Ledger — Gathering & Combat XP Calculator
// Mount point: <div id="valen-ledger"></div> on any wiki page.
// Numbers sourced from the game's own balance data (xp_table.json,
// gather_xp_table.json, combat_data.json in the desktop calculator app),
// not the fan-site numbers.
// ==========================================================================
mw.hook('wikipage.content').add(function ($content) {
  var mount = $content.find('#valen-ledger')[0];
  if (!mount || mount.dataset.vlInit) return;
  mount.dataset.vlInit = '1';

  var MAX_LEVEL = 100;

  var XP_TABLE = {1:0,2:74,3:160,4:258,5:371,6:500,7:649,8:820,9:1016,10:1241,11:1500,12:1797,13:2139,14:2531,15:2982,16:3500,17:4095,18:4778,19:5563,20:6464,21:7500,22:8690,23:10056,24:11626,25:13429,26:15500,27:17879,28:20612,29:23751,30:27358,31:31500,32:36258,33:41724,34:48003,35:55215,36:63500,37:73017,38:83949,39:96506,40:110930,41:127500,42:146533,43:168397,44:193512,45:222361,46:255500,47:293567,48:337294,49:387523,50:445222,51:511500,52:587634,53:675088,54:775547,55:890944,56:1023500,57:1175767,58:1350676,59:1551594,60:1782388,61:2047500,62:2352034,63:2701852,64:3103688,65:3565275,66:4095500,67:4704568,68:5404204,69:6207875,70:7131050,71:8191500,72:9409637,73:10808909,74:12416250,75:14262600,76:16383500,77:18819774,78:21618318,79:24833000,80:28525701,81:32767500,82:37640048,83:43237135,84:49666500,85:57051902,86:65535500,87:75280595,88:86474770,89:99333501,90:114104303,91:131071500,92:150561691,93:172950041,94:198667502,95:228209107,96:262143500,97:301123882,98:345900582,99:397335504,100:456418714};
  function xpForLevel(l){ l = Math.max(1, Math.min(MAX_LEVEL, l)); return XP_TABLE[l]; }

  var GATHER = {
    fishing: [ {name:'Minnow',xp:8},{name:'Blue Gill',xp:75},{name:'Common Trout',xp:75},{name:'Bass',xp:200},{name:'Perch',xp:225},{name:'Elder Trout',xp:500},{name:'Carp',xp:1250} ],
    mining: [ {name:'Copper',xp:15},{name:'Tin',xp:15},{name:'Iron',xp:30},{name:'Coal',xp:80},{name:'Mithril',xp:150},{name:'Silver',xp:300},{name:'Gold',xp:350},{name:'Essence',xp:550} ],
    smithing: [
      // Bronze
      {name:'Bronze Bar',xp:15},
      // Iron
      {name:'Iron Bar',xp:30},{name:'Iron Rod',xp:10},{name:'Iron Plate',xp:30},
      // Steel
      {name:'Steel Bar',xp:55},{name:'Steel Rod',xp:30},{name:'Large Steel Rod',xp:40},{name:'Steel Plate',xp:60},{name:'Large Steel Plate',xp:70},
      // Mithril
      {name:'Mithril Bar',xp:155},{name:'Large Mithril Rod',xp:275},{name:'Mithril Rod',xp:250},{name:'Large Mithril Plate',xp:550},{name:'Mithril Plate',xp:500},
      // Silver
      {name:'Silver Plate',xp:400},{name:'Silver Foil',xp:400},
      // Gold
      {name:'Gold Bar',xp:800},{name:'Gold Plate',xp:400},{name:'Golden Shield Frame',xp:1000},
      // Ebony
      {name:'Small Ebony Rod',xp:3000},{name:'Large Ebony Rod',xp:3500},{name:'Ebony Rod',xp:3250},{name:'Small Ebony Plate',xp:6000},{name:'Ebony Plate',xp:6500},{name:'Large Ebony Plate',xp:7000},
      // Duskknight
      {name:'Duskknight Boot Left Sabaton',xp:10000},{name:'Duskknight Boot Right Sabaton',xp:10000},{name:'Duskknight Leg Tuille',xp:10000},{name:'Duskknight Leg Poleyn',xp:10000},{name:'Duskknight Body Pauldron',xp:10000},{name:'Duskknight Body Couter',xp:10000},{name:'Duskknight Body Rerebrace',xp:12000},{name:'Duskknight Body Vambrace',xp:12000},{name:'Duskknight Leg Tasset',xp:14000},{name:'Duskknight Boot Greave',xp:15000},{name:'Duskknight Leg Cuisses',xp:15000},{name:'Duskknight Helmet Face Plate',xp:15000},{name:'Duskknight Helmet Head Plate',xp:15000},{name:'Duskknight Helmet Spike',xp:15000},{name:'Duskknight Body Breastplate',xp:18000}
    ],
    potionmaking: [ {name:'Fishing Potion',xp:500},{name:'Weak Health Potion',xp:500},{name:'Mining Potion',xp:1000},{name:'Shields Potion',xp:1500},{name:'Normal Health Potion',xp:1500},{name:'Attack Potion',xp:1500},{name:'Archery Potion',xp:1750},{name:'Magic Potion',xp:2000},{name:'Strong Health Potion',xp:3250},{name:'Strong Shields Potion',xp:6000} ]
  };
  Object.keys(GATHER).forEach(function(k){ if (k === 'smithing') return; GATHER[k].sort(function(a,b){ return a.xp-b.xp; }); });

  // Smithing crafting tree, sourced from the game's own recipe data (not fan-site numbers).
  // Each entry: item name -> array of {name, count} ingredients needed to craft ONE of that item.
  // Items with no entry here are raw/gathered materials (chain terminates).
  var RECIPES = {
    'Iron Rod': [{name:'Iron Bar', count:1}],
    'Large Mithril Rod': [{name:'Mithril Rod', count:2}],
    'Mithril Rod': [{name:'Mithril Bar', count:2}],
    'Steel Rod': [{name:'Steel Bar', count:1}],
    'Iron Plate': [{name:'Iron Bar', count:2}],
    'Large Steel Rod': [{name:'Steel Rod', count:2}],
    'Silver Plate': [{name:'Silver Bar', count:7}],
    'Silver Foil': [{name:'Silver Plate', count:1}],
    'Gold Plate': [{name:'Gold Bar', count:8}],
    'Steel Plate': [{name:'Steel Bar', count:2}],
    'Large Steel Plate': [{name:'Steel Plate', count:2}],
    'Large Mithril Plate': [{name:'Mithril Plate', count:4}],
    'Mithril Plate': [{name:'Mithril Bar', count:4}],
    'Golden Shield Frame': [{name:'Gold Plate', count:5},{name:'Gold Bar', count:3},{name:'Steel Rod', count:2},{name:'Steel Bar', count:2}],
    'Small Ebony Rod': [{name:'Ebony Bar', count:2}],
    'Large Ebony Rod': [{name:'Ebony Rod', count:4},{name:'Ebony Bar', count:3}],
    'Ebony Rod': [{name:'Small Ebony Rod', count:4},{name:'Ebony Bar', count:3}],
    'Small Ebony Plate': [{name:'Ebony Bar', count:4}],
    'Ebony Plate': [{name:'Small Ebony Plate', count:4},{name:'Ebony Bar', count:3}],
    'Large Ebony Plate': [{name:'Ebony Plate', count:4},{name:'Ebony Bar', count:3}],
    'Duskknight Boot Left Sabaton': [{name:'Ebony Plate', count:1},{name:'Silver Foil', count:2}],
    'Duskknight Boot Right Sabaton': [{name:'Ebony Plate', count:1},{name:'Silver Foil', count:2}],
    'Duskknight Leg Tuille': [{name:'Ebony Plate', count:1},{name:'Silver Foil', count:1}],
    'Duskknight Leg Poleyn': [{name:'Ebony Plate', count:1},{name:'Silver Foil', count:1}],
    'Duskknight Body Pauldron': [{name:'Ebony Plate', count:1},{name:'Silver Foil', count:1}],
    'Duskknight Body Couter': [{name:'Ebony Plate', count:1},{name:'Silver Foil', count:1}],
    'Duskknight Body Rerebrace': [{name:'Ebony Plate', count:2},{name:'Silver Foil', count:2}],
    'Duskknight Body Vambrace': [{name:'Ebony Plate', count:2},{name:'Silver Foil', count:2}],
    'Duskknight Leg Tasset': [{name:'Ebony Plate', count:3},{name:'Silver Foil', count:3}],
    'Duskknight Boot Greave': [{name:'Large Ebony Plate', count:1},{name:'Silver Foil', count:2}],
    'Duskknight Leg Cuisses': [{name:'Large Ebony Plate', count:2},{name:'Silver Foil', count:2}],
    'Duskknight Helmet Face Plate': [{name:'Large Ebony Plate', count:1},{name:'Silver Foil', count:2}],
    'Duskknight Helmet Head Plate': [{name:'Large Ebony Plate', count:1},{name:'Silver Foil', count:2}],
    'Duskknight Helmet Spike': [{name:'Large Ebony Rod', count:1},{name:'Silver Foil', count:1}],
    'Duskknight Body Breastplate': [{name:'Large Ebony Plate', count:4},{name:'Silver Foil', count:8}],
    'Bronze Bar': [{name:'Copper Ore', count:1},{name:'Tin Ore', count:1}],
    'Iron Bar': [{name:'Iron Ore', count:2}],
    'Steel Bar': [{name:'Iron Ore', count:1},{name:'Coal Ore', count:1}],
    'Mithril Bar': [{name:'Mithril Ore', count:1},{name:'Coal Ore', count:2}],
    'Gold Bar': [{name:'Gold Ore', count:8}],
    'Silver Bar': [{name:'Silver Ore', count:7}],
    'Ebony Bar': [{name:'Ebony Ore', count:7}]
  };

  // Smithing XP per item, for bars that aren't in the GATHER.smithing dropdown (they're an
  // intermediate step, not a selectable training target) but still have their own recipe XP.
  var BAR_SMITHING_XP = { 'Bronze Bar':15, 'Iron Bar':30, 'Steel Bar':55, 'Mithril Bar':155, 'Gold Bar':800, 'Silver Bar':675, 'Ebony Bar':1800 };
  function getSmithingXp(name){
    var found = GATHER.smithing.filter(function(e){ return e.name === name; })[0];
    if (found) return found.xp;
    if (BAR_SMITHING_XP.hasOwnProperty(name)) return BAR_SMITHING_XP[name];
    return null;
  }

  // Walks RECIPES tier-by-tier from `qty` of `name`, merging same-name items within a tier.
  // Returns {entries: [{name,qty},...], totals: {rawItemName: qty,...}} or null if `name` has no recipe.
  // `entries` is a flat list in traversal order, one per item per tier (never combined onto one line).
  function buildMaterialBreakdown(name, qty){
    if (!RECIPES[name]) return null;
    var entries = [{name:name, qty:qty}];
    var totals = {};
    var currentTier = [{name:name, qty:qty}];
    while (true){
      var nextMap = {};
      var expandedAny = false;
      currentTier.forEach(function(e){
        var recipe = RECIPES[e.name];
        if (!recipe){ totals[e.name] = (totals[e.name] || 0) + e.qty; return; }
        expandedAny = true;
        recipe.forEach(function(ing){ nextMap[ing.name] = (nextMap[ing.name] || 0) + e.qty * ing.count; });
      });
      if (!expandedAny) break;
      var nextTier = Object.keys(nextMap).map(function(n){ return {name:n, qty:nextMap[n]}; });
      nextTier.forEach(function(e){ entries.push(e); });
      currentTier = nextTier;
    }
    return { entries: entries, totals: totals };
  }

  var MONSTERS = [
    {name:'Goblin',level:1,xp:50,area:'Broken Village',health:50,defence:1},{name:'Hen',level:1,xp:40,area:'Farmlands',health:40,defence:1},{name:'Rooster',level:1,xp:40,area:'Farmlands',health:40,defence:1},{name:'Cow',level:5,xp:105,area:'Farmlands',health:100,defence:5},{name:'Skeleton',level:6,xp:80,area:'Wheat Fields',health:75,defence:6},{name:'Bandit',level:10,xp:132,area:'Forest',health:120,defence:10},{name:'Skeleton Miner',level:13,xp:141,area:'Valen Gate / Grave Town',health:125,defence:13},{name:'Pirate Corsair',level:10,xp:132,area:'Valen Port',health:120,defence:5},{name:'Pirate Rigger',level:10,xp:132,area:'Valen Port',health:120,defence:5},{name:'Pirate Deckhand',level:10,xp:132,area:'Valen Port',health:120,defence:5},{name:'Goblin Villager',level:15,xp:229,area:'Goblin Village',health:200,defence:13},{name:'Skeleton Knight',level:26,xp:442,area:'West Mine',health:350,defence:20},{name:'Highwayman',level:28,xp:384,area:'West Mine',health:300,defence:23},{name:'Bandit Mercenary',level:30,xp:389,area:'Mercenary Camp',health:300,defence:25},{name:'Slum Bandit',level:30,xp:390,area:'Valen City',health:300,defence:20},{name:'Elf',level:37,xp:548,area:'Elven Haven',health:400,defence:30},{name:'Elf Scholar',level:37,xp:548,area:'Elven Haven',health:400,defence:20},{name:'Noble Skeleton',level:37,xp:548,area:'Valen City',health:400,defence:20},{name:'Cavern Goblin',level:37,xp:548,area:'West Mine Deep Cavern',health:400,defence:20},{name:'Skeleton Knight (Darklands)',level:67,xp:1670,area:'Darklands',health:1000,defence:40},{name:'Ashen Warrior',level:95,xp:2925,area:'West Mine Ashen Cavern',health:1500,defence:60},{name:'Ashen Archer',level:95,xp:2925,area:'West Mine Ashen Cavern',health:1500,defence:50},{name:'Ashen Mage',level:95,xp:2925,area:'West Mine Ashen Cavern',health:1500,defence:50},{name:'Goblin Berserker (Boss)',level:9,xp:273,area:'Broken Village',health:250,defence:6},{name:'Goblin Watcher (Boss)',level:9,xp:273,area:'Broken Village',health:250,defence:3},{name:'Pirate Captain (Boss)',level:25,xp:625,area:'Valen Port',health:500,defence:10},{name:'Bandit Leader (Boss)',level:25,xp:626,area:'Forest Alcove',health:500,defence:15},{name:'Goblin Chieftain (Boss)',level:40,xp:1400,area:'Goblin Village',health:1000,defence:18},{name:'Goblin General (Boss)',level:55,xp:1900,area:'General Cave',health:1500,defence:20},{name:'Bandit Mercenary Boss',level:74,xp:3480,area:'Mercenary Camp',health:2000,defence:27},{name:'Skeleton Pioneer (Boss)',level:90,xp:4750,area:'West Mine',health:2500,defence:30},{name:'Cavern Goblin Hunter (Boss)',level:107,xp:6105,area:'West Mine Deep Cavern',health:3000,defence:35},{name:'Fire Lich (Boss)',level:120,xp:7350,area:'Grave Town',health:3500,defence:35},{name:'Elf Warden (Boss)',level:120,xp:7350,area:'Elven Haven',health:3500,defence:35},{name:'The Burning King (Boss)',level:307,xp:30260,area:'West Mine Ashen Cavern',health:10000,defence:60}
  ];

  var COMBAT_EXP = {1:{total:0},2:{total:74},3:{total:160},4:{total:258},5:{total:371},6:{total:500},7:{total:649},8:{total:820},9:{total:1016},10:{total:1241},11:{total:1500},12:{total:1797},13:{total:2139},14:{total:2531},15:{total:2982},16:{total:3500},17:{total:4095},18:{total:4778},19:{total:5563},20:{total:6464},21:{total:7500},22:{total:8690},23:{total:10056},24:{total:11626},25:{total:13429},26:{total:15500},27:{total:17879},28:{total:20612},29:{total:23751},30:{total:27363},31:{total:31500},32:{total:36259},33:{total:41720},34:{total:47981},35:{total:55151},36:{total:63354},37:{total:72736},38:{total:83461},39:{total:95721},40:{total:109735},41:{total:125750},42:{total:144039},43:{total:164906},44:{total:188691},45:{total:215772},46:{total:246571},47:{total:281556},48:{total:321250},49:{total:366236},50:{total:417159},51:{total:474747},52:{total:539822},53:{total:613313},54:{total:696249},55:{total:789780},56:{total:895184},57:{total:1013881},58:{total:1147444},59:{total:1297613},60:{total:1466305},61:{total:1655628},62:{total:1867892},63:{total:2105628},64:{total:2371611},65:{total:2668884},66:{total:4095500},67:{total:4704568},68:{total:5404204},69:{total:6207875},70:{total:7131050},71:{total:8191500},72:{total:9409637},73:{total:10808909},74:{total:12416250},75:{total:14262600},76:{total:16383500},77:{total:18819774},78:{total:21618318},79:{total:24833000},80:{total:28525701},81:{total:32767500},82:{total:37640048},83:{total:43237135},84:{total:49666500},85:{total:57051902},86:{total:65535500},87:{total:75280595},88:{total:86474770},89:{total:99333501},90:{total:114104303},91:{total:131071500},92:{total:150561691},93:{total:172950041},94:{total:198667502},95:{total:228209107},96:{total:262143500},97:{total:301123982},98:{total:345900582},99:{total:397335504},100:{total:456418846}};

  // Max Hit formula (melee/archery/magic), confirmed against real game tooltips.
  var MAXHIT_CONST = {
    base_damage: 5.0,
    base_effective_level: 8,
    effective_level_damage_multiplier: 1.1,
    base_equipment_damage: 30.0,
    equipment_power_strength: 0.333333,
    damage_divider: 50.0
  };
  var MAXHIT_STYLES = {
    Attack:  { label:'Melee',   powerLabel:'Melee Power' },
    Archery: { label:'Archery', powerLabel:'Archery Power' },
    Magic:   { label:'Magic',   powerLabel:'Magic Power' }
  };
  function computeMaxHit(damageSkill, weaponDamage, attackSpeed, power){
    var c = MAXHIT_CONST;
    var effectiveAttackLevel = damageSkill + c.base_effective_level;
    var weaponDamageBonus = weaponDamage + c.base_equipment_damage;
    var flatDamageBonus = (power * c.equipment_power_strength) * attackSpeed;
    var maxHit = c.base_damage + (effectiveAttackLevel * c.effective_level_damage_multiplier) * (weaponDamageBonus + flatDamageBonus) / c.damage_divider;
    return { maxHit: maxHit, effectiveAttackLevel: effectiveAttackLevel, weaponDamageBonus: weaponDamageBonus, flatDamageBonus: flatDamageBonus };
  }

  function fmt(n){ return Math.round(n).toLocaleString('en-US'); }
  function fmt1(n){ return (Math.round(n*10)/10).toLocaleString('en-US'); }
  function clamp(v,lo,hi){ return Math.max(lo, Math.min(hi, v)); }

  if (!document.getElementById('valen-ledger-style')) {
    var style = document.createElement('style');
    style.id = 'valen-ledger-style';
    style.textContent =
      '#valen-ledger{font-family:"PT Serif",Georgia,serif;color:#3d2b1f;max-width:720px;margin:20px 0;}' +
      '#valen-ledger .vl-tabs{display:flex;gap:4px;flex-wrap:wrap;}' +
      '#valen-ledger .vl-tab{font-family:"Kingthings Petrock",serif;font-size:14px;letter-spacing:.03em;color:#5a4b38;background:#d8c39a;border:1px solid #5a4b38;border-bottom:none;padding:8px 14px 9px;border-radius:6px 6px 0 0;cursor:pointer;}' +
      '#valen-ledger .vl-tab.active{color:#f1e7d3;background:#3d2b1f;}' +
      '#valen-ledger .vl-card{background:#e2d1b4;border:1px solid #5a4b38;border-radius:0 6px 6px 6px;padding:20px;}' +
      '#valen-ledger .vl-field{display:flex;flex-direction:column;gap:5px;margin-bottom:14px;}' +
      '#valen-ledger .vl-row{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px;}' +
      '#valen-ledger label{font-size:11px;letter-spacing:.05em;text-transform:uppercase;color:#5a4b38;font-weight:bold;}' +
      '#valen-ledger select,#valen-ledger input[type=number]{font-family:"PT Serif",Georgia,serif;font-size:15px;color:#3d2b1f;background:#f1e7d3;border:1px solid #5a4b38;border-radius:4px;padding:7px 9px;width:100%;box-sizing:border-box;}' +
      '#valen-ledger input[type=number]{font-family:"Courier New",monospace;}' +
      '#valen-ledger .vl-hint{font-size:12.5px;color:#5a4b38;line-height:1.5;margin:2px 0 0;}' +
      '#valen-ledger .vl-xp-pair{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px;}' +
      '#valen-ledger .vl-xp-box{background:#f1e7d3;border:1px solid #5a4b38;border-radius:4px;padding:8px 10px;}' +
      '#valen-ledger .vl-xp-box .vl-num{font-family:"Courier New",monospace;font-size:17px;}' +
      '#valen-ledger .vl-result{margin-top:6px;padding-top:16px;border-top:2px double #5a4b38;}' +
      '#valen-ledger .vl-result-head{font-family:"Kingthings Petrock",serif;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:#8f6420;margin:0 0 8px;}' +
      '#valen-ledger .vl-result-text{font-size:15px;line-height:1.7;white-space:pre-line;margin:0 0 12px;}' +
      '#valen-ledger .vl-result-text.done{color:#8f6420;font-weight:bold;}' +
      '#valen-ledger .vl-bar-track{height:9px;border-radius:5px;background:#c9b284;overflow:hidden;border:1px solid #5a4b38;}' +
      '#valen-ledger .vl-bar-fill{height:100%;background:#8f6420;width:0%;}' +
      '#valen-ledger .vl-stat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:6px;}' +
      '#valen-ledger .vl-stat{background:#f1e7d3;border:1px solid #5a4b38;border-radius:4px;padding:10px 12px;}' +
      '#valen-ledger .vl-stat-label{font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:#5a4b38;display:block;margin-bottom:5px;}' +
      '#valen-ledger .vl-stat-value{font-family:"Courier New",monospace;font-size:17px;}' +
      '#valen-ledger .vl-skill-grid{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;}' +
      '#valen-ledger .vl-skill-btn{font-family:"Kingthings Petrock",serif;font-size:12px;padding:6px 12px;border-radius:16px;border:1px solid #5a4b38;background:transparent;color:#5a4b38;cursor:pointer;}' +
      '#valen-ledger .vl-skill-btn.active{background:#8f6420;border-color:#8f6420;color:#f1e7d3;}' +
      '#valen-ledger .vl-skill-btn.health.active{background:#7a352c;border-color:#7a352c;}' +
      '#valen-ledger .vl-warn{color:#7a352c;font-size:14px;padding:12px 14px;border:1px solid #7a352c;border-radius:4px;background:rgba(122,53,44,.08);}' +
      '#valen-ledger .vl-side{margin-top:14px;}' +
      '@media(max-width:520px){#valen-ledger .vl-row,#valen-ledger .vl-xp-pair{grid-template-columns:1fr;}#valen-ledger .vl-stat-grid{grid-template-columns:1fr;}}';
    document.head.appendChild(style);
  }

  mount.innerHTML =
    '<nav class="vl-tabs" id="vl-tabs"></nav>' +
    '<div class="vl-card">' +
      '<div id="vl-gather-view">' +
        '<div class="vl-field"><label>Action / Item</label><select id="vl-action"></select></div>' +
        '<div class="vl-row">' +
          '<div class="vl-field"><label>Current Level</label><input id="vl-cur-lvl" type="number" min="1" max="100" value="15"></div>' +
          '<div class="vl-field"><label>Target Level</label><input id="vl-tgt-lvl" type="number" min="1" max="100" value="20"></div>' +
        '</div>' +
        '<div class="vl-xp-pair"><div class="vl-xp-box"><label>Current XP</label><div class="vl-num" id="vl-cur-xp">0</div></div><div class="vl-xp-box"><label>Target XP</label><div class="vl-num" id="vl-tgt-xp">0</div></div></div>' +
        '<div class="vl-result"><p class="vl-result-head">Reckoning</p><p class="vl-result-text" id="vl-gather-result">Pick a skill and an action to begin.</p><div class="vl-bar-track"><div class="vl-bar-fill" id="vl-gather-fill"></div></div></div>' +
        '<div class="vl-result" id="vl-breakdown-section" style="display:none;"><p class="vl-result-head">Material Breakdown</p><p class="vl-result-text" id="vl-breakdown-text"></p></div>' +
      '</div>' +
      '<div id="vl-combat-view" style="display:none;">' +
        '<div class="vl-field"><label>Skill Being Trained</label><div class="vl-skill-grid" id="vl-skill-grid"></div><p class="vl-hint" id="vl-combat-hint"></p></div>' +
        '<div class="vl-row"><div class="vl-field"><label id="vl-cur-label">Current Level</label><input id="vl-c-cur" type="number" min="1" max="100" value="1"></div><div class="vl-field"><label id="vl-req-label">Required Level</label><input id="vl-c-req" type="number" min="2" max="100" value="10"></div></div>' +
        '<div class="vl-field"><label id="vl-next-label">EXP Left For Next Level</label><input id="vl-c-next" type="number" min="0" value="74"></div>' +
        '<div class="vl-field" id="vl-secondary-field" style="display:none;"><label id="vl-secondary-lvl-label">Secondary Skill - Current Level (optional)</label><input id="vl-sec-lvl" type="number" min="1" max="100" placeholder="Leave blank to skip"><label id="vl-secondary-exp-label" style="margin-top:8px;">Secondary Skill - EXP Left (optional)</label><input id="vl-sec-exp" type="number" min="0" placeholder="e.g. 1234"></div>' +
        '<div class="vl-field"><label>Monster</label><select id="vl-monster"></select></div>' +
        '<div class="vl-field" id="vl-custom-field" style="display:none;"><label>Custom Monster Total XP</label><input id="vl-custom-xp" type="number" min="1" placeholder="e.g. 250"></div>' +
        '<div class="vl-row"><div class="vl-field"><label>Time Per Kill (min)</label><input id="vl-kill-min" type="number" min="0" value="0"></div><div class="vl-field"><label>Time Per Kill (sec)</label><input id="vl-kill-sec" type="number" min="0" max="59" value="15"></div></div>' +
        '<div class="vl-field" id="vl-health-field"><label>Health - Current Level (optional)</label><input id="vl-h-lvl" type="number" min="1" max="100" placeholder="Leave blank to skip"><label style="margin-top:8px;">Health - EXP Left (optional)</label><input id="vl-h-exp" type="number" min="0" placeholder="e.g. 1234"></div>' +
        '<div class="vl-result"><p class="vl-result-head">Reckoning</p><p class="vl-warn" id="vl-warn" style="display:none;"></p>' +
          '<div id="vl-results"><div class="vl-stat-grid"><div class="vl-stat"><span class="vl-stat-label">EXP Needed</span><span class="vl-stat-value" id="vl-r-exp">-</span></div><div class="vl-stat"><span class="vl-stat-label">Kills Required</span><span class="vl-stat-value" id="vl-r-kills">-</span></div><div class="vl-stat"><span class="vl-stat-label">Time Required</span><span class="vl-stat-value" id="vl-r-time">-</span></div></div><p class="vl-hint" id="vl-r-sub"></p></div>' +
          '<div class="vl-side" id="vl-sec-progress" style="display:none;"><p class="vl-hint" id="vl-sec-detail"></p><div class="vl-bar-track"><div class="vl-bar-fill" id="vl-sec-bar"></div></div></div>' +
          '<div class="vl-side" id="vl-health-progress" style="display:none;"><p class="vl-hint" id="vl-health-detail"></p><div class="vl-bar-track"><div class="vl-bar-fill" id="vl-health-bar"></div></div></div>' +
        '</div>' +
      '</div>' +
      '<div id="vl-maxhit-view" style="display:none;">' +
        '<div class="vl-field"><label>Combat Style</label><div class="vl-skill-grid" id="vl-maxhit-style-grid"></div></div>' +
        '<div class="vl-row">' +
          '<div class="vl-field"><label id="vl-mh-skill-label">Attack Level</label><input id="vl-mh-skill" type="number" min="1" max="100" value="50"></div>' +
          '<div class="vl-field"><label id="vl-mh-power-label">Melee Power</label><input id="vl-mh-power" type="number" min="0" value="0"></div>' +
        '</div>' +
        '<div class="vl-row">' +
          '<div class="vl-field"><label>Weapon Damage</label><input id="vl-mh-wepdmg" type="number" min="0" value="0"></div>' +
          '<div class="vl-field"><label>Attack Speed</label><input id="vl-mh-speed" type="number" min="0" step="0.1" value="2.5"></div>' +
        '</div>' +
        '<p class="vl-hint">Weapon Damage and Attack Speed come straight off your weapon\'s tooltip; the power stat comes from your equipped gear and also matches its tooltip.</p>' +
        '<div class="vl-result">' +
          '<p class="vl-result-head">Reckoning</p>' +
          '<div class="vl-stat-grid" style="grid-template-columns:1fr;"><div class="vl-stat"><span class="vl-stat-label">Max Hit</span><span class="vl-stat-value" id="vl-mh-result" style="font-size:24px;">-</span></div></div>' +
          '<p class="vl-hint" id="vl-mh-breakdown"></p>' +
        '</div>' +
      '</div>' +
      '<div id="vl-npcxphr-view" style="display:none;">' +
        '<p class="vl-warn" style="margin-bottom:14px;">Estimated model: this game has no confirmed hit-chance formula. Hit chance here is approximated as effective level divided by (effective level plus monster defence), clamped between 10% and 95%. Treat these numbers as a rough guide, not verified data like the rest of this wiki.</p>' +
        '<div class="vl-field"><label>Combat Style (for damage)</label><div class="vl-skill-grid" id="vl-nx-style-grid"></div></div>' +
        '<div class="vl-field"><label>Skill(s) Being Trained</label><div class="vl-skill-grid" id="vl-nx-skill-grid"></div><p class="vl-hint" id="vl-nx-hint"></p></div>' +
        '<div class="vl-row">' +
          '<div class="vl-field"><label id="vl-nx-skill-label">Attack Level</label><input id="vl-nx-skill" type="number" min="1" max="100" value="50"></div>' +
          '<div class="vl-field"><label id="vl-nx-power-label">Melee Power</label><input id="vl-nx-power" type="number" min="0" value="0"></div>' +
        '</div>' +
        '<div class="vl-row">' +
          '<div class="vl-field"><label>Weapon Damage</label><input id="vl-nx-wepdmg" type="number" min="0" value="0"></div>' +
          '<div class="vl-field"><label>Attack Speed</label><input id="vl-nx-speed" type="number" min="0" step="0.1" value="2.5"></div>' +
        '</div>' +
        '<div class="vl-field"><label>Monster</label><select id="vl-nx-monster"></select></div>' +
        '<div class="vl-result">' +
          '<p class="vl-result-head">Reckoning</p>' +
          '<div class="vl-stat-grid"><div class="vl-stat"><span class="vl-stat-label">Hit Chance</span><span class="vl-stat-value" id="vl-nx-hitchance">-</span></div><div class="vl-stat"><span class="vl-stat-label">Avg Dmg / Hit</span><span class="vl-stat-value" id="vl-nx-avgdmg">-</span></div><div class="vl-stat"><span class="vl-stat-label">Kills / Hour</span><span class="vl-stat-value" id="vl-nx-kph">-</span></div></div>' +
          '<div class="vl-stat-grid" style="grid-template-columns:1fr;margin-top:10px;"><div class="vl-stat"><span class="vl-stat-label" id="vl-nx-xph-label">XP / Hour</span><span class="vl-stat-value" id="vl-nx-xph" style="font-size:20px;">-</span></div></div>' +
          '<p class="vl-hint" id="vl-nx-sub"></p>' +
        '</div>' +
      '</div>' +
    '</div>';

  var TABS = [ {id:'fishing',label:'Fishing'},{id:'mining',label:'Mining'},{id:'smithing',label:'Smithing'},{id:'potionmaking',label:'Potion Making'},{id:'combat',label:'Combat'},{id:'maxhit',label:'Max Hit'},{id:'npcxphr',label:'NPC XP/hr'} ];
  var tabsEl = mount.querySelector('#vl-tabs');
  TABS.forEach(function(t){
    var b = document.createElement('button');
    b.type = 'button'; b.className = 'vl-tab'; b.textContent = t.label; b.dataset.tab = t.id;
    b.addEventListener('click', function(){ selectTab(t.id); });
    tabsEl.appendChild(b);
  });

  var state = { tab: 'fishing' };
  var gatherView = mount.querySelector('#vl-gather-view');
  var combatView = mount.querySelector('#vl-combat-view');
  var maxhitView = mount.querySelector('#vl-maxhit-view');
  var npcxphrView = mount.querySelector('#vl-npcxphr-view');

  function selectTab(id){
    state.tab = id;
    mount.querySelectorAll('.vl-tab').forEach(function(b){ b.classList.toggle('active', b.dataset.tab === id); });
    gatherView.style.display = 'none';
    combatView.style.display = 'none';
    maxhitView.style.display = 'none';
    npcxphrView.style.display = 'none';
    if (id === 'combat'){ combatView.style.display = 'block'; calcCombat(); return; }
    if (id === 'maxhit'){ maxhitView.style.display = 'block'; calcMaxHit(); return; }
    if (id === 'npcxphr'){ npcxphrView.style.display = 'block'; calcNpcXpHr(); return; }
    gatherView.style.display = 'block';
    populateActions();
    calcGather();
  }

  var actionSelect = mount.querySelector('#vl-action');
  var curLvlInput = mount.querySelector('#vl-cur-lvl');
  var tgtLvlInput = mount.querySelector('#vl-tgt-lvl');
  var curXpLabel = mount.querySelector('#vl-cur-xp');
  var tgtXpLabel = mount.querySelector('#vl-tgt-xp');
  var gatherResult = mount.querySelector('#vl-gather-result');
  var gatherFill = mount.querySelector('#vl-gather-fill');
  var breakdownSection = mount.querySelector('#vl-breakdown-section');
  var breakdownText = mount.querySelector('#vl-breakdown-text');

  function populateActions(){
    var entries = GATHER[state.tab] || [];
    actionSelect.innerHTML = '';
    entries.forEach(function(e, i){
      var opt = document.createElement('option');
      opt.value = String(i);
      opt.textContent = e.name + ' (' + fmt(e.xp) + ' xp)';
      actionSelect.appendChild(opt);
    });
  }

  function clampLevelInput(input){
    var v = parseInt(input.value, 10);
    if (isNaN(v)) v = 1;
    v = clamp(v, 1, MAX_LEVEL);
    input.value = v;
    return v;
  }

  function calcGather(){
    var entries = GATHER[state.tab] || [];
    if (entries.length === 0) return;
    var idx = parseInt(actionSelect.value, 10) || 0;
    var entry = entries[idx];
    var curLevel = clampLevelInput(curLvlInput);
    var tgtLevel = clampLevelInput(tgtLvlInput);
    var curXp = xpForLevel(curLevel);
    var tgtXp = xpForLevel(tgtLevel);
    curXpLabel.textContent = fmt(curXp);
    tgtXpLabel.textContent = fmt(tgtXp);
    if (tgtXp <= curXp){
      gatherResult.classList.add('done');
      gatherResult.textContent = 'Level ' + tgtLevel + ' calls for ' + fmt(tgtXp) + ' xp. You already have that at level ' + curLevel + ' (' + fmt(curXp) + ' xp). Nothing left to gather.';
      gatherFill.style.width = '100%';
      breakdownSection.style.display = 'none';
      return;
    }
    gatherResult.classList.remove('done');
    var needed = tgtXp - curXp;
    var actions = Math.ceil(needed / entry.xp);
    gatherResult.textContent = 'Level ' + curLevel + ' -> Level ' + tgtLevel + '\nNeed ' + fmt(needed) + ' more xp\n= ' + fmt(actions) + ' x ' + entry.name;
    gatherFill.style.width = '0%';

    var breakdown = state.tab === 'smithing' ? buildMaterialBreakdown(entry.name, actions) : null;
    if (!breakdown){
      breakdownSection.style.display = 'none';
    } else {
      breakdownSection.style.display = 'block';
      var text = breakdown.entries.map(function(e, i){
        var xp = getSmithingXp(e.name);
        var xpSuffix = xp ? ' (XP: ' + fmt(e.qty * xp) + ')' : '';
        return (i === 0 ? '' : '-> ') + fmt(e.qty) + ' ' + e.name + xpSuffix;
      }).join('\n');
      var rawNames = Object.keys(breakdown.totals);
      if (rawNames.length){
        text += '\n\nTotal raw materials: ' + rawNames.map(function(n){ return fmt(breakdown.totals[n]) + ' ' + n; }).join(', ');
      }
      breakdownText.textContent = text;
    }
  }

  actionSelect.addEventListener('change', calcGather);
  curLvlInput.addEventListener('input', calcGather);
  tgtLvlInput.addEventListener('input', calcGather);

  var selectedSkills = ['Attack'];
  var COMBAT_SKILLS = ['Attack','Archery','Magic','Defence','Evasion','Warding','Health'];
  var skillGrid = mount.querySelector('#vl-skill-grid');
  COMBAT_SKILLS.forEach(function(s){
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'vl-skill-btn' + (s === 'Attack' ? ' active' : '') + (s === 'Health' ? ' health' : '');
    b.textContent = s; b.dataset.skill = s;
    b.addEventListener('click', function(){ onSkillClick(s); });
    skillGrid.appendChild(b);
  });

  var combatHint = mount.querySelector('#vl-combat-hint');
  var combatCur = mount.querySelector('#vl-c-cur');
  var combatReq = mount.querySelector('#vl-c-req');
  var curLabel = mount.querySelector('#vl-cur-label');
  var reqLabel = mount.querySelector('#vl-req-label');
  var combatNext = mount.querySelector('#vl-c-next');
  var nextLabel = mount.querySelector('#vl-next-label');
  var secondaryField = mount.querySelector('#vl-secondary-field');
  var secLvlLabel = mount.querySelector('#vl-secondary-lvl-label');
  var secExpLabel = mount.querySelector('#vl-secondary-exp-label');
  var secLvl = mount.querySelector('#vl-sec-lvl');
  var secExp = mount.querySelector('#vl-sec-exp');
  var monsterSelect = mount.querySelector('#vl-monster');
  var customField = mount.querySelector('#vl-custom-field');
  var customXp = mount.querySelector('#vl-custom-xp');
  var killMin = mount.querySelector('#vl-kill-min');
  var killSec = mount.querySelector('#vl-kill-sec');
  var healthField = mount.querySelector('#vl-health-field');
  var healthLvl = mount.querySelector('#vl-h-lvl');
  var healthExp = mount.querySelector('#vl-h-exp');
  var warnEl = mount.querySelector('#vl-warn');
  var resultsEl = mount.querySelector('#vl-results');
  var rExp = mount.querySelector('#vl-r-exp');
  var rKills = mount.querySelector('#vl-r-kills');
  var rSub = mount.querySelector('#vl-r-sub');
  var rTime = mount.querySelector('#vl-r-time');
  var secProgress = mount.querySelector('#vl-sec-progress');
  var secDetail = mount.querySelector('#vl-sec-detail');
  var secBar = mount.querySelector('#vl-sec-bar');
  var healthProgress = mount.querySelector('#vl-health-progress');
  var healthDetail = mount.querySelector('#vl-health-detail');
  var healthBar = mount.querySelector('#vl-health-bar');

  function buildMonsterSelect(){
    monsterSelect.innerHTML = '';
    var areas = {};
    MONSTERS.forEach(function(m){ (areas[m.area] = areas[m.area] || []).push(m); });
    Object.keys(areas).forEach(function(area){
      var grp = document.createElement('optgroup');
      grp.label = area;
      areas[area].slice().sort(function(a,b){ return a.level-b.level; }).forEach(function(m){
        var opt = document.createElement('option');
        opt.value = String(m.xp);
        opt.textContent = m.name + ' (Lvl ' + m.level + ') - ' + fmt(m.xp) + ' XP';
        grp.appendChild(opt);
      });
      monsterSelect.appendChild(grp);
    });
    var customGrp = document.createElement('optgroup');
    customGrp.label = 'Other';
    var customOpt = document.createElement('option');
    customOpt.value = 'custom'; customOpt.textContent = 'Custom monster...';
    customGrp.appendChild(customOpt);
    monsterSelect.appendChild(customGrp);
  }

  function splitFractionsFor(skills){
    if (skills.length === 1 && skills[0] === 'Health') return {primary:0.25, secondary:0, health:0};
    if (skills.length === 1) return {primary:0.75, secondary:0, health:0.25};
    return {primary:0.375, secondary:0.375, health:0.25};
  }

  function findLevelForExp(total){
    var lvl = 1;
    for (var l = 1; l <= MAX_LEVEL; l++){ if (COMBAT_EXP[l].total <= total) lvl = l; else break; }
    return lvl;
  }

  function formatTime(totalSeconds){
    totalSeconds = Math.round(totalSeconds);
    var days = Math.floor(totalSeconds / 86400);
    var hours = Math.floor((totalSeconds % 86400) / 3600);
    var minutes = Math.floor((totalSeconds % 3600) / 60);
    var seconds = totalSeconds % 60;
    var parts = [];
    if (days) parts.push(days + 'd');
    if (hours || days) parts.push(hours + 'h');
    if (minutes || hours || days) parts.push(minutes + 'm');
    parts.push(seconds + 's');
    return parts.join(' ');
  }

  function clampLevel(input, fallback){
    var v = parseInt(input.value, 10);
    if (isNaN(v)) v = fallback;
    return v;
  }

  function updateHint(){
    if (selectedSkills.length === 2){
      combatHint.textContent = 'Dual training: 37.5% of each kill\'s EXP goes to ' + selectedSkills[0] + ' and ' + selectedSkills[1] + ', and 25% goes to Health.';
    } else if (selectedSkills[0] === 'Health'){
      combatHint.textContent = 'Training Health directly: 25% of every kill\'s EXP goes to Health.';
    } else {
      combatHint.textContent = '75% of every kill\'s EXP trains this skill. The other 25% always goes to Health.';
    }
    var primary = selectedSkills[0];
    curLabel.textContent = 'Current Level (' + primary + ')';
    reqLabel.textContent = 'Required Level (' + primary + ')';
    nextLabel.textContent = 'EXP Left For Next Level (' + primary + ')';
    if (selectedSkills.length === 2){
      var secondary = selectedSkills[1];
      secondaryField.style.display = 'block';
      secLvlLabel.textContent = secondary + ' - Current Level (optional)';
      secExpLabel.textContent = secondary + ' - EXP Left (optional)';
    } else {
      secondaryField.style.display = 'none';
    }
    healthField.style.display = selectedSkills.indexOf('Health') !== -1 ? 'none' : 'block';
  }

  function renderSide(levelInput, expInput, panel, detailEl, barEl, expGained, skillName, kills){
    if (!levelInput.value){ panel.style.display = 'none'; return; }
    var lvl = parseInt(levelInput.value, 10);
    if (!lvl || lvl < 1 || lvl > MAX_LEVEL){ panel.style.display = 'none'; return; }
    var curExp = COMBAT_EXP[lvl].total;
    if (expInput.value !== '' && COMBAT_EXP[lvl+1]){
      var expLeft = Math.max(0, parseFloat(expInput.value) || 0);
      curExp = Math.max(COMBAT_EXP[lvl].total, COMBAT_EXP[lvl+1].total - expLeft);
    }
    var newTotal = curExp + expGained;
    var newLevel = findLevelForExp(newTotal);
    var nextLvl = Math.min(newLevel + 1, MAX_LEVEL);
    var into = newTotal - COMBAT_EXP[newLevel].total;
    var forLevel = COMBAT_EXP[nextLvl] ? COMBAT_EXP[nextLvl].total - COMBAT_EXP[newLevel].total : 1;
    var pct = newLevel >= MAX_LEVEL ? 100 : Math.min(100, Math.round((into/forLevel)*100));
    panel.style.display = 'block';
    detailEl.textContent = newLevel >= MAX_LEVEL
      ? (skillName + ': already at max level.')
      : (skillName + ': level ' + lvl + ' -> ' + newLevel + ' (' + pct + '% into ' + nextLvl + '), +' + fmt(expGained) + ' xp over ' + fmt(kills) + ' kills.');
    barEl.style.width = pct + '%';
  }

  function calcCombat(){
    warnEl.style.display = 'none';
    resultsEl.style.display = 'block';

    function warn(msg){
      warnEl.textContent = msg; warnEl.style.display = 'block';
      resultsEl.style.display = 'none';
      secProgress.style.display = 'none';
      healthProgress.style.display = 'none';
    }

    var curLevel = clampLevel(combatCur, 0);
    var reqLevel = clampLevel(combatReq, 0);
    if (!curLevel || !reqLevel || curLevel < 1 || curLevel > MAX_LEVEL || reqLevel < 1 || reqLevel > MAX_LEVEL){ warn('Enter valid current and required levels (1-100).'); return; }
    if (reqLevel <= curLevel){ warn('Required level must be higher than current level.'); return; }

    var monsterXp;
    if (monsterSelect.value === 'custom') monsterXp = parseFloat(customXp.value);
    else monsterXp = parseFloat(monsterSelect.value);
    if (!monsterXp || monsterXp <= 0){ warn('Pick a monster or enter a custom XP value.'); return; }

    var mins = parseFloat(killMin.value) || 0;
    var secs = parseFloat(killSec.value) || 0;
    var timePerKill = mins * 60 + secs;
    if (timePerKill <= 0){ warn('Enter an average time to kill greater than 0.'); return; }

    var nextFull = COMBAT_EXP[curLevel+1] ? COMBAT_EXP[curLevel+1].total - COMBAT_EXP[curLevel].total : null;
    var expToNext = parseFloat(combatNext.value);
    if (nextFull !== null && (isNaN(expToNext) || expToNext < 0)){ warn('Enter the EXP left for your next level.'); return; }

    var totalNeeded;
    if (nextFull === null) totalNeeded = 0;
    else if (reqLevel === curLevel + 1) totalNeeded = Math.max(0, expToNext);
    else totalNeeded = Math.max(0, expToNext) + Math.max(0, COMBAT_EXP[reqLevel].total - COMBAT_EXP[curLevel+1].total);

    var fr = splitFractionsFor(selectedSkills);
    var primarySkill = selectedSkills[0];
    var secondarySkill = selectedSkills.length === 2 ? selectedSkills[1] : null;

    var primaryPerKill = monsterXp * fr.primary;
    var secondaryPerKill = monsterXp * fr.secondary;
    var healthPerKill = monsterXp * fr.health;

    var kills = totalNeeded === 0 ? 0 : Math.ceil(totalNeeded / primaryPerKill);
    var totalTime = kills * timePerKill;
    var secondaryGained = kills * secondaryPerKill;
    var healthGained = kills * healthPerKill;

    rExp.textContent = fmt(totalNeeded) + ' xp';
    rKills.textContent = fmt(kills);
    rSub.textContent = secondarySkill
      ? ('Each kill gives ' + primaryPerKill.toLocaleString(undefined,{maximumFractionDigits:1}) + ' xp to ' + primarySkill + ' and ' + secondarySkill + ' (of ' + fmt(monsterXp) + ' total)')
      : ('Each kill gives ' + primaryPerKill.toLocaleString(undefined,{maximumFractionDigits:1}) + ' xp to ' + primarySkill + ' (of ' + fmt(monsterXp) + ' total)');
    rTime.textContent = formatTime(totalTime);

    if (selectedSkills.indexOf('Health') !== -1) healthProgress.style.display = 'none';
    else renderSide(healthLvl, healthExp, healthProgress, healthDetail, healthBar, healthGained, 'Health', kills);

    if (!secondarySkill) secProgress.style.display = 'none';
    else renderSide(secLvl, secExp, secProgress, secDetail, secBar, secondaryGained, secondarySkill, kills);
  }

  function onSkillClick(skill){
    if (skill === 'Health'){ selectedSkills = ['Health']; }
    else if (selectedSkills.indexOf('Health') !== -1){ selectedSkills = [skill]; }
    else if (selectedSkills.indexOf(skill) !== -1){ if (selectedSkills.length > 1) selectedSkills = selectedSkills.filter(function(s){ return s !== skill; }); }
    else if (selectedSkills.length >= 2){ selectedSkills = [selectedSkills[1], skill]; }
    else { selectedSkills = selectedSkills.concat([skill]); }
    mount.querySelectorAll('.vl-skill-btn').forEach(function(b){ b.classList.toggle('active', selectedSkills.indexOf(b.dataset.skill) !== -1); });
    updateHint();
    calcCombat();
  }

  [combatCur, combatReq, combatNext, secLvl, secExp, customXp, killMin, killSec, healthLvl, healthExp].forEach(function(el){
    el.addEventListener('input', calcCombat);
  });

  buildMonsterSelect();
  monsterSelect.addEventListener('change', function(){
    customField.style.display = monsterSelect.value === 'custom' ? 'block' : 'none';
    calcCombat();
  });
  updateHint();

  // ------------- Max Hit -------------
  var maxhitStyle = 'Attack';
  var mhStyleGrid = mount.querySelector('#vl-maxhit-style-grid');
  Object.keys(MAXHIT_STYLES).forEach(function(styleKey){
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'vl-skill-btn' + (styleKey === maxhitStyle ? ' active' : '');
    b.textContent = MAXHIT_STYLES[styleKey].label;
    b.dataset.style = styleKey;
    b.addEventListener('click', function(){
      maxhitStyle = styleKey;
      mhStyleGrid.querySelectorAll('.vl-skill-btn').forEach(function(btn){ btn.classList.toggle('active', btn.dataset.style === styleKey); });
      mhSkillLabel.textContent = MAXHIT_STYLES[styleKey].label + ' Level';
      mhPowerLabel.textContent = MAXHIT_STYLES[styleKey].powerLabel;
      calcMaxHit();
    });
    mhStyleGrid.appendChild(b);
  });

  var mhSkillLabel = mount.querySelector('#vl-mh-skill-label');
  var mhPowerLabel = mount.querySelector('#vl-mh-power-label');
  var mhSkill = mount.querySelector('#vl-mh-skill');
  var mhPower = mount.querySelector('#vl-mh-power');
  var mhWepDmg = mount.querySelector('#vl-mh-wepdmg');
  var mhSpeed = mount.querySelector('#vl-mh-speed');
  var mhResult = mount.querySelector('#vl-mh-result');
  var mhBreakdown = mount.querySelector('#vl-mh-breakdown');

  function calcMaxHit(){
    var skillLevel = parseFloat(mhSkill.value) || 0;
    var power = parseFloat(mhPower.value) || 0;
    var wepDmg = parseFloat(mhWepDmg.value) || 0;
    var speed = parseFloat(mhSpeed.value) || 0;
    var r = computeMaxHit(skillLevel, wepDmg, speed, power);
    mhResult.textContent = fmt1(r.maxHit);
    mhBreakdown.textContent = 'Effective ' + MAXHIT_STYLES[maxhitStyle].label + ' level: ' + fmt1(r.effectiveAttackLevel) +
      ' | Weapon damage bonus: ' + fmt1(r.weaponDamageBonus) +
      ' | Flat damage bonus: ' + fmt1(r.flatDamageBonus);
  }

  [mhSkill, mhPower, mhWepDmg, mhSpeed].forEach(function(el){ el.addEventListener('input', calcMaxHit); });

  // ------------- NPC XP/hr -------------
  var nxStyle = 'Attack';
  var nxSelectedSkills = ['Attack'];
  var nxStyleGrid = mount.querySelector('#vl-nx-style-grid');
  Object.keys(MAXHIT_STYLES).forEach(function(styleKey){
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'vl-skill-btn' + (styleKey === nxStyle ? ' active' : '');
    b.textContent = MAXHIT_STYLES[styleKey].label;
    b.dataset.style = styleKey;
    b.addEventListener('click', function(){
      nxStyle = styleKey;
      nxStyleGrid.querySelectorAll('.vl-skill-btn').forEach(function(btn){ btn.classList.toggle('active', btn.dataset.style === styleKey); });
      calcNpcXpHr();
    });
    nxStyleGrid.appendChild(b);
  });

  var nxSkillGrid = mount.querySelector('#vl-nx-skill-grid');
  COMBAT_SKILLS.forEach(function(s){
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'vl-skill-btn' + (s === 'Attack' ? ' active' : '') + (s === 'Health' ? ' health' : '');
    b.textContent = s; b.dataset.skill = s;
    b.addEventListener('click', function(){ onNxSkillClick(s); });
    nxSkillGrid.appendChild(b);
  });

  var nxHint = mount.querySelector('#vl-nx-hint');
  var nxSkillLabel = mount.querySelector('#vl-nx-skill-label');
  var nxPowerLabel = mount.querySelector('#vl-nx-power-label');
  var nxSkill = mount.querySelector('#vl-nx-skill');
  var nxPower = mount.querySelector('#vl-nx-power');
  var nxWepDmg = mount.querySelector('#vl-nx-wepdmg');
  var nxSpeed = mount.querySelector('#vl-nx-speed');
  var nxMonsterSelect = mount.querySelector('#vl-nx-monster');
  var nxHitChance = mount.querySelector('#vl-nx-hitchance');
  var nxAvgDmg = mount.querySelector('#vl-nx-avgdmg');
  var nxKph = mount.querySelector('#vl-nx-kph');
  var nxXphLabel = mount.querySelector('#vl-nx-xph-label');
  var nxXph = mount.querySelector('#vl-nx-xph');
  var nxSub = mount.querySelector('#vl-nx-sub');

  function buildNxMonsterSelect(){
    nxMonsterSelect.innerHTML = '';
    var areas = {};
    MONSTERS.forEach(function(m, idx){ (areas[m.area] = areas[m.area] || []).push(idx); });
    Object.keys(areas).forEach(function(area){
      var grp = document.createElement('optgroup');
      grp.label = area;
      areas[area].slice().sort(function(a,b){ return MONSTERS[a].level - MONSTERS[b].level; }).forEach(function(idx){
        var m = MONSTERS[idx];
        var opt = document.createElement('option');
        opt.value = String(idx);
        opt.textContent = m.name + ' (Lvl ' + m.level + ', HP ' + fmt(m.health) + ', Def ' + m.defence + ')';
        grp.appendChild(opt);
      });
      nxMonsterSelect.appendChild(grp);
    });
  }

  function updateNxLabelsAndHint(){
    if (nxSelectedSkills.length === 2){
      nxHint.textContent = 'Dual training: 37.5% of each kill\'s EXP goes to ' + nxSelectedSkills[0] + ' and ' + nxSelectedSkills[1] + ', and 25% goes to Health.';
    } else if (nxSelectedSkills[0] === 'Health'){
      nxHint.textContent = 'Training Health directly: 25% of every kill\'s EXP goes to Health.';
    } else {
      nxHint.textContent = '75% of every kill\'s EXP trains this skill. The other 25% always goes to Health.';
    }
    nxSkillLabel.textContent = MAXHIT_STYLES[nxStyle].label + ' Level';
    nxPowerLabel.textContent = MAXHIT_STYLES[nxStyle].powerLabel;
    nxXphLabel.textContent = nxSelectedSkills[0] + ' XP / Hour';
  }

  function onNxSkillClick(skill){
    if (skill === 'Health'){ nxSelectedSkills = ['Health']; }
    else if (nxSelectedSkills.indexOf('Health') !== -1){ nxSelectedSkills = [skill]; }
    else if (nxSelectedSkills.indexOf(skill) !== -1){ if (nxSelectedSkills.length > 1) nxSelectedSkills = nxSelectedSkills.filter(function(s){ return s !== skill; }); }
    else if (nxSelectedSkills.length >= 2){ nxSelectedSkills = [nxSelectedSkills[1], skill]; }
    else { nxSelectedSkills = nxSelectedSkills.concat([skill]); }
    nxSkillGrid.querySelectorAll('.vl-skill-btn').forEach(function(b){ b.classList.toggle('active', nxSelectedSkills.indexOf(b.dataset.skill) !== -1); });
    updateNxLabelsAndHint();
    calcNpcXpHr();
  }

  function calcNpcXpHr(){
    var m = MONSTERS[parseInt(nxMonsterSelect.value, 10) || 0];
    if (!m){ return; }
    var skillLevel = parseFloat(nxSkill.value) || 0;
    var power = parseFloat(nxPower.value) || 0;
    var wepDmg = parseFloat(nxWepDmg.value) || 0;
    var speed = parseFloat(nxSpeed.value) || 0;
    if (speed <= 0){ nxSub.textContent = 'Enter an attack speed greater than 0.'; return; }

    var effectiveLevel = skillLevel + MAXHIT_CONST.base_effective_level;
    var hitChance = effectiveLevel / (effectiveLevel + m.defence);
    hitChance = Math.max(0.10, Math.min(0.95, hitChance));

    var mh = computeMaxHit(skillLevel, wepDmg, speed, power);
    var avgDamagePerHit = (mh.maxHit / 2) * hitChance;
    var dps = avgDamagePerHit / speed;
    var killsPerHour;
    if (dps <= 0) killsPerHour = 0;
    else {
      var timeToKillSeconds = m.health / dps;
      killsPerHour = 3600 / timeToKillSeconds;
    }

    var fr = splitFractionsFor(nxSelectedSkills);
    var xpPerKillPrimary = m.xp * fr.primary;
    var xpPerHourPrimary = killsPerHour * xpPerKillPrimary;

    nxHitChance.textContent = Math.round(hitChance * 100) + '%';
    nxAvgDmg.textContent = fmt1(avgDamagePerHit);
    nxKph.textContent = fmt1(killsPerHour);
    nxXph.textContent = fmt(xpPerHourPrimary) + ' xp';

    var subParts = [];
    if (nxSelectedSkills.length === 2){
      var xpPerHourSecondary = killsPerHour * (m.xp * fr.secondary);
      subParts.push(nxSelectedSkills[1] + ': ' + fmt(xpPerHourSecondary) + ' xp/hr');
    }
    if (fr.health > 0 && nxSelectedSkills.indexOf('Health') === -1){
      var xpPerHourHealth = killsPerHour * (m.xp * fr.health);
      subParts.push('Health: ' + fmt(xpPerHourHealth) + ' xp/hr');
    }
    nxSub.textContent = subParts.length ? subParts.join(' | ') : '';
  }

  [nxSkill, nxPower, nxWepDmg, nxSpeed].forEach(function(el){ el.addEventListener('input', calcNpcXpHr); });
  buildNxMonsterSelect();
  nxMonsterSelect.addEventListener('change', calcNpcXpHr);
  updateNxLabelsAndHint();

  selectTab('fishing');
});