MediaWiki:Common.js: Difference between revisions
Jump to navigation
Jump to search
fixed the redirect not firing on its own on real phones Tag: Reverted |
added a temporary debug banner to see what's happening on your phone Tag: Reverted |
||
| Line 20: | Line 20: | ||
// giving up on the first check. | // giving up on the first check. | ||
(function () { | (function () { | ||
// TEMPORARY DEBUG BUILD: the automatic redirect isn't firing on some real | |||
// phones and it can't be reproduced in any test environment available | |||
// here (Electron only fakes the user-agent string, it never actually runs | |||
// WebKit). Instead of guessing again, this shows what the script actually | |||
// sees directly on the page, and does NOT navigate away, so the banner | |||
// stays put to be read/screenshotted. Remove this block and restore the | |||
// plain redirect once the real cause is identified from that report. | |||
function showDebugBanner(lines) { | |||
try { | |||
var el = document.createElement('div'); | |||
el.id = 'wov-debug-banner'; | |||
el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#111;color:#0f0;font:12px monospace;padding:8px;white-space:pre-wrap;word-break:break-all;max-height:60vh;overflow:auto;'; | |||
el.textContent = lines.join('\n'); | |||
(document.body || document.documentElement).appendChild(el); | |||
} catch (e) {} | |||
} | |||
function tryForceDesktop() { | function tryForceDesktop() { | ||
var log = []; | |||
try { | try { | ||
log.push('readyState: ' + document.readyState); | |||
var alreadyTried = false; | log.push('bodyClass: ' + (document.body ? document.body.className : 'NO BODY')); | ||
try { alreadyTried = !!sessionStorage.getItem('wovForcedDesktop'); } catch (e) {} | var isMinerva = !!(document.body && document.body.classList.contains('skin-minerva')); | ||
log.push('isMinerva: ' + isMinerva); | |||
if (!isMinerva) { log.push('DECISION: not Minerva, nothing to do'); showDebugBanner(log); return; } | |||
var | var storageWorks = true, alreadyTried = false; | ||
try { alreadyTried = !!sessionStorage.getItem('wovForcedDesktop'); } catch (e) { storageWorks = false; log.push('sessionStorage error: ' + e.message); } | |||
log.push('storageWorks: ' + storageWorks + ', alreadyTried: ' + alreadyTried); | |||
if (alreadyTried) { log.push('DECISION: already tried this session, stopping (would normally stay on Minerva)'); showDebugBanner(log); return; } | |||
var targetUrl = window.location.pathname + window.location.search + (window.location.search ? '&' : '?') + 'mobileaction=toggle_view_desktop'; | |||
log.push('DECISION: would redirect to: ' + targetUrl); | |||
log.push('cookie: ' + document.cookie); | |||
showDebugBanner(log); | |||
// Redirect intentionally disabled during this debug pass. | |||
} catch (e) { | } catch (e) { | ||
log.push('FATAL ERROR: ' + e.message); | |||
showDebugBanner(log); | |||
} | } | ||
} | } | ||
Revision as of 05:34, 1 September 2026
// ==========================================================================
// Force the branded desktop (Vector) skin for everyone, including phones.
// The site auto-switches to MediaWiki's generic Minerva mobile skin based
// on user-agent, which has none of the custom theme/nav and is a much
// worse experience than the (now mobile-responsive) desktop skin.
//
// The server does set a loop-guard cookie on the toggle ("stopMobileRedirect"),
// but it's HttpOnly (confirmed by testing), so client-side JS can never
// actually read it - that check would always be dead code. sessionStorage
// is the only guard we can use here, wrapped in its own try/catch so a
// browser that blocks storage access doesn't cancel the whole redirect.
//
// This used to bail out permanently if document.body wasn't available yet
// ("if (!document.body) return"), with no retry. Real-device testing (real
// Safari/Chrome on iOS - NOT reproducible by spoofing the user-agent string
// in a desktop Electron/Chromium test, since that never runs WebKit at all)
// showed the redirect worked fine when triggered manually via URL, but never
// fired on its own: this script runs before <body> exists in some real
// mobile browsers, so it needs to wait for DOMContentLoaded instead of
// giving up on the first check.
(function () {
// TEMPORARY DEBUG BUILD: the automatic redirect isn't firing on some real
// phones and it can't be reproduced in any test environment available
// here (Electron only fakes the user-agent string, it never actually runs
// WebKit). Instead of guessing again, this shows what the script actually
// sees directly on the page, and does NOT navigate away, so the banner
// stays put to be read/screenshotted. Remove this block and restore the
// plain redirect once the real cause is identified from that report.
function showDebugBanner(lines) {
try {
var el = document.createElement('div');
el.id = 'wov-debug-banner';
el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#111;color:#0f0;font:12px monospace;padding:8px;white-space:pre-wrap;word-break:break-all;max-height:60vh;overflow:auto;';
el.textContent = lines.join('\n');
(document.body || document.documentElement).appendChild(el);
} catch (e) {}
}
function tryForceDesktop() {
var log = [];
try {
log.push('readyState: ' + document.readyState);
log.push('bodyClass: ' + (document.body ? document.body.className : 'NO BODY'));
var isMinerva = !!(document.body && document.body.classList.contains('skin-minerva'));
log.push('isMinerva: ' + isMinerva);
if (!isMinerva) { log.push('DECISION: not Minerva, nothing to do'); showDebugBanner(log); return; }
var storageWorks = true, alreadyTried = false;
try { alreadyTried = !!sessionStorage.getItem('wovForcedDesktop'); } catch (e) { storageWorks = false; log.push('sessionStorage error: ' + e.message); }
log.push('storageWorks: ' + storageWorks + ', alreadyTried: ' + alreadyTried);
if (alreadyTried) { log.push('DECISION: already tried this session, stopping (would normally stay on Minerva)'); showDebugBanner(log); return; }
var targetUrl = window.location.pathname + window.location.search + (window.location.search ? '&' : '?') + 'mobileaction=toggle_view_desktop';
log.push('DECISION: would redirect to: ' + targetUrl);
log.push('cookie: ' + document.cookie);
showDebugBanner(log);
// Redirect intentionally disabled during this debug pass.
} catch (e) {
log.push('FATAL ERROR: ' + e.message);
showDebugBanner(log);
}
}
if (document.body) {
tryForceDesktop();
} else {
document.addEventListener('DOMContentLoaded', tryForceDesktop);
}
})();
$(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},{name:'Bronze Helmet',xp:60},{name:'Bronze Platebody',xp:60},{name:'Bronze Platelegs',xp:120},{name:'Bronze Sword',xp:90},
// Iron
{name:'Iron Bar',xp:30},{name:'Iron Rod',xp:10},{name:'Iron Plate',xp:30},{name:'Iron Helmet',xp:120},{name:'Iron Platebody',xp:300},{name:'Iron Platelegs',xp:240},{name:'Iron Sword',xp:180},
// 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},{name:'Steel Helmet',xp:220},{name:'Steel Platebody',xp:550},{name:'Steel Platelegs',xp:440},{name:'Steel Sword',xp:330},
// 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},{name:'Mithril Helmet',xp:620},{name:'Mithril Platebody',xp:1550},{name:'Mithril Platelegs',xp:1240},{name:'Mithril Sword',xp:930},
// Silver
{name:'Silver Bar',xp:675},{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:'Ebony Bar',xp:1800},{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}],
'Bronze Helmet': [{name:'Bronze Bar', count:4}],
'Bronze Platebody': [{name:'Bronze Bar', count:10}],
'Bronze Platelegs': [{name:'Bronze Bar', count:8}],
'Bronze Sword': [{name:'Bronze Bar', count:6}],
'Iron Bar': [{name:'Iron Ore', count:2}],
'Iron Helmet': [{name:'Iron Bar', count:4}],
'Iron Platebody': [{name:'Iron Bar', count:10}],
'Iron Platelegs': [{name:'Iron Bar', count:8}],
'Iron Sword': [{name:'Iron Bar', count:6}],
'Steel Bar': [{name:'Iron Ore', count:1},{name:'Coal Ore', count:1}],
'Steel Helmet': [{name:'Steel Bar', count:4}],
'Steel Platebody': [{name:'Steel Bar', count:10}],
'Steel Platelegs': [{name:'Steel Bar', count:8}],
'Steel Sword': [{name:'Steel Bar', count:6}],
'Mithril Bar': [{name:'Mithril Ore', count:1},{name:'Coal Ore', count:2}],
'Mithril Helmet': [{name:'Mithril Bar', count:4}],
'Mithril Platebody': [{name:'Mithril Bar', count:10}],
'Mithril Platelegs': [{name:'Mithril Bar', count:8}],
'Mithril Sword': [{name:'Mithril Bar', count:6}],
'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;
}
// Seconds per craft at the Anvil/Furnace, one entry per RECIPES key. Bar/Helmet/Platebody/
// Platelegs/Sword durations for Bronze-Mithril come straight off the Smithing Guide tables;
// everything else comes from the game's own recipe data.
var CRAFT_DURATION = {
'Bronze Bar':3, 'Bronze Helmet':6, 'Bronze Platebody':18, 'Bronze Platelegs':16, 'Bronze Sword':8,
'Iron Bar':4, 'Iron Rod':15, 'Iron Plate':4, 'Iron Helmet':10, 'Iron Platebody':28, 'Iron Platelegs':24, 'Iron Sword':12,
'Steel Bar':5, 'Steel Rod':3, 'Large Steel Rod':3, 'Steel Plate':5, 'Large Steel Plate':5, 'Steel Helmet':14, 'Steel Platebody':38, 'Steel Platelegs':32, 'Steel Sword':16,
'Mithril Bar':6, 'Large Mithril Rod':13, 'Mithril Rod':13, 'Large Mithril Plate':22, 'Mithril Plate':22, 'Mithril Helmet':18, 'Mithril Platebody':48, 'Mithril Platelegs':40, 'Mithril Sword':20,
'Silver Bar':10, 'Silver Plate':15, 'Silver Foil':15,
'Gold Bar':15, 'Gold Plate':15, 'Golden Shield Frame':60,
'Small Ebony Rod':40, 'Large Ebony Rod':40, 'Ebony Rod':40, 'Small Ebony Plate':60, 'Ebony Plate':60, 'Large Ebony Plate':60, 'Ebony Bar':20,
'Duskknight Boot Left Sabaton':120, 'Duskknight Boot Right Sabaton':120, 'Duskknight Leg Tuille':120, 'Duskknight Leg Poleyn':120, 'Duskknight Body Pauldron':120, 'Duskknight Body Couter':120, 'Duskknight Body Rerebrace':120, 'Duskknight Body Vambrace':120, 'Duskknight Leg Tasset':120, 'Duskknight Boot Greave':120, 'Duskknight Leg Cuisses':120, 'Duskknight Helmet Face Plate':120, 'Duskknight Helmet Head Plate':120, 'Duskknight Helmet Spike':120, 'Duskknight Body Breastplate':120
};
// 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(', ');
}
var totalSeconds = breakdown.entries.reduce(function(sum, e){
var dur = CRAFT_DURATION[e.name];
return dur ? sum + e.qty * dur : sum;
}, 0);
if (totalSeconds > 0){
text += '\nTotal craft time (Anvil/Furnace only, not counting ore gathering): ' + formatTime(totalSeconds);
}
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');
});
// ==========================================================================
// Mobile nav toggle — the sidebar (#mw-panel) renders inline far down the
// page on narrow screens, so add a fixed hamburger button that slides it in
// as an overlay drawer instead. CSS in Common.css keeps this hidden/inert
// on desktop widths (>=1000px).
// ==========================================================================
(function () {
function initMobileNav() {
var panel = document.getElementById('mw-panel');
if (!panel || document.getElementById('mobile-nav-toggle')) return;
var toggle = document.createElement('button');
toggle.id = 'mobile-nav-toggle';
toggle.type = 'button';
toggle.setAttribute('aria-label', 'Toggle navigation menu');
toggle.textContent = '\u2630 Menu';
var backdrop = document.createElement('div');
backdrop.id = 'mobile-nav-backdrop';
document.body.insertBefore(backdrop, document.body.firstChild);
document.body.insertBefore(toggle, document.body.firstChild);
function closeNav() {
panel.classList.remove('mobile-nav-open');
backdrop.classList.remove('mobile-nav-open');
}
function toggleNav() {
panel.classList.toggle('mobile-nav-open');
backdrop.classList.toggle('mobile-nav-open');
}
toggle.addEventListener('click', toggleNav);
backdrop.addEventListener('click', closeNav);
panel.querySelectorAll('a').forEach(function (a) {
a.addEventListener('click', closeNav);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMobileNav);
} else {
initMobileNav();
}
})();