hv-unified/scripts/latest.user.js
GaboGG 751d001ba3 v0.17.6 - jpx chain: never press M while jpx is active (toggle-off guard)
Fixes the race where our synthetic M landed while jpx was mid-round
(active=true) and disabled the chain. Now jpxTriggerAuto skips if
jpxLastActive===true, and the initial press waits for jpx's first state
event. NOTE: ajaxRound must stay OFF for chaining to work — jpx's AJAX
round advance dispatches a synthetic DOMContentLoaded that resets its
own isActiveBattle flag.
2026-08-04 17:15:42 -04:00

4019 lines
169 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name HV Unified
// @namespace hvunified
// @version 0.17.6
// @description HV Unified — Bridge & Strategy/Gear Advisor for HentaiVerse (complements Monsterbation & HVUtils)
// @author GaboGG + Hermes
// @match *://*.hentaiverse.org/*
// @match *://alt.hentaiverse.org/*
// @grant none
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// ═══════════════════════════════════════════════════════════════════════
// CONFIG — default settings
// ═══════════════════════════════════════════════════════════════════════
const VERSION = '0.17.6';
const CFG = {
// — Battle strategy advisory
autoBuff: true,
autoDebuff: true,
autoCure: true,
// — Thresholds (simulation-backed)
cureHP: 0.45, // Cast Cure below this HP
cureItemHP: 0.50, // Use health items at this HP
cureRegenHP: 0.65, // Cast Regen below this HP
manaGemMP: 0.45, // Use mana gems below this MP
manaPotionMP: 0.25, // Use mana potions at this MP
skillOC: 60, // Use weapon skills at this OC
useAttackSpells: false, // Don't use attack spells (let channel proc naturally)
// — Out of battle
autoSell: true,
sellQuality: 'Average',
trainingQueue: true,
// — UI & Guidance
cfgButton: true,
showGuidance: true,
showEquipAdvice: true,
autoDifficulty: true,
// — jpx integration (bridge)
autoChainJpx: true, // Re-trigger jpx auto-battle (M) after each round
chainMinHP: 0.25, // Pause chaining below this HP
chainMaxRounds: 0, // 0 = unlimited; else stop after N rounds
};
// ═══════════════════════════════════════════════════════════════════════
// STATE — global runtime state
// ═══════════════════════════════════════════════════════════════════════
const SP = 'hvunified_';
const STATE = {
page: null,
level: 1,
tier: 'novice',
isekai: false,
battleMode: '',
inBattle: false,
battleInitialized: false,
_mysticUsed: false, // Track if Mystic Gem has been used this battle
_lastActionTime: 0,
_spiritForSkill: 0, // 3-state flag for burst spirit toggle (0=off, 1=toggle-on, 2=cast-done) // Timestamp of last dispatched action (for Q debounce)
_baseMpCosts: {}, // Cached base MP costs for buff spells (read from magic pane at battle start)
maxBuffDur: {}, // Tracks highest duration seen per buff icon (persisted to localStorage)
hoverTarget: -1,
interruptHover: false,
interruptAlert: false,
monsters: [],
monsterSp: [], // SP/OC bar levels per monster (0-120, high = about to special)
hp: 1,
mp: 1,
sp: 1,
oc: 0,
spiritStance: false,
buffs: {},
spellsKnown: [],
skillsKnown: [],
itemsKnown: {},
cooldowns: {},
battleMonsterNames: [],
channeling: false,
monsterData: {},
difficulty: 'Normal',
battleStats: {
damageDealt: 0,
damageTaken: 0,
turns: 0,
drops: 0,
credits: 0,
},
round: 0,
settingsVisible: false,
};
// Load monster data from localStorage
try {
STATE.monsterData = JSON.parse(localStorage[SP + 'monsters'] || '{}');
} catch (e) {
STATE.monsterData = {};
}
// Load max buff durations from localStorage (learned across sessions)
// Pre-seed with L150+ baseline values so fill-level formula works from first turn
const DEFAULT_MAX_BUFF_DUR = {
'haste': 80,
'protection': 80,
'sparklife': 50,
'heartseeker': 120,
'regen': 50,
'absorb': 75,
'shadowveil': 75,
};
STATE.maxBuffDur = { ...DEFAULT_MAX_BUFF_DUR };
try {
const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}');
for (const [k, v] of Object.entries(saved)) {
STATE.maxBuffDur[k] = v;
}
} catch (e) {}
// ── helpers ──
function loadConfig() {
try {
const saved = JSON.parse(localStorage[SP + 'cfg'] || '{}');
for (const [k, v] of Object.entries(saved)) {
if (CFG[k] !== undefined) CFG[k] = v;
}
} catch (e) {}
}
function saveConfig() {
try { localStorage[SP + 'cfg'] = JSON.stringify(CFG); } catch (e) {}
}
function setConfig(key, value) {
if (CFG[key] !== undefined) {
CFG[key] = value;
saveConfig();
}
}
function readCSSDigits(container) {
if (!container) return null;
const digits = [];
container.querySelectorAll('div').forEach(c => {
const m = (c.className || '').match(/c4(\d)\b/);
if (m) digits.push(m[1]);
});
if (digits.length === 0) return null;
const parsed = parseInt(digits.join(''));
return isNaN(parsed) ? null : parsed;
}
// CSS font character map: c5a=a, c5b=b, ..., c5z=z
// c59 = space, c4a = period, c40-c49 = 0-9
// Note: no \\b word boundary — JS handles boundaries differently in class names
function readCSSText(container) {
if (!container) return '';
let text = '';
container.querySelectorAll('div').forEach(c => {
const cls = c.className || '';
// c5a-c5z → letters a-z
const letterMatch = cls.match(/c5([a-z])\b/);
if (letterMatch) {
text += letterMatch[1];
return;
}
// c59 → space
if (/c59\b/.test(cls)) {
text += ' ';
return;
}
// c4a → period
if (/c4a\b/.test(cls)) {
text += '.';
return;
}
// c40-c49 → digits 0-9
const digitMatch = cls.match(/c4([0-9])\b/);
if (digitMatch) {
text += digitMatch[1];
return;
}
});
return text;
}
// Save difficulty from last overworld page read
function cacheDifficulty() {
if (STATE.difficulty && STATE.difficulty !== 'Normal') {
try { localStorage[SP + 'difficulty'] = STATE.difficulty; } catch (e) {}
}
}
// Load cached difficulty (for battle pages where difficulty isn't in DOM)
function restoreCachedDifficulty() {
if (STATE.difficulty === 'Normal' || !STATE.difficulty) {
try {
const cached = localStorage[SP + 'difficulty'];
if (cached) STATE.difficulty = cached;
} catch (e) {}
}
}
// Save max buff durations to localStorage (persist learned values across restarts)
function saveMaxBuffDur() {
try { localStorage[SP + 'maxBuffDur'] = JSON.stringify(STATE.maxBuffDur); } catch (e) {}
}
// ═══════════════════════════════════════════════════════════════════════
// UTILS — DOM shortcuts, constants, helpers
// ═══════════════════════════════════════════════════════════════════════
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => [...(c || document).querySelectorAll(s)];
const clamp = (v, l, h) => Math.max(l, Math.min(h, v));
const dummy = document.createElement('div');
const KEYS = {
A: 65, B: 66, C: 67, D: 68, E: 69, F: 70, G: 71, H: 72,
I: 73, J: 74, K: 75, L: 76, M: 77, N: 78, O: 79, P: 80,
Q: 81, R: 82, S: 83, T: 84, U: 85, V: 86, W: 87, X: 88, Y: 89, Z: 90,
D0: 48, D1: 49, D2: 50, D3: 51, D4: 52, D5: 53, D6: 54, D7: 55, D8: 56, D9: 57,
SPACE: 32, RETURN: 13, ESCAPE: 27, COMMA: 188, PERIOD: 190, UP: 38, DOWN: 40,
};
function isInputFocused() {
const el = document.activeElement;
if (!el) return false;
const t = el.tagName;
return t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT' || el.isContentEditable;
}
// ── CSS style builder shorthand ──
function css(styles) {
return Object.entries(styles)
.map(([k, v]) => `${k.replace(/([A-Z])/g, '-$1').toLowerCase()}:${v}`)
.join(';');
}
// ═══════════════════════════════════════════════════════════════════════
// PAGE DETECTOR — detect current page and player level
// ═══════════════════════════════════════════════════════════════════════
function detectPage() {
// Battle page detection by DOM elements (most reliable)
if (document.getElementById('textlog') ||
document.getElementById('riddlemaster') ||
document.getElementById('pane_vitals') ||
document.getElementById('battle_root')) {
return 'battle';
}
// URL-based page detection
const url = window.location.href || '';
const pages = {
'ss=ch': 'character',
'ss=eq': 'equipshop',
'ss=is': 'itemshop',
'ss=am': 'armory',
'ss=ar': 'arena',
'ss=gr': 'grindfest',
'ss=iw': 'itemworld',
'ss=ml': 'monsterlab',
'ss=sh': 'shrine',
'ss=mm': 'mooglemail',
'ss=tr': 'training',
'ss=rb': 'ring',
'ss=re': 'repair',
'ss=up': 'upgrade',
'ss=en': 'enchant',
'ss=sv': 'salvage',
'ss=rf': 'reforge',
'ss=sf': 'soulfuse',
'ss=ab': 'abilities',
'ss=ml': 'monster',
'ss=it': 'invitems',
'ss=se': 'settings',
'ss=ss': 'spiritshop',
'ss=mk': 'market',
'ss=lt': 'lottery',
'ss=la': 'lastarena',
};
for (const [param, page] of Object.entries(pages)) {
if (url.includes(param)) return page;
}
return 'bazaar';
}
function isBattlePage() {
return !!(document.getElementById('textlog') ||
document.getElementById('riddlemaster') ||
document.getElementById('pane_vitals'));
}
function detectLevel() {
const container = document.querySelector('#level_readout > div');
if (!container) {
try {
const s = parseInt(localStorage[SP + 'playerLevel']);
if (s > 0) STATE.level = s;
} catch (e) {}
return;
}
// Battle page format: innerText = "Normal Lv.10"
const text = (container.innerText || container.textContent || '').trim();
const textMatch = text.match(/(\w+)\s+Lv\.?\s*(\d+)/);
if (textMatch) {
STATE.difficulty = textMatch[1];
STATE.level = parseInt(textMatch[2]) || 1;
updateTier();
return;
}
// CSS-font rendering (overworld page): "Nightmare Lv.52"
const cssText = readCSSText(container);
const cssMatch = cssText.match(/(\w+)\s+lv\.\s*(\d+)/i);
if (cssMatch) {
STATE.difficulty = cssMatch[1].charAt(0).toUpperCase() + cssMatch[1].slice(1);
cacheDifficulty();
}
// CSS-digit rendering for level number
const digits = readCSSDigits(container);
if (digits !== null && digits > 0) {
STATE.level = digits;
updateTier();
}
if (STATE.level > 1) {
try { localStorage[SP + 'playerLevel'] = STATE.level; } catch (e) {}
}
}
function updateTier() {
const lv = STATE.level || 1;
STATE.tier = lv >= 300 ? 'master' : lv >= 150 ? 'veteran' : lv >= 50 ? 'adept' : 'novice';
}
// ═══════════════════════════════════════════════════════════════════════
// BATTLE PARSER — read game state from the battle DOM
// ═══════════════════════════════════════════════════════════════════════
function parseBarRatio(sel, base, ctx) {
const e = (ctx || document).querySelector(sel);
return e ? clamp(parseInt(e.style.width || 0) / base, 0, 1) : 1;
}
function parseBattleState() {
STATE.inBattle = true;
STATE.isekai = !!document.getElementById('vcp');
// ── Parse monster names from battle log (plain text) ──
STATE.battleMonsterNames = [];
// Also track channeling status from log
STATE.channeling = false;
const log = document.getElementById('textlog');
if (log) {
log.querySelectorAll('tr').forEach(row => {
const text = (row.textContent || '');
const m = text.match(/Spawned Monster \w: MID=\d+ \(([^)]+)\)/);
if (m) STATE.battleMonsterNames.push(m[1].toLowerCase());
// Detect channeling status from log messages
if (text.includes('The effect Channeling')) {
STATE.channeling = text.includes('gains the effect');
}
});
}
// Also check the effects pane for channeling (more reliable — log entries scroll off)
if ($('#pane_effects img[src$="channeling.png"]')) {
STATE.channeling = true;
}
// ── Level & difficulty ──
const container = document.querySelector('#level_readout > div');
if (container) {
const text = (container.innerText || container.textContent || '').trim();
const textMatch = text.match(/(\w+)\s+Lv\.?\s*(\d+)/);
if (textMatch) {
STATE.difficulty = textMatch[1];
STATE.level = parseInt(textMatch[2]) || 1;
} else {
// CSS-font rendering for overworld/battle font
const cssText = readCSSText(container);
const cssMatch = cssText.match(/(\w+)\s+lv\.\s*(\d+)/i);
if (cssMatch) {
STATE.difficulty = cssMatch[1].charAt(0).toUpperCase() + cssMatch[1].slice(1);
cacheDifficulty();
}
const d = readCSSDigits(container);
if (d !== null && d > 0) STATE.level = d;
}
}
if (!STATE.level || STATE.level < 1) {
try {
const s = parseInt(localStorage[SP + 'playerLevel']);
if (s > 0) STATE.level = s;
} catch (e) {}
}
if (!STATE.level || STATE.level < 1) STATE.level = 1;
try { localStorage[SP + 'playerLevel'] = STATE.level; } catch (e) {}
updateTier();
restoreCachedDifficulty();
// ── Vitals ──
const vitalsPane = document.getElementById('pane_vitals');
const isk = STATE.isekai;
STATE.hp = parseBarRatio('img[src$="green.png"]', isk ? 496 : 414, vitalsPane);
STATE.mp = parseBarRatio('img[src$="bar_blue.png"]', isk ? 207 : 414, vitalsPane);
STATE.sp = parseBarRatio('img[src$="bar_red.png"]', isk ? 207 : 414, vitalsPane);
// ── Overcharge ──
const vcp = document.getElementById('vcp');
STATE.oc = vcp
? clamp(parseInt((vcp.querySelector('div') || {}).style?.width || 0) / 190, 0, 1) * 100
: 0;
// ── Spirit Stance ──
STATE.spiritStance = ((document.getElementById('ckey_spirit') || {}).src || '').includes('_s.png');
// ── Monsters ──
STATE.monsters = $$('.btm1[onclick]');
// Parse each monster's SP/OC bar level (the red bar = special attack charge)
STATE.monsterSp = [];
$$('.btm1').forEach(m => {
const spBar = m.querySelector('.btm5 img[src$="barred.png"]');
const spPct = spBar ? (parseInt(spBar.style.width) || 0) : 0;
STATE.monsterSp.push(spPct);
});
// ── Spells from spell pane ──
STATE.spellsKnown = [];
const magicPane = document.getElementById('pane_magic');
if (magicPane) {
// Read all btsd elements with onmouseover (including on-cooldown spells)
$$('.btsd', magicPane).forEach(el => {
const omo = el.getAttribute('onmouseover') || '';
if (!omo) return;
const m = omo.match(/battle\.set_infopane_spell\('([^']+)',\s*'[^']*',\s*'[^']*',\s*(\d+),\s*(\d+),\s*(\d+)\)/);
if (m) {
STATE.spellsKnown.push({ n: m[1], mp: parseInt(m[2]), chr: parseInt(m[3]), cd: parseInt(m[4]) });
// Cache the base MP cost (from magic pane, unaffected by channeling/Conservation)
if (!STATE._baseMpCosts[m[1]]) {
STATE._baseMpCosts[m[1]] = parseInt(m[2]);
}
}
});
}
// ── Skills from skill pane (Flee, Scan, etc.) ──
const skillPane = document.getElementById('pane_skill');
if (skillPane) {
$$('.btsd[onclick]', skillPane).forEach(el => {
const omo = el.getAttribute('onmouseover') || '';
const m = omo.match(/battle\.set_infopane_spell\('([^']+)'/);
if (m) STATE.spellsKnown.push({ n: m[1], mp: 0, chr: 0, cd: 0 });
});
}
// ── Skills/spells from quickbar ──
const WEAPON_SKILLS = [
'Great Cleave', 'Frenzied Blows', 'Shield Bash', 'Iris Strike',
'Skyward Sword', 'Shatter Strike', 'Rending Blow', 'Backstab',
'Merciful Blow', 'Vital Strike', 'Concussive Strike',
];
STATE.skillsKnown = [];
$$('#quickbar > .btqs[onclick]').forEach(el => {
const omo = el.getAttribute('onmouseover') || '';
const m = omo.match(/battle\.set_infopane_spell\('([^']+)'/);
if (m) {
const name = m[1];
if (WEAPON_SKILLS.includes(name)) {
STATE.skillsKnown.push(name);
} else if (!STATE.spellsKnown.some(s => s.n === name)) {
const costMatch = omo.match(/battle\.set_infopane_spell\('([^']+)',\s*'[^']*',\s*'[^']*',\s*(\d+),\s*(\d+),\s*(\d+)\)/);
STATE.spellsKnown.push({
n: name,
mp: costMatch ? parseInt(costMatch[2]) : 0,
chr: costMatch ? parseInt(costMatch[3]) : 0,
cd: costMatch ? parseInt(costMatch[4]) : 0,
});
}
}
});
// ── Items ──
STATE.itemsKnown = {};
// Read all item slots, including on-cooldown ones (which lose their id)
const itemPane = document.getElementById('pane_item');
if (itemPane) {
$$('.bti3 > div[onmouseover], .bti3 div[id^="ikey_"]', itemPane).forEach(el => {
const omo = el.getAttribute('onmouseover') || '';
const m = omo.match(/set_infopane_item\((\d+)\)/);
if (m) {
const slot = el.id ? el.id.replace('ikey_', '') : ('auto_' + m[1]);
STATE.itemsKnown[slot] = m[1];
}
});
}
// Buffs
STATE.buffs = {};
const pane = document.getElementById('pane_effects');
if (pane) {
$$('img[onmouseover]', pane).forEach(img => {
const omo = img.getAttribute('onmouseover') || '';
const dur = omo.match(/(\d+)\s*turns?\s*rem/);
// Also try to get turn count from the last parameter of set_infopane_effect
const rawDur = omo.match(/set_infopane_effect\([^,]+,\s*'[^']*',\s*(\d+)\s*\)/);
// 'permanent' means the buff lasts indefinitely (Absorb, autocast effects)
const isPermanent = omo.includes("'permanent'");
const name = (img.src || '').split('/').pop().replace('.png', '');
const turns = isPermanent ? 999 : (dur ? parseInt(dur[1]) : (rawDur ? parseInt(rawDur[1]) : 50));
STATE.buffs[name] = turns;
// Track the max duration seen for this buff (for fill-level calculation)
if (turns < 999 && turns > (STATE.maxBuffDur[name] || 0)) {
STATE.maxBuffDur[name] = turns;
saveMaxBuffDur(); // persist across page loads
}
});
}
// Spark of Life check
if ($('img[src$="fallenshield.png"]') && !$('img[src$="bar_dgreen.png"]'))
STATE.buffs['spark_of_life'] = 0;
// ── Cooldowns ──
STATE.cooldowns = {};
$$('.btqb').forEach(el => {
const txt = (el.getAttribute('onmouseover') || el.getAttribute('title') || '');
const cd = txt.match(/Cooldown:\s*(\d+)/);
if (cd) {
const key = (el.querySelector('img')?.src || '').split('/').pop()?.replace('.png', '') || '?';
STATE.cooldowns[key] = parseInt(cd[1]);
}
});
STATE.difficulty = STATE.difficulty || 'Normal';
STATE.battleInitialized = true;
}
// ═══════════════════════════════════════════════════════════════════════
// ITEMS — item ID mapping
// ═══════════════════════════════════════════════════════════════════════
const ITEMS = {
// Gems
10005: { n: 'Health Gem', t: 'heal' },
10006: { n: 'Mana Gem', t: 'mana' },
10007: { n: 'Spirit Gem', t: 'spirit' },
10008: { n: 'Mystic Gem', t: 'channel' },
// Health potions
11191: { n: 'Health Draught', t: 'heal', cost: 25 },
11195: { n: 'Health Potion', t: 'heal', cost: 50 },
11199: { n: 'Health Elixir', t: 'heal', cost: 500 },
// Mana potions
11291: { n: 'Mana Draught', t: 'mana', cost: 50 },
11295: { n: 'Mana Potion', t: 'mana', cost: 100 },
11299: { n: 'Mana Elixir', t: 'mana', cost: 1000 },
// Spirit potions
11391: { n: 'Spirit Draught', t: 'spirit', cost: 50 },
11395: { n: 'Spirit Potion', t: 'spirit', cost: 100 },
11399: { n: 'Spirit Elixir', t: 'spirit', cost: 1000 },
// Crystals
50001: { n: 'Crystal of Vigor', s: 'STR' },
50002: { n: 'Crystal of Finesse', s: 'DEX' },
50003: { n: 'Crystal of Swiftness', s: 'AGI' },
50004: { n: 'Crystal of Fortitude', s: 'END' },
50005: { n: 'Crystal of Cunning', s: 'INT' },
50006: { n: 'Crystal of Knowledge', s: 'WIS' },
};
function getItemType(id) {
const item = ITEMS[parseInt(id)];
return item ? item.t : null;
}
function hasUsableGem(type) {
for (const [id, info] of Object.entries(STATE.itemsKnown)) {
const d = ITEMS[parseInt(info)];
if (!d || d.t !== type) continue;
// P slot (gem) only valid if it has content inside it
if (id === 'p') {
const p = document.getElementById('ikey_p');
if (!p || !p.querySelector('div')) continue;
}
return { id, item: d };
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════
// KNOWLEDGE BASE — game data, spell unlocks, milestones, stat advice
// ═══════════════════════════════════════════════════════════════════════
// Base MP costs for buff spells (used for channeling value calculation when
// the DOM shows Mana Conservation-reduced costs like 1 MP)
const SPELL_COSTS = {
'Absorb': 40,
'Haste': 51,
'Heartseeker': 141,
'Protection': 32,
'Regen': 71,
'Shadow Veil': 52,
'Spark of Life': 70,
};
const KB = {
qualities: ['Crude', 'Fair', 'Average', 'Superior', 'Exquisite', 'Magnificent', 'Legendary', 'Peerless'],
// Stat priority per weapon type (order = importance)
statPriority: {
'1H': ['STR', 'END', 'DEX', 'AGI', 'WIS', 'INT'],
'2H': ['STR', 'DEX', 'END', 'AGI', 'WIS', 'INT'],
'DW': ['STR', 'DEX', 'AGI', 'END', 'WIS', 'INT'],
'Niten': ['STR', 'DEX', 'AGI', 'END', 'WIS', 'INT'],
'Staff': ['INT', 'WIS', 'END', 'AGI', 'DEX', 'STR'],
},
// Forum note: stats "barely even matter". These are rough guidelines, not precise.
statAllocation: {
novice: {
'1H': { STR: 30, END: 25, DEX: 20, AGI: 10, WIS: 10, INT: 5 },
'Staff': { INT: 30, WIS: 25, END: 25, AGI: 10, DEX: 5, STR: 5 },
},
adept: {
'1H': { STR: 35, END: 20, DEX: 20, AGI: 10, WIS: 10, INT: 5 },
'Staff': { INT: 35, WIS: 25, END: 20, AGI: 10, DEX: 5, STR: 5 },
},
veteran: {
'1H': { STR: 40, DEX: 25, END: 15, AGI: 10, WIS: 5, INT: 5 },
'Staff': { INT: 40, WIS: 30, END: 15, AGI: 10, DEX: 3, STR: 2 },
},
master: {
'1H': { STR: 45, DEX: 30, END: 10, AGI: 10, WIS: 3, INT: 2 },
'Staff': { INT: 45, WIS: 35, END: 10, AGI: 5, DEX: 3, STR: 2 },
},
},
// Spell unlock levels (forum-corrected: no MagNet)
spellUnlocks: [
{ lvl: 5, name: 'Cure' },
{ lvl: 10, name: 'Protection' },
{ lvl: 15, name: 'Fiery Blast' },
{ lvl: 25, name: 'Freeze' },
{ lvl: 50, name: 'Regen' },
{ lvl: 60, name: 'Haste' },
{ lvl: 70, name: 'Weaken' },
{ lvl: 130, name: 'Imperil' },
{ lvl: 140, name: 'Heartseeker' },
{ lvl: 175, name: 'Arcane Focus' },
{ lvl: 220, name: 'Full-Cure' },
],
// IW Potency rankings (forum research data)
iwPotency: {
'1H': ['Butcher', 'Fatality', 'Overpower'],
'2H': ['Overpower', 'Butcher', 'Fatality'],
'DW': ['Overpower', 'Fatality', 'Butcher'],
'Niten': ['Overpower', 'Fatality', 'Butcher'],
'Staff': ['Economizer', 'Aether', 'Juggernaut'],
},
// Difficulty EXP multipliers
difficultyMult: {
'Normal': 1,
'Hard': 2,
'Nightmare': 4,
'Hell': 7,
'Nintendo': 10,
'IWBTH': 15,
'PFUDOR': 20,
},
// Milestone tips
milestones: [
{ lvl: 1, tip: 'Do Arena "First Blood". Use items before spells.' },
{ lvl: 5, tip: 'Cure unlocked! Keep Health Draughts.' },
{ lvl: 10, tip: 'Protection unlocked — set as autocast.' },
{ lvl: 15, tip: 'First damage spell: Fiery Blast.' },
{ lvl: 50, tip: 'Regen + Adept tier. Train Adept Learner.' },
{ lvl: 60, tip: 'Haste unlocked — +50% action speed.' },
{ lvl: 70, tip: 'Weaken: -50% enemy damage. Use items, then Cure.' },
{ lvl: 100, tip: 'Level 100! T2 spells available. Accuracy target: 150%.' },
{ lvl: 130, tip: 'Imperil — THE most important debuff. Always cast first.' },
{ lvl: 140, tip: 'Heartseeker — +25% physical damage, +10% crit. Self-buff, priority over Arcane Focus for 2H.' },
{ lvl: 300, tip: 'Master tier. Optimize for speed. Run PFUDOR.' },
],
};
// ═══════════════════════════════════════════════════════════════════════
// STRATEGY ENGINE — tier-based action recommendation
// ═══════════════════════════════════════════════════════════════════════
// ── Spell helpers ──
function hasSpell(name) {
return STATE.spellsKnown.some(s => s.n === name);
}
// Check if a spell is not on cooldown (has onclick in the DOM AND inner img isn't dimmed)
function hasReadySpell(name) {
const el = $$('.btsd, .btqs').find(e =>
(e.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${name}'`));
if (!el) return false;
if (!el.hasAttribute('onclick')) return false;
// Double-check: the inner img might have opacity:0.5 (cooldown tint)
// even if the parent has onclick from a previous parse
const img = el.querySelector('img.btqi, img');
if (img && img.style.opacity === '0.5') return false;
return true;
}
function findSpell(name) {
return STATE.spellsKnown.find(s => s.n === name);
}
function findDmgSpell(list) {
const s = STATE.spellsKnown.find(sp => list.includes(sp.n));
return s ? s.n : null;
}
// ── Buff helpers ──
// Returns the buff duration from STATE.buffs, or 0 if not present
function buffDuration(name) {
return STATE.buffs[name] || 0;
}
// Should we cast this buff? Only if missing OR about to expire (< threshold turns)
function shouldBuff(name, minTurns) {
const dur = buffDuration(name);
return dur === 0 || dur < minTurns;
}
// Known buffs with their priority, icon name, spell name, and refresh threshold
// Threat detection: SP bar max is 120px, monsters >80% are dangerous
const THREAT_PX = Math.round(120 * 80 / 100); // ~96px — triggers when genuinely close to specialing
// NOTE: Heartseeker and Arcane Focus are mutually exclusive. For a 2H (physical) build,
// Heartseeker is preferred (+25% phys dmg, +10% crit). Arcane Focus is for magic builds.
const BUFF_PRIORITY = [
{ icon: 'haste', spell: 'Haste', minTurns: 4 }, // 100% uptime: refresh well before expiry
{ icon: 'protection', spell: 'Protection', minTurns: 4 },
{ icon: 'sparklife', spell: 'Spark of Life', minTurns: 3 }, // icon: sparklife.png
{ icon: 'heartseeker', spell: 'Heartseeker', minTurns: 6 }, // Long duration, refresh at 6 to avoid gap
{ icon: 'regen', spell: 'Regen', minTurns: 4 },
{ icon: 'absorb', spell: 'Absorb', minTurns: 4 },
{ icon: 'shadowveil', spell: 'Shadow Veil', minTurns: 4 },
];
// Known important monster names (bosses, legendaries, ultimates)
const RARE_MONSTERS = [
'manbearpig', 'white bunneh', 'mithra', 'dalek',
'konata', 'mikuru asahina', 'ryouko asakura', 'yuki nagato',
'skuld', 'urd', 'verdandi', 'yggdrasil',
'rhaegal', 'viserion', 'drogon',
'real life', 'invisible pink unicorn', 'flying spaghetti monster',
'recycled boss rush', 'bottomless dungeon', 'new game +',
'achievement grind', 'time trial mode', 'hardcore mode',
];
function isRareMonster(idx) {
const m = STATE.monsters[idx];
if (!m) {
// Fallback: check battle log names
return STATE.battleMonsterNames.some(name =>
RARE_MONSTERS.some(r => name.includes(r))
);
}
// Boss/rare monsters have distinct visual cues:
// 1. Gold border: style="border-color:#BD7400"
// 2. Gold background on label: style="background:#E6CCA3"
// NOTE: At high levels/difficulties, normal monsters also get SP bars,
// so we can't use nbarred.png as a boss indicator anymore.
const style = m.getAttribute('style') || '';
const inner = m.innerHTML || '';
// Check for gold border (boss indicator — still reliable)
if (style.includes('BD7400') || style.includes('E6CCA3')) return true;
// Fallback: check name from DOM
const n = m.querySelector('.btm3');
if (!n) return false;
const name = (n.textContent || '').replace(/^\d+\s*/, '').trim().toLowerCase();
// Use CSS text parser for boss names rendered in font
const cssName = readCSSText(n);
return RARE_MONSTERS.some(r => name.includes(r) || cssName.includes(r));
}
function anyRareMonster() {
return STATE.monsters.some((m, i) => isRareMonster(i));
}
function findWeakestMonster() {
let best = -1, bestW = Infinity;
STATE.monsters.forEach((m, i) => {
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
const hp = m.querySelector('img[src$="nbargreen.png"]');
const w = hp ? parseInt(hp.style.width || '') : 120;
if (w < bestW && w > 0) { bestW = w; best = i; }
});
return best >= 0 ? best : 0;
}
function findStrongestMonster() {
let best = -1, bestW = -1;
STATE.monsters.forEach((m, i) => {
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
const hp = m.querySelector('img[src$="nbargreen.png"]');
const w = hp ? parseInt(hp.style.width || '') : 120;
if (w > bestW) { bestW = w; best = i; }
});
return best >= 0 ? best : 0;
}
// Find the monster with the highest SP/OC bar (most dangerous — about to special)
// The red bar (nbarred.png) has a max width of 120px
// Returns the index, or 0 if none found
function findDangerousMonster() {
let best = -1, bestSP = -1;
STATE.monsters.forEach((m, i) => {
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
const sp = STATE.monsterSp[i] || 0;
if (sp > bestSP) { bestSP = sp; best = i; }
});
return best >= 0 ? best : 0;
}
// Check if there are 2+ monsters with SP bar > threshold (imminent special attacks)
function hasHighThreat(threshold) {
let count = 0;
STATE.monsters.forEach((m, i) => {
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
const sp = STATE.monsterSp[i] || 0;
if (sp > threshold) count++;
});
return count >= 2;
}
// Find optimal target for Domino Strike splash (2H passive) — center-aligned
// For 2H builds, Domino Strike splashes up to 2 enemies on each side of the target.
// Attacking edge monsters wastes 50% of the splash potential.
function findOptimalDominoTarget() {
const total = STATE.monsters.length;
if (total <= 2) return findWeakestMonster();
let best = -1;
let bestScore = -1;
STATE.monsters.forEach((m, i) => {
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
let splash = 0;
if (i - 1 >= 0 && STATE.monsters[i - 1] && STATE.monsters[i - 1].hasAttribute('onclick')) splash++;
if (i - 2 >= 0 && STATE.monsters[i - 2] && STATE.monsters[i - 2].hasAttribute('onclick')) splash++;
if (i + 1 < total && STATE.monsters[i + 1] && STATE.monsters[i + 1].hasAttribute('onclick')) splash++;
if (i + 2 < total && STATE.monsters[i + 2] && STATE.monsters[i + 2].hasAttribute('onclick')) splash++;
if (splash > bestScore) { bestScore = splash; best = i; }
});
return best >= 0 ? best : 0;
}
function checkMonsterDebuff(idx, db) {
const m = STATE.monsters[idx];
if (!m) return false;
const s = m.querySelector('.btm6');
if (!s) return false;
return s.innerHTML.toLowerCase().includes(db.toLowerCase() + '.png') ||
s.innerHTML.toLowerCase().includes('wpn_' + db.toLowerCase());
}
// ── Damage spell tier list ──
const SPELL_T3 = ['Ragnarok', 'Paradise Lost', 'Flames of Loki', 'Fimbulvetr', 'Wrath of Thor', 'Storms of Njord'];
const SPELL_T2 = ['Disintegrate', 'Banishment', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst'];
const SPELL_T1 = ['Corruption', 'Smite', 'Fiery Blast', 'Freeze', 'Shockblast', 'Gale'];
function getBestDamageSpell() {
if (STATE.monsters.length >= 2) {
const aoe = findDmgSpell(SPELL_T3.concat(SPELL_T2));
if (aoe) return aoe;
}
return findDmgSpell(SPELL_T3.concat(SPELL_T2, SPELL_T1));
}
// ── Channeling maintenance ──
//
// Channeling: procs randomly when casting any mana-costing spell.
// Proc chance = spell_cost / (base_mana * 1.2)
// Cannot proc from spells cast while already Channeling.
// Effect: next spell costs 1 MP and is 50% stronger (or 50% longer for buffs)
// Duration: 5 ticks (15 with Mystic Gem in P-slot)
//
// Best trigger spells: Regen/Absorb (17 MP = ~6% proc chance each cast)
// Cheaper spells have proportionally lower proc chance.
// Checks if we're in the initial buff-up phase (most buffs are missing)
function isFirstRoundBuffs() {
let missing = 0, total = 0;
for (const b of BUFF_PRIORITY) {
if (!hasSpell(b.spell)) continue;
total++;
if (buffDuration(b.icon) === 0) missing++;
}
return total > 0 && missing >= total * 0.6; // 60%+ of our buffs are down
}
// Round 1 strategy: cast buffs from cheapest to most expensive.
// - Cheaper spells first = more casts before channeling = more proc chances
// - If channeling procs mid-sequence, switch to most expensive remaining buff
// Returns the next buff to cast, or null if all buffs are up.
function castInitialBuffs() {
if (!isFirstRoundBuffs()) return null;
if (STATE.mp < 0.20) return null;
// Collect all missing/expiring buffs with their MP cost
const candidates = [];
for (const b of BUFF_PRIORITY) {
if (!hasReadySpell(b.spell)) continue;
const dur = buffDuration(b.icon);
if (dur === 0 || dur < 3) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
const sp = findSpell(b.spell);
candidates.push({ ...b, mpCost: sp ? sp.mp : 999, dur });
}
}
if (candidates.length === 0) return null;
// Log which mode we're in
if (STATE.channeling) {
// Channeling is charged — cast the MOST expensive buff for best value
candidates.sort((a, b) => b.mpCost - a.mpCost);
return { type: 'spell', name: candidates[0].spell, selfTarget: true, _reason: 'initCh' };
} else {
// No channeling — cast the CHEAPEST buff first for more proc attempts
candidates.sort((a, b) => a.mpCost - b.mpCost);
return { type: 'spell', name: candidates[0].spell, selfTarget: true, _reason: 'init' };
}
}
function kickstartChanneling() {
if (STATE.channeling) return null;
// Priority: cast or refresh a buff with a good MP cost for proc chance.
// Higher MP cost = higher channeling proc chance.
// Regen/Absorb/Heartseeker have the best cost/proc ratio among buffs.
if (STATE.mp > 0.20) {
// Collect all buffs that need refreshing, sorted by MP cost descending
const candidates = [];
for (const b of BUFF_PRIORITY) {
const dur = buffDuration(b.icon);
if ((dur === 0 || dur < 5) && STATE.mp > 0.15 && hasSpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
const sp = findSpell(b.spell);
candidates.push({ ...b, mpCost: sp ? sp.mp : 0 });
}
}
if (candidates.length > 0) {
// Cheapest first to maximize channeling proc chance
candidates.sort((a, b) => a.mpCost - b.mpCost);
return { type: 'spell', name: candidates[0].spell, selfTarget: true };
}
}
return null;
}
// ── Channeling consumption — pick the best value from fill level × cost ──
// Channeling gives: 1 MP cost + 50% longer duration.
// The best target is the buff where:
// - It's nearly empty (close to expiring relative to its max duration)
// - It costs a lot of MP (more savings from the free cast)
//
// Score = emptiess_ratio × mp_cost
// = (max_dur - current) / max_dur × mp_cost
//
// A 100-MP buff with 90 turns max at 1 turn remaining: (90-1)/90 * 100 = 98.9
// A 40-MP buff with 50 turns max at 25 remaining: (50-25)/50 * 40 = 20.0
// A 140-MP buff with 80 turns max at 71 remaining: (80-71)/80 * 140 = 15.75
// A freshly cast buff always scores near 0.
//
// max_dur is tracked dynamically per buff in STATE.maxBuffDur.
// It updates each time we see a new high, learning your actual duration.
function findBestChannelingTarget() {
if (!STATE.channeling || STATE.mp < 0.10) return null;
const candidates = [];
for (const b of BUFF_PRIORITY) {
if (!hasReadySpell(b.spell)) continue;
const dur = buffDuration(b.icon);
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
const sp = findSpell(b.spell);
// Use cached base MP cost (snapshotted at battle start from the magic pane)
// This avoids the channeling 1-MP display issue and Mana Conservation fluctuation
let mpCost = STATE._baseMpCosts[b.spell] || 0;
if (mpCost === 0 && sp) {
// Not cached yet — read from magic pane directly
const paneEl = $$('.btsd[onmouseover*="' + b.spell.replace(/'/g, "\\'") + '"]',
document.getElementById('pane_magic'))[0];
if (paneEl) {
const costM = (paneEl.getAttribute('onmouseover') || '')
.match(/set_infopane_spell\('[^']+',\s*'[^']*',\s*'[^']*',\s*(\d+)/);
if (costM) mpCost = parseInt(costM[1]);
}
if (mpCost === 0) mpCost = sp.mp; // last resort: use what parseBattleState gave us
STATE._baseMpCosts[b.spell] = mpCost;
}
// Skip if we can't afford the base cost — game pre-checks even when
// channeling makes the effective cost 1 MP
const curMp = parseInt((document.getElementById('vrm') || {}).textContent) || 0;
const estMaxMp = (STATE.mp > 0 && curMp > 0) ? Math.round(curMp / STATE.mp) : 414;
if (mpCost > 0 && curMp > 0 && mpCost > curMp) continue;
// Fill level: how empty is this buff relative to its max observed duration
let maxDur = STATE.maxBuffDur[b.icon] || 0;
// If max matches current (first observation, no learning yet), assume min 30 turns
if (maxDur <= dur) maxDur = Math.max(dur + 1, 30);
const emptiness = maxDur > 0 ? Math.min(1, (maxDur - dur) / maxDur) : 1;
// Score: emptiness × mp_cost — an empty expensive buff is best
const score = emptiness * mpCost;
candidates.push({ ...b, mpCost, dur, score, emptiness, maxDur });
}
if (candidates.length > 0) {
candidates.sort((a, b) => b.score - a.score);
console.log(`%c[HV] ch score: ${candidates[0].spell}=${candidates[0].score.toFixed(1)} (dur=${candidates[0].dur}, max=${candidates[0].maxDur}, mp=${candidates[0].mpCost}, empty=${(candidates[0].emptiness*100).toFixed(0)}%)`, 'color:#bb0');
// Only use a buff if it actually needs refreshing (score >= 30 means
// e.g. 30% emptiness on a 100-MP spell, or 60% on a 50-MP spell)
if (candidates[0].score >= 30) {
return { type: 'spell', name: candidates[0].spell, selfTarget: true, _reason: `ch score ${candidates[0].score.toFixed(0)}` };
}
}
// Fallback: for physical 2H builds, don't waste channeling on weak damage spells.
// Channeling improves the next spell, but Fiery Blast does negligible damage at IWBTH.
// Better to use the charge on a basic attack that procs Domino Strike and Penetrated Armor.
const isPhysical2H = STATE.skillsKnown.includes('Great Cleave') || STATE.skillsKnown.includes('Rending Blow');
if (isPhysical2H) {
return null; // Let channeling expire naturally — basic attacks do more than Fiery Blast
}
// Mage fallback: use any cheap spell for mage builds
const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale',
'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst',
'Banishment', 'Disintegrate']);
if (cheap) {
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: cheap, target: t };
}
// Last resort: any spell we know that costs MP
for (const sp of STATE.spellsKnown) {
if (sp.mp > 0 && sp.mp < (STATE.mp * 100)) {
const t = findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: sp.n, target: t };
}
}
return null;
}
// ── Core strategy: forum-corrected item-before-spell priority ──
//
// Priority order:
// 1. Mystic Gem if channeling is down (free OC gen)
// 2. Health/Mana/Spirit gems proactively (don't hoard)
// 3. Cure if HP critical
// 4. Buffs (Protection, Regen, Haste)
// 5. Debuffs (Imperil, Weaken)
// 6. Spirit Stance at OC 60-80% (don't waste OC on skills before stance)
// 7. Weapon skills only at high OC (80+) to let OC build
// 8. Damage spells
// 9. Basic attack builds OC naturally
function getRecommendedAction() {
parseBattleState();
if (STATE.tier === 'novice') return strategyNovice();
if (STATE.tier === 'adept') return strategyAdept();
return strategyVeteran(); // Veteran+ use the same engine
}
// Returns non-null ONLY when there's something smarter than basic attack
function getSmartAction() {
const a = getRecommendedAction();
if (!a || a.type === 'attack') return null;
return a;
}
// ── Novice (1-50): survival first ──
function strategyNovice() {
// 0. Mystic Gem for channeling — only use once per battle
// (channeling status is tracked from the battle log)
const gem = hasUsableGem('channel');
if (gem && !STATE.channeling && !STATE._mysticUsed)
return { type: 'item', id: gem.id, selfTarget: true };
// 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) {
const gem = hasUsableGem('heal');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// 2. Cure
if (STATE.hp < CFG.cureHP) {
const c = hasReadySpell('Full-Cure') ? 'Full-Cure' : hasReadySpell('Cure') ? 'Cure' : null;
if (c) return { type: 'spell', name: c, selfTarget: true };
}
// 3. Buffs — cast if missing or about to expire
// On first round, cast cheapest-first to proc channeling
{
const ib = castInitialBuffs();
if (ib) return ib;
}
// If channeling is active, prioritize most expensive buff for best value
if (STATE.channeling && STATE.mp > 0.10) {
const ch = findBestChannelingTarget();
if (ch) return ch;
}
// Collect available buff candidates and sort by cost
const buffCandidates = [];
for (const b of BUFF_PRIORITY) {
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.10 && hasReadySpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
const sp = findSpell(b.spell);
buffCandidates.push({ ...b, mpCost: sp ? sp.mp : 0 });
}
}
if (buffCandidates.length > 0) {
buffCandidates.sort((a, b) => STATE.channeling ? b.mpCost - a.mpCost : a.mpCost - b.mpCost);
return { type: 'spell', name: buffCandidates[0].spell, selfTarget: true, _reason: STATE.channeling ? 'chBuff' : 'buff' };
}
// 4. Mana items — AFTER buffs (Novice)
if (STATE.mp < CFG.manaGemMP) {
const gem = hasUsableGem('mana');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// 5. Weapon skills — these are better than basic attacks
const skillOC = CFG.skillOC || 80;
if (STATE.oc >= skillOC) {
// Great Cleave: boss/rare fights only
if (anyRareMonster() && STATE.skillsKnown.includes('Great Cleave')) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t };
}
// Rending Blow: AoE armor pen vs 5+ enemies
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
}
// Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) {
const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Shatter Strike', target: t };
}
}
}
// 6. Damage spells (reserve MP for Cure)
const dmg = CFG.useAttackSpells ? findDmgSpell(SPELL_T1) : null;
if (dmg && STATE.mp > 0.25) {
const cure = findSpell('Cure') || findSpell('Full-Cure');
const cureCost = cure ? (cure.mp / 100) * (STATE.level || 1) : 4;
const mpMax = STATE.mp * 100;
const reserve = Math.max(0.15, cureCost / mpMax);
if (STATE.mp - reserve > 0.25) {
const t = findWeakestMonster();
if (t >= 0) return { type: 'spell', name: dmg, target: t };
}
}
// 7. Channeling kickstart or basic attack
{
const k = kickstartChanneling();
if (k) return k;
}
// If channeling is charged, consume it on the highest-MP buff that needs refreshing
{
const ch = findBestChannelingTarget();
if (ch) return ch;
}
const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : findWeakestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t };
return null;
}
// ── Adept (50-150): building power ──
function strategyAdept() {
// 0. Mystic Gem for channeling — only use once per battle
const gem = hasUsableGem('channel');
if (gem && !STATE.channeling && !STATE._mysticUsed)
return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' };
// 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) {
const gem = hasUsableGem('heal');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// 2. Cure
if (STATE.hp < CFG.cureHP) {
const c = hasReadySpell('Full-Cure') ? 'Full-Cure' : hasReadySpell('Cure') ? 'Cure' : null;
if (c) return { type: 'spell', name: c, selfTarget: true };
}
// 3. Buffs
// On first round, cast cheapest-first to proc channeling
{
const ib = castInitialBuffs();
if (ib) return ib;
}
// If channeling is active, prioritize most expensive buff for best value
// Note: channeling reduces cost to ~1, but the game pre-checks base MP,
// If channeling is active, prioritize most expensive buff for best value
if (STATE.channeling && STATE.mp > 0.10) {
const ch = findBestChannelingTarget();
if (ch) return ch;
}
// Collect available buff candidates and sort by cost
const buffCandidates = [];
for (const b of BUFF_PRIORITY) {
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.10 && hasReadySpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
const sp = findSpell(b.spell);
buffCandidates.push({ ...b, mpCost: sp ? sp.mp : 0 });
}
}
if (buffCandidates.length > 0) {
buffCandidates.sort((a, b) => STATE.channeling ? b.mpCost - a.mpCost : a.mpCost - b.mpCost);
return { type: 'spell', name: buffCandidates[0].spell, selfTarget: true, _reason: STATE.channeling ? 'chBuff' : 'buff' };
}
// 4. Mana items — AFTER buffs (Adept)
if (STATE.mp < CFG.manaGemMP) {
const gem = hasUsableGem('mana');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// 4. Debuffs — conservative: Imperil on bosses/rares or 5+ mobs
// Weaken only on high-threat or bosses. Estoc handles <5.
if (CFG.autoDebuff && STATE.mp > 0.35) {
if (hasReadySpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 5)) {
const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil'))
return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' };
}
if (hasReadySpell('Weaken') && (anyRareMonster() || hasHighThreat(THREAT_PX))) {
const b = findDangerousMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken'))
return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' };
}
}
// 5. Weapon skills — prioritize high-threat monsters (high SP/OC bar)
const isHighThreat = hasHighThreat(THREAT_PX);
if (isHighThreat) console.log('%c[HV] ⚠ HIGH THREAT (Adept): 2+ monsters with SP >85%', 'color:#f44');
const skillOC_N = CFG.skillOC || 75;
if (STATE.oc >= skillOC_N || isHighThreat) {
if (isHighThreat && STATE.oc < skillOC_N) console.log('%c[HV] ⚠ emergency skill at OC=' + STATE.oc.toFixed(0), 'color:#f80');
// Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5)
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
}
// Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Shatter Strike', target: t };
}
}
// Great Cleave: boss/rare fights only (single target, lower priority than AoE)
if (anyRareMonster() && STATE.skillsKnown.includes('Great Cleave')) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t };
}
}
// 5. Damage spells
const dmg = CFG.useAttackSpells ? getBestDamageSpell() : null;
if (dmg && STATE.mp > 0.2) {
const t = STATE.monsters.length > 1 ? findWeakestMonster() : findStrongestMonster();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: dmg, target: t };
}
// 8. Channeling kickstart or basic attack
{
const k = kickstartChanneling();
if (k) return k;
}
// Use channeling charge on the highest-MP buff that needs refreshing
{
const ch = findBestChannelingTarget();
if (ch) return ch;
}
const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster());
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' };
return null;
}
// ── Veteran (150-300) / Master (300+): full rotation ──
function strategyVeteran() {
// 1. Health items — ALWAYS BEFORE ATTACKING OR SKILLS!
const reqHpItem = (STATE.monsters && STATE.monsters.length >= 5) ? 0.65 : CFG.cureItemHP;
if (STATE.hp < reqHpItem) {
const gem = hasUsableGem('heal');
if (gem) return { type: 'item', id: gem.id, selfTarget: true, _reason: `hp${(STATE.hp*100).toFixed(0)}` };
// Check non-gem health items
for (const [slot, info] of Object.entries(STATE.itemsKnown)) {
if (slot === 'p') continue;
const def = ITEMS[parseInt(info)];
if (def && def.t === 'heal') return { type: 'item', id: slot, selfTarget: true, _reason: `hp${(STATE.hp*100).toFixed(0)}` };
}
}
// 2. Cure / Full-Cure spell — higher threshold when many mobs
const cureThresh = (STATE.monsters && STATE.monsters.length >= 5) ? 0.65 : Math.max(0.55, CFG.cureHP);
if (STATE.hp < cureThresh) {
const c = hasReadySpell('Full-Cure') ? 'Full-Cure' : hasReadySpell('Cure') ? 'Cure' : null;
if (c) return { type: 'spell', name: c, selfTarget: true, _reason: `hp${(STATE.hp*100).toFixed(0)}` };
}
// 0. Mystic Gem for channeling — only use once per battle
const gem = hasUsableGem('channel');
if (gem && !STATE.channeling && !STATE._mysticUsed)
return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' };
// 0e. HIGH-THREAT WEAPON SKILLS — only when 3+ enemies
// Skip if ≤2 enemies — basic attacks build OC for next wave instead.
const SINGLE_THREAT = Math.round(120 * 90 / 100);
const anyImminent = STATE.monsters.some((m, i) => (STATE.monsterSp[i] || 0) > SINGLE_THREAT);
const isHighThreatEarly = hasHighThreat(THREAT_PX);
if ((isHighThreatEarly || anyImminent) && STATE.oc >= 20 && STATE.monsters.length >= 3) {
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
// Use findOptimalDominoTarget for max AoE coverage (target + 2 left + 2 right)
const t = findOptimalDominoTarget();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t, _reason: 'threat_early' };
}
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) {
const t = findDangerousMonster();
if (t >= 0) return { type: 'skill', name: 'Shatter Strike', target: t, _reason: 'stun_early' };
}
}
if (STATE.skillsKnown.includes('Great Cleave')) {
const t = findDangerousMonster();
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t, _reason: 'cleave_early' };
}
}
// 2c. OC-building priority: when 5+ monsters, OC < 60, and AoE skills ready,
// skip debuffs/non-essential buffs and basic attack to build OC fast.
const needsOC = STATE.monsters && STATE.monsters.length >= 4
&& STATE.oc < (CFG.skillOC || 60)
&& STATE.skillsKnown.includes('Rending Blow')
&& document.querySelector(`.btqs[onmouseover*="set_infopane_spell('Rending Blow'"], .btsd[onmouseover*="set_infopane_spell('Rending Blow'"]`)
?.hasAttribute('onclick');
if (needsOC) {
const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : findOptimalDominoTarget();
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t, _reason: 'oc_build' };
}
// 3. Buffs
if (CFG.autoBuff) {
{
const ib = castInitialBuffs();
if (ib) return ib;
}
if (STATE.channeling && STATE.mp > 0.10) {
const ch = findBestChannelingTarget();
if (ch) return ch;
}
const buffCandidates = [];
for (const b of BUFF_PRIORITY) {
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.10 && hasReadySpell(b.spell)) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
const sp = findSpell(b.spell);
buffCandidates.push({ ...b, mpCost: sp ? sp.mp : 0 });
}
}
if (buffCandidates.length > 0) {
buffCandidates.sort((a, b) => STATE.channeling ? b.mpCost - a.mpCost : a.mpCost - b.mpCost);
return { type: 'spell', name: buffCandidates[0].spell, selfTarget: true, _reason: STATE.channeling ? 'chBuff' : 'buff' };
}
}
// 4. Mana items
if (STATE.mp < CFG.manaGemMP) {
const gem = hasUsableGem('mana');
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
}
// 4. Debuffs
if (CFG.autoDebuff && STATE.mp > 0.35) {
if (hasReadySpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 5)) {
const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil'))
return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' };
}
if (hasReadySpell('Weaken') && (anyRareMonster() || hasHighThreat(THREAT_PX))) {
const b = findDangerousMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken'))
return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' };
}
}
// 5. Weapon skills — prioritize high-threat monsters (high SP/OC bar)
const isHighThreat = hasHighThreat(THREAT_PX);
if (isHighThreat) console.log('%c[HV] ⚠ HIGH THREAT: 2+ monsters with SP >85%', 'color:#f44');
const askillOC3 = CFG.skillOC || 80;
if (STATE.oc >= askillOC3 || (isHighThreat && STATE.monsters.length >= 3)) {
if (isHighThreat && STATE.oc < askillOC3) console.log('%c[HV] ⚠ emergency: using skill at OC=' + STATE.oc.toFixed(0), 'color:#f80');
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
const t = isHighThreat ? findDangerousMonster() : findOptimalDominoTarget();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t, _reason: isHighThreat ? 'threat' : 'oc' };
}
if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Shatter Strike', target: t, _reason: 'stun' };
}
}
if (anyRareMonster() && STATE.skillsKnown.includes('Great Cleave')) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t, _reason: isHighThreat ? 'threat' : 'boss' };
}
if (isHighThreat && STATE.oc >= (CFG.skillOC || 60) && STATE.monsters.length >= 3) {
const ps = ['Frenzied Blows', 'Skyward Sword', 'Iris Strike', 'Merciful Blow',
'Vital Strike', 'Shield Bash', 'Concussive Strike'];
for (const sk of ps) {
if (STATE.skillsKnown.includes(sk)) {
const t = findDangerousMonster();
if (t >= 0) return { type: 'skill', name: sk, target: t, _reason: 'threat' };
}
}
}
}
// 5. Spirit gem auto-consume
const spGem = document.getElementById('ikey_p');
const spGemId = spGem ? (spGem.getAttribute('onmouseover') || '').match(/set_infopane_item\((\d+)\)/) : null;
if (spGemId && spGemId[1] === '10007') {
if (hasUsableGem('spirit')) return { type: 'item', id: 'p', selfTarget: true, _reason: 'gem' };
}
// Spirit potions — only at SP < threshold (Spark needs 50%)
if (STATE.sp < CFG.spiritPotionSP) {
for (const [slot, info] of Object.entries(STATE.itemsKnown)) {
if (slot === 'p') continue;
const def = ITEMS[parseInt(info)];
if (def && def.t === 'spirit') return { type: 'item', id: slot, selfTarget: true, _reason: `sp${(STATE.sp*100).toFixed(0)}` };
}
}
// 6. Damage spells
const dmg = CFG.useAttackSpells ? getBestDamageSpell() : null;
if (dmg && STATE.mp > 0.15) {
const t = STATE.monsters.length > 1 ? findWeakestMonster() : 0;
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'spell', name: dmg, target: t, _reason: 'dmg' };
}
// 7. Channeling kickstart or basic attack
{
const k = kickstartChanneling();
if (k) return k;
}
// Consume channeling on the highest-MP buff that needs refreshing
{
const ch = findBestChannelingTarget();
if (ch) return ch;
}
// ── Stalling: when 1 normal enemy remains, prefer buff refreshes over killing
// This gives weapon skill cooldowns time to tick down before next round.
// Always stall regardless of enemy SP — the last enemy will special anyway,
// and one extra turn of buff refresh > killing immediately with skills on CD.
if (STATE.monsters && STATE.monsters.length === 1 && !anyRareMonster()) {
for (const b of BUFF_PRIORITY) {
const dur = buffDuration(b.icon);
if (dur === 0 || dur < 15) {
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
if (hasReadySpell(b.spell) && STATE.mp > 0.15) {
return { type: 'spell', name: b.spell, selfTarget: true, _reason: 'stall' };
}
}
}
}
const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster());
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' };
return null;
}
// ═══════════════════════════════════════════════════════════════════════
// JPX BRIDGE — chain jpx's single-round auto-battler into continuous play.
// jpx (by design) runs ONE round per M press. We re-trigger it after each
// round completes, with safety guards. We NEVER re-implement battle logic —
// jpx executes; we bridge rounds.
//
// v2: listens to jpx's OWN state signal (jpx_ctrlWidget_update event with
// detail.active) instead of guessing from the DOM. When jpx reports it went
// inactive (round ended, its reDoBattle reset the flag), and a new round is
// loaded with monsters, we re-press M to start the next auto round.
// ═══════════════════════════════════════════════════════════════════════
let jpxChainEnabled = false; // chain engaged for current battle
let jpxChainRounds = 0; // rounds chained this battle
let jpxChainTimer = null;
let jpxLastActive = null; // last known isActiveBattle from jpx
let jpxChainCooldown = 0; // timestamp guard to avoid double-trigger
let jpxChainRetryCount = 0; // lazy-start retry counter
let jpxChainWaiting = false; // waiting for next-round monsters to spawn
function jpxPresent() {
return !!(document.getElementById('ctrl-widget') ||
document.querySelector('#ctrl-widget, .ctrl-widget'));
}
// Lazy-start: retry until jpx's widget actually appears (it's created lazily
// by jpx after its init, so our first check may be too early).
function jpxChainStart() {
if (!CFG.autoChainJpx) return;
if (jpxChainEnabled) return;
if (!jpxPresent()) {
// Retry a few times over ~6s before giving up
if (jpxChainRetryCount < 10) {
jpxChainRetryCount++;
setTimeout(jpxChainStart, 600);
}
return;
}
jpxChainRetryCount = 0;
jpxChainEnabled = true;
jpxChainRounds = 0;
jpxChainCooldown = Date.now();
// Start the first auto round — but only AFTER we know jpx's real state
// (wait up to 1.5s for its first jpx_ctrlWidget_update event). If the
// user already pressed M manually, jpxLastActive=true and we skip.
let waited = 0;
const sync = setInterval(() => {
waited += 300;
if (jpxLastActive !== null || waited > 1500) {
clearInterval(sync);
setTimeout(jpxTriggerAuto, 200);
}
}, 300);
console.log('%c[HV] ⛓ jpx auto-chain engaged (re-triggering M each round)', 'color:#0f0');
}
// Trigger jpx's auto-battle (equivalent to pressing M) — ONLY when jpx is
// currently INACTIVE. Pressing M while jpx is already active toggles it OFF,
// which is the bug we hit: our M landed while jpx was mid-round (active=true)
// and disabled the chain.
function jpxTriggerAuto() {
// If we know jpx is already active, pressing M would DISABLE it — skip.
if (jpxLastActive === true) {
console.log('%c[HV] ⛓ jpx already active — skipping M (would toggle off)', 'color:#f80');
return false;
}
try {
const evt = new KeyboardEvent('keydown', {
key: 'm',
code: 'KeyM',
keyCode: 77,
which: 77,
bubbles: true,
cancelable: true,
});
// Give jpx a moment to process, then verify via its own state event.
const before = jpxLastActive;
document.dispatchEvent(evt);
setTimeout(() => {
if (jpxLastActive !== before) {
console.log(`%c[HV] ✅ M registered (active: ${before}${jpxLastActive})`, 'color:#0f0');
} else {
console.log(`%c[HV] ❌ M ignored by jpx (still active: ${jpxLastActive}) — keybind miss`, 'color:#f44');
}
}, 700);
return true;
} catch (e) {
console.warn('[HV] jpxTriggerAuto failed:', e.message);
return false;
}
}
// Are there living monsters right now (a round is in progress)?
function jpxMonstersPresent() {
return document.querySelectorAll('.btm1').length > 0;
}
// Is the battle over (finish button / no continue button)?
function jpxBattleOver() {
if (document.querySelector('img[src$="finishbattle.png"]')) return true;
const btcp = document.getElementById('btcp');
if (btcp) return false; // continue button exists → not over
return !jpxMonstersPresent();
}
// Called when jpx reports a state change via jpx_ctrlWidget_update
function jpxChainOnState(detail) {
if (!CFG.autoChainJpx) return;
if (!jpxChainEnabled) return;
if (!jpxPresent()) return;
const active = !!detail?.active;
jpxLastActive = active;
// jpx just went inactive → a round ended (or battle ended)
if (active === false) {
// Stop conditions
if (CFG.chainMaxRounds > 0 && jpxChainRounds >= CFG.chainMaxRounds) {
jpxChainStop('max rounds reached');
return;
}
if (STATE.hp !== undefined && STATE.hp < CFG.chainMinHP) {
jpxChainStop('HP below ' + Math.round(CFG.chainMinHP * 100) + '%');
return;
}
if (jpxBattleOver()) {
jpxChainStop('battle finished');
return;
}
// Don't press M yet — jpx would read it as "advance" (round already
// ended, no monsters). Wait until the NEXT round's monsters spawn,
// then trigger auto. Poll up to ~8s.
jpxChainWaiting = true;
const now = Date.now();
if (now - jpxChainCooldown < 2500) return;
jpxChainCooldown = now;
jpxChainRounds++;
let waited = 0;
const timer = setInterval(() => {
waited += 400;
if (jpxMonstersPresent()) {
clearInterval(timer);
jpxChainWaiting = false;
setTimeout(jpxTriggerAuto, 200);
console.log(`%c[HV] ⛓ chaining round ${jpxChainRounds}${CFG.chainMaxRounds ? '/' + CFG.chainMaxRounds : ''} (${jpxMonstersPresent()} mobs)`, 'color:#0f0');
} else if (waited > 8000 || jpxBattleOver()) {
clearInterval(timer);
jpxChainWaiting = false;
if (!jpxBattleOver()) {
console.log('%c[HV] ⛓ timeout waiting for next round', 'color:#f80');
}
}
}, 400);
}
}
function jpxChainStop(reason) {
if (!jpxChainEnabled) return;
jpxChainEnabled = false;
jpxChainRetryCount = 0;
console.log('%c[HV] ⛓ jpx auto-chain stopped: ' + reason, 'color:#f80');
}
function jpxChainReset() {
jpxChainEnabled = false;
jpxChainRounds = 0;
jpxChainCooldown = 0;
}
// One-time wiring: listen for jpx's state events
let jpxBridgeWired = false;
function jpxBridgeWire() {
if (jpxBridgeWired) return;
if (!window.__hvJpxListener) {
window.__hvJpxListener = (e) => {
// Only act if this is the right profile (persistent vs isekai) — jpx
// sends suffix in detail; accept either to be safe.
jpxChainOnState(e.detail || {});
};
window.addEventListener('jpx_ctrlWidget_update', window.__hvJpxListener);
}
jpxBridgeWired = true;
}
// ═══════════════════════════════════════════════════════════════════════
// ACTION EXECUTOR — carry out recommended actions
// ═══════════════════════════════════════════════════════════════════════
function executeAction(a) {
if (!a) return false;
let result = false;
switch (a.type) {
case 'attack': result = attackMonster(a.target); break;
case 'spell': result = a.selfTarget ? castSelfSpell(a.name) : castTargetSpell(a.name, a.target); break;
case 'skill': result = useSkill(a.name, a.target); break;
case 'item': result = useItem(a.id); break;
}
if (result) {
STATE._lastActionTime = Date.now();
}
return result;
}
function attackMonster(i) {
if (i < 0 || i >= STATE.monsters.length) return false;
const m = STATE.monsters[i];
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return false;
m.click();
return true;
}
function castSelfSpell(n) {
const s = $$('.btsd[onclick]').find(el =>
(el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`));
if (!s) return false;
s.click();
return true;
}
function castTargetSpell(n, i) {
const s = $$('.btsd[onclick]').find(el =>
(el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`));
if (!s) return false;
s.click();
// Target selection: click immediately, no setTimeout delay needed.
// HV's native lock_action handles the state transition synchronously.
if (i >= 0 && i < STATE.monsters.length) {
const m = STATE.monsters[i];
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
}
return true;
}
function useItem(id) {
let it = document.getElementById('ikey_' + id);
// Fallback for auto_ slot IDs (on-cooldown items without explicit DOM id):
// search for element with matching onmouseover item ID
if (!it && typeof id === 'string' && id.startsWith('auto_')) {
const rawId = id.replace('auto_', '');
const itemPane = document.getElementById('pane_item');
if (itemPane) {
it = $$('div[onmouseover*="set_infopane_item(' + rawId + ')"]', itemPane)[0];
}
}
if (!it) return false;
// Track that we used the P-slot gem so we don't retry stale state
if (id === 'p') STATE._mysticUsed = true;
dummy.setAttribute('onclick', it.getAttribute('onmouseover'));
dummy.click();
it.click();
return true;
}
function useSkill(n, i) {
let el = null;
// Try quickbar first
for (const e of $$('#quickbar > .btqs[onclick]')) {
if ((e.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`)) {
el = e; break;
}
}
// Try spellbook
if (!el) {
el = $$('.btsd[onclick]').find(e =>
(e.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`));
}
// Fallback: onclick match
if (!el) {
for (const e of $$('.btqb[onclick]')) {
if ((e.getAttribute('onclick') || '').includes(n)) { el = e; break; }
}
}
// Fallback: image match
if (!el) {
for (const e of $$('.btqb img')) {
if ((e.src || '').toLowerCase().includes(n.toLowerCase().replace(/\s+/g, '_'))) {
el = e.parentElement; break;
}
}
}
if (!el) return false;
el.click();
// Click target immediately — no setTimeout needed
if (i >= 0 && i < STATE.monsters.length) {
const m = STATE.monsters[i];
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
}
return true;
}
// ═══════════════════════════════════════════════════════════════════════
// HOVER SYSTEM — REMOVED (Handled by Monsterbation / MB)
// ═══════════════════════════════════════════════════════════════════════
function setupHover() {}
// ═══════════════════════════════════════════════════════════════════════
// KEYBINDINGS — keyboard shortcuts for battle
// ═══════════════════════════════════════════════════════════════════════
let keybindingsSetup = false;
function setupKeybindings() {
if (keybindingsSetup) return;
keybindingsSetup = true;
document.addEventListener('keydown', function(e) {
// Settings — comma key (non-conflicting with Monsterbation).
// Works on ANY page the script loads (battle, character, bazaar...).
if (e.keyCode === KEYS.COMMA) {
// Don't swallow comma when typing in an input/textarea (e.g. auction bids)
const tag = (document.activeElement || {}).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
e.preventDefault();
toggleSettings(); // toggleSettings owns the settingsVisible flag
}
});
console.log('%c⌨ [HV] Key: ,=settings (Battle input managed by Monsterbation)', 'color:#0f0;font-size:11px');
}
// ═══════════════════════════════════════════════════════════════════════
// UI OVERLAYS — REMOVED (Handled by Monsterbation / MB)
// ═══════════════════════════════════════════════════════════════════════
function updateCooldownDisplay() {}
function updateDurationDisplay() {}
function updateAlertColours() {}
function addMonsterNumbers() {}
function updateMonsterHPDisplay() {}
function saveMonsterData() {}
function refreshUI() {}
// ═══════════════════════════════════════════════════════════════════════
// SETTINGS PANEL — in-battle configuration UI
// ═══════════════════════════════════════════════════════════════════════
function toggleSettings() {
if (STATE.settingsVisible) { closeSettings(); } else { openSettings(); }
}
function closeSettings() {
const p = document.getElementById('hv-settings-panel');
if (p) p.remove();
STATE.settingsVisible = false;
}
function openSettings() {
if (document.getElementById('hv-settings-panel')) return;
STATE.settingsVisible = true;
const p = document.createElement('div');
p.id = 'hv-settings-panel';
p.style.cssText = css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '520px',
maxHeight: '80vh',
overflowY: 'auto',
background: '#1a1a2e',
color: '#ccc',
zIndex: '99999',
borderRadius: '8px',
padding: '16px',
fontSize: '12px',
fontFamily: 'monospace',
boxShadow: '0 0 30px rgba(0,0,0,0.8)',
});
const mkTog = (l, k) =>
`<label style="display:flex;align-items:center;margin:3px 0;cursor:pointer">
<input type="checkbox" ${CFG[k] ? 'checked' : ''} data-key="${k}" style="margin-right:6px">
<span>${l}</span>
</label>`;
const mkNum = (l, k, st) =>
`<div style="margin:3px 0;display:flex;align-items:center">
<span style="width:190px;flex-shrink:0">${l}</span>
<input type="number" value="${CFG[k]}" data-key="${k}" step="${st || 0.05}"
style="width:55px;background:#333;color:#ccc;border:1px solid #555;border-radius:3px;padding:2px 4px">
</div>`;
p.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<b style="color:#fdcb00;font-size:14px">⚙ HV Unified v${VERSION} (Bridge & Advisor)</b>
<button id="hv-settings-close" style="background:#d50c2d;color:#fff;border:none;border-radius:4px;padding:4px 10px;cursor:pointer">✕</button>
</div>
<div style="border-top:1px solid #444;margin:8px 0"></div>
<b style="color:#0f0">Strategy Advisory</b>
${mkTog('Auto-buff recommendations', 'autoBuff')}
${mkTog('Auto-debuff recommendations', 'autoDebuff')}
${mkTog('Auto-cure recommendations', 'autoCure')}
${mkTog('Auto-difficulty suggestion', 'autoDifficulty')}
${mkTog('Use attack spells in advice', 'useAttackSpells')}
${mkNum('Cure HP threshold', 'cureHP')}
${mkNum('Item HP threshold', 'cureItemHP')}
${mkNum('Mana gem MP threshold', 'manaGemMP')}
${mkNum('Mana potion MP threshold', 'manaPotionMP')}
${mkNum('Skill OC% threshold', 'skillOC', 5)}
<div style="border-top:1px solid #444;margin:8px 0"></div>
<b style="color:#0f0">Out of Battle & Guidance</b>
${mkTog('Training queue info', 'trainingQueue')}
${mkTog('Advisor panel', 'showGuidance')}
${mkTog('Equip KEEP/SELL tags', 'showEquipAdvice')}
${mkTog('Config button', 'cfgButton')}
<div style="border-top:1px solid #444;margin:8px 0"></div>
<b style="color:#0f0">jpx Bridge</b>
${mkTog('Auto-chain jpx rounds (re-press M)', 'autoChainJpx')}
${mkNum('Min HP% to keep chaining', 'chainMinHP', 0.05)}
${mkNum('Max chained rounds (0=∞)', 'chainMaxRounds', 1)}
<div style="margin-top:12px;text-align:center;color:#666;font-size:10px">
Changes apply immediately. Press <b>,</b> to open settings.
</div>`;
document.body.appendChild(p);
document.getElementById('hv-settings-close').onclick = closeSettings;
$$('input', p).forEach(inp => {
inp.addEventListener('change', () => {
const k = inp.dataset.key;
if (!k || CFG[k] === undefined) return;
if (inp.type === 'checkbox') CFG[k] = inp.checked;
else CFG[k] = parseFloat(inp.value) || CFG[k];
saveConfig();
refreshUI();
});
});
}
// ═══════════════════════════════════════════════════════════════════════
// OUT-OF-BATTLE — shop enhancements, shrine, training, etc.
// ═══════════════════════════════════════════════════════════════════════
// ── Item Shop: quick-buy essentials + crystal labels ──
function enhanceItemShop() {
if (document.getElementById('hv-itemshop-enh')) return;
const main = document.getElementById('mainpane');
if (!main) { setTimeout(enhanceItemShop, 200); return; }
const bar = document.createElement('div');
bar.id = 'hv-itemshop-enh';
bar.style.cssText = css({
position: 'fixed',
bottom: '30px',
right: '4px',
zIndex: '9995',
background: '#111827',
color: '#d1d5db',
padding: '0',
borderRadius: '8px',
fontSize: '10px',
fontFamily: 'monospace',
maxWidth: '320px',
boxShadow: '0 0 15px rgba(0,0,0,0.6)',
border: '1px solid #374151',
});
// Inventory counts from left pane
const inventory = {};
$$('#item_pane .itemlist tr', main).forEach(row => {
const itemDiv = row.querySelector('[id^="item_"]');
const countTd = row.querySelector('td:last-child');
if (itemDiv && countTd) {
const id = parseInt(itemDiv.id.replace('item_', ''));
inventory[id] = parseInt(countTd.textContent) || 0;
}
});
const essentials = [
{ id: 11191, label: 'Health Draught (25c)', warn: 2 },
{ id: 11195, label: 'Health Potion (50c)', warn: 1 },
{ id: 11291, label: 'Mana Draught (50c)', warn: 1 },
{ id: 11295, label: 'Mana Potion (100c)', warn: 0 },
];
let bodyHtml = '';
for (const e of essentials) {
const inInv = inventory[e.id] || 0;
const urgent = inInv <= e.warn;
bodyHtml += `<div style="display:flex;justify-content:space-between;padding:2px 0;cursor:pointer"
data-item="${e.id}" class="hv-shop-buy">
<span style="color:${urgent ? '#f88' : '#8f8'}">${urgent ? '⬆ ' : '✓ '}${e.label}</span>
<span style="color:#888">have: ${inInv}</span>
</div>`;
}
bodyHtml += '<div style="border-top:1px solid #374151;margin:4px 0;padding:4px 0;font-size:9px;color:#888">' +
'💎 Crystals: <span style="color:#fdcb00">Vigor=STR</span> <span style="color:#0f0">Finesse=DEX</span> ' +
'<span style="color:#8af">Swift=AGI</span> <span style="color:#f80">Fort=END</span> ' +
'<span style="color:#f0f">Cunn=INT</span> <span style="color:#0ff">Know=WIS</span></div>';
const collapsed = localStorage[SP + 'shopCollapsed'] === '1';
bar.innerHTML = `<div style="display:flex;justify-content:space-between;align-items:center;
padding:6px 8px;cursor:pointer" id="hv-shop-header">
<b style="color:#fdcb00;font-size:10px">🛒 Shop</b>
<span id="hv-shop-toggle" style="color:#888;font-size:12px">${collapsed ? '▶' : '▼'}</span>
</div>
<div id="hv-shop-body" style="padding:0 8px 6px;display:${collapsed ? 'none' : 'block'}">${bodyHtml}</div>`;
bar.querySelector('#hv-shop-header').onclick = () => {
const body = document.getElementById('hv-shop-body');
const toggle = document.getElementById('hv-shop-toggle');
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
toggle.textContent = isHidden ? '▼' : '▶';
localStorage[SP + 'shopCollapsed'] = isHidden ? '0' : '1';
};
document.body.appendChild(bar);
bar.addEventListener('click', e => {
const row = e.target.closest('.hv-shop-buy');
if (!row) return;
const id = row.dataset.item;
const shopItem = document.querySelector('#shop_pane #item_' + id);
if (shopItem) shopItem.click();
});
}
// ── Equip advice: KEEP/SELL tags ──
function evaluateEquipment(text) {
const t = (text || '').toLowerCase();
for (const q of KB.qualities) {
if (t.includes(q.toLowerCase())) {
const qi = KB.qualities.indexOf(q);
if (qi >= 5) return { verdict: 'keep', reason: 'Magnificent+ — keep' };
if (qi <= 2) return { verdict: 'sell', reason: 'Low quality — sell' };
return { verdict: 'keep', reason: 'Usable' };
}
}
return { verdict: 'keep', reason: '' };
}
function enhanceEquipShopWithAdvice() {
if (!CFG.showEquipAdvice) return;
const m = document.getElementById('mainpane');
if (!m) return;
$$('tr', m).forEach(row => {
if (row.querySelector('.hv-equip-advice')) return;
const t = row.textContent || '';
if (!t.includes('Sell') && !t.includes('Salvage')) return;
const ev = evaluateEquipment(t);
const d = document.createElement('span');
d.className = 'hv-equip-advice';
const col = ev.verdict === 'keep' ? '#0f0' : '#f80';
d.style.cssText = `margin-left:6px;font-size:9px;color:${col};font-weight:bold`;
d.textContent = ev.verdict === 'keep' ? '✓ KEEP' : '💰 SELL';
d.title = ev.reason;
const td = row.querySelector('td:last-child');
if (td) td.appendChild(d);
});
}
// ── Shrine: bulk selection ──
function enhanceShrine() {
// Shrine enhancements are owned by HVUT.
}
// ── Training: priority guide ──
function enhanceTraining() {
if (!CFG.trainingQueue) return;
const m = document.getElementById('mainpane');
if (!m || document.getElementById('hv-training-enh')) return;
const d = document.createElement('div');
d.id = 'hv-training-enh';
d.style.cssText = 'margin:8px;padding:8px;background:#1a2a1a;border-radius:4px;font-size:11px;color:#8f8';
d.innerHTML = '<b>📋 Training Priority</b>: Adept Learner → Scavenger → Ability Boost → Quartermaster<br>' +
'<small style="color:#888">Train cheapest available. AL to Lv100+, then damage.</small>';
const ta = m.querySelector('div');
if (ta) ta.insertBefore(d, ta.firstChild);
}
function enhanceMonsterLab() {
// Monster Lab enhancements are owned by HVUT.
}
// ── Config button (bottom-right) ──
function addConfigButton() {
if (!CFG.cfgButton) return;
if (document.getElementById('hv-cfg-btn')) return updateConfigButton();
const b = document.createElement('div');
b.id = 'hv-cfg-btn';
b.style.cssText = css({
position: 'fixed',
bottom: '4px',
right: '4px',
zIndex: '9998',
background: '#1a1a2e',
color: '#fdcb00',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'monospace',
cursor: 'pointer',
opacity: '0.7',
});
b.textContent = `⚙ HV v${VERSION} | ${STATE.tier.toUpperCase()} | Lv${STATE.level}`;
b.title = 'Click for settings, press , in battle';
b.onclick = toggleSettings;
document.body.appendChild(b);
}
function updateConfigButton() {
const b = document.getElementById('hv-cfg-btn');
if (!b) return;
const diff = STATE.difficulty || 'Normal';
b.textContent = `⚙ HV v${VERSION} | ${STATE.tier.toUpperCase()} | Lv${STATE.level} | ${diff}`;
}
// ═══════════════════════════════════════════════════════════════════════
// GUIDANCE — character page advisor panel with attribute recommendations
// ═══════════════════════════════════════════════════════════════════════
function detectFightingStyle() {
if (STATE.skillsKnown.includes('Shield Bash')) return '1H';
if (STATE.skillsKnown.includes('Great Cleave')) return '2H';
if (STATE.skillsKnown.includes('Iris Strike')) return 'DW';
if (STATE.skillsKnown.includes('Skyward Sword')) return 'Niten';
if (STATE.skillsKnown.includes('Concussive Strike')) return 'Staff';
return '1H';
}
function getAttrAdvice(style) {
const a = KB.statAllocation[STATE.tier]?.[style] || KB.statAllocation[STATE.tier]?.Staff || { INT: 35, WIS: 25, END: 20, AGI: 10, DEX: 5, STR: 5 };
const notes = { STR: 'Phys dmg', DEX: 'Acc+Parry', END: 'HP+Mit', INT: 'Magic dmg', WIS: 'MP+Acc', AGI: 'Evade+Spd' };
return Object.entries(a).map(([s, p]) => {
const bar = '█'.repeat(Math.round(p / 5)) + '░'.repeat(20 - Math.round(p / 5));
return `${s.padEnd(4)} ${bar} ${p}% — ${notes[s] || ''}`;
}).join('\n');
}
function getSpellAdvice() {
const lv = STATE.level;
const up = KB.spellUnlocks.filter(s => s.lvl > lv && s.lvl <= lv + 20);
const ju = KB.spellUnlocks.filter(s => s.lvl <= lv && s.lvl >= lv - 5);
const lines = [];
if (ju.length) lines.push('⚠ Recent: ' + ju.map(s => s.name).join(', '));
if (up.length) lines.push('🔮 Soon: ' + up.map(s => `${s.name}@${s.lvl}`).join(', '));
if (!lines.length) lines.push('All major spells unlocked.');
return lines.join('\n');
}
function getDifficultyAdvice() {
const tier = STATE.tier;
const difficulty = STATE.difficulty || 'Normal';
// Difficulty order (higher index = harder)
const difficulties = ['Normal', 'Hard', 'Nightmare', 'Hell', 'Nintendo', 'IWBTH', 'PFUDOR'];
// Forum-corrected: PFUDOR from Veteran onward
let suggested, reason;
switch (tier) {
case 'novice':
suggested = 'Normal';
reason = 'Survival first. Use items before spells.';
break;
case 'adept':
suggested = 'Hard';
reason = 'Better drops + EXP. You have enough HP now.';
break;
case 'veteran':
suggested = 'Nintendo';
reason = '10x EXP. Better drops than Hell, while still comfortable.';
break;
case 'master':
suggested = 'PFUDOR';
reason = 'Max rewards. Always PFUDOR.';
break;
default:
suggested = 'Normal';
reason = '';
}
const currentIdx = difficulties.findIndex(d => d.toLowerCase() === (difficulty || '').toLowerCase());
const suggestedIdx = difficulties.findIndex(d => d.toLowerCase() === suggested.toLowerCase());
// Only warn if current difficulty is LOWER than suggested
// (don't nag if you're already on a higher difficulty)
if (currentIdx < suggestedIdx && CFG.autoDifficulty) {
return { current: difficulty, suggested, reason, upgrade: true,
message: `⚡ Suggested: ${suggested} (currently ${difficulty})\n ${reason}` };
}
return { current: difficulty, suggested, reason, upgrade: false, message: '' };
}
// ── Attribute guidance ──
function getCurrentAttributes() {
try {
if (typeof attr_current !== 'undefined' && attr_current) {
const delta = (typeof attr_delta !== 'undefined' && attr_delta) ? attr_delta : {};
return {
STR: (attr_current.str || 0) + (delta.str || 0),
DEX: (attr_current.dex || 0) + (delta.dex || 0),
AGI: (attr_current.agi || 0) + (delta.agi || 0),
END: (attr_current.end || 0) + (delta.end || 0),
INT: (attr_current.int || 0) + (delta.int || 0),
WIS: (attr_current.wis || 0) + (delta.wis || 0),
};
}
} catch (e) {}
return { STR: 0, DEX: 0, AGI: 0, END: 0, INT: 0, WIS: 0 };
}
function getNextAttributeAdvice(style) {
const current = getCurrentAttributes();
const total = Object.values(current).reduce((a, b) => a + b, 0);
if (total === 0) return null;
const target = KB.statAllocation[STATE.tier]?.[style] || KB.statAllocation[STATE.tier]?.Staff;
if (!target) return null;
let worstStat = null, worstDeficit = -Infinity;
const stats = ['STR', 'DEX', 'AGI', 'END', 'INT', 'WIS'];
for (const stat of stats) {
const currentPct = total > 0 ? (current[stat] / total) * 100 : 0;
const targetPct = target[stat] || 0;
const deficit = targetPct - currentPct;
if (deficit > worstDeficit) { worstDeficit = deficit; worstStat = stat; }
}
if (!worstStat || worstDeficit <= 2) {
const priority = KB.statPriority[style] || KB.statPriority['1H'];
return { stat: priority[0], reason: 'Balanced. Push primary damage stat.',
target: target[priority[0]], current: Math.round((current[priority[0]] / total) * 100), deficit: 0 };
}
const reasons = {
STR: 'More physical damage.',
DEX: 'Better accuracy, crit, parry.',
AGI: 'Faster attacks, higher evade.',
END: 'More HP.',
INT: 'Stronger magic.',
WIS: 'More MP, better resist.',
};
return { stat: worstStat, reason: reasons[worstStat] || '',
target: target[worstStat], current: Math.round((current[worstStat] / total) * 100),
deficit: Math.round(worstDeficit) };
}
function highlightAttributeButton(advice) {
if (!advice) return;
const statKey = advice.stat.toLowerCase();
const incBtn = document.getElementById(statKey + '_inc');
if (!incBtn) return;
incBtn.style.outline = '2px solid #fdcb00';
incBtn.style.outlineOffset = '2px';
incBtn.title = `Level ${advice.stat} next! ${advice.reason}`;
}
// ── Guidance panel ──
function showGuidancePanel() {
if (!CFG.showGuidance) return;
if (document.getElementById('hv-guidance')) return;
const style = detectFightingStyle();
const diff = STATE.difficulty || 'Normal';
const da = getDifficultyAdvice();
const nextAttr = getNextAttributeAdvice(style);
const collapsed = localStorage[SP + 'guideCollapsed'] === '1';
const p = document.createElement('div');
p.id = 'hv-guidance';
p.style.cssText = css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: '99996',
background: '#111827',
color: '#d1d5db',
padding: '0',
borderRadius: '8px',
fontSize: '11px',
fontFamily: 'monospace',
maxWidth: '600px',
minWidth: '320px',
boxShadow: '0 0 30px rgba(0,0,0,0.8)',
border: '1px solid #374151',
cursor: 'move',
userSelect: 'none',
});
// Restore saved position
const savedPos = JSON.parse(localStorage[SP + 'guidePos'] || '{}');
if (savedPos.left && savedPos.top) {
p.style.left = savedPos.left + 'px';
p.style.top = savedPos.top + 'px';
p.style.transform = 'none';
}
let nextHtml = '';
if (nextAttr) {
nextHtml = `<div style="background:#1f2937;padding:10px;margin:6px 0;border-radius:4px;border-left:3px solid #fdcb00;font-size:14px">
<b style="color:#fdcb00">👉 Next: +${nextAttr.stat}</b>
<span style="color:#9ca3af;margin-left:8px">${nextAttr.reason}</span>
<div style="margin-top:4px;font-size:11px;color:#888">
Target: <b style="color:#fdcb00">${nextAttr.target}%</b> |
Now: <b style="color:#${nextAttr.deficit > 10 ? 'f44' : '8f8'}">${nextAttr.current}%</b>
</div>
</div>`;
}
p.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 10px;cursor:move" id="hv-guide-header">
<b style="color:#fdcb00;font-size:12px">🧭 Advisor — Lv${STATE.level} (${STATE.tier.toUpperCase()})</b>
<div style="display:flex;gap:6px">
<span id="hv-guide-minimize" style="cursor:pointer;color:#888;font-size:14px">${collapsed ? '📌' : '🗕'}</span>
<span id="hv-guide-close" style="cursor:pointer;color:#f44;font-size:14px">✕</span>
</div>
</div>
<div id="hv-guide-body" style="padding:0 10px 10px;display:${collapsed ? 'none' : 'block'}">
${nextAttr ? nextHtml : ''}
<div style="background:#1f2937;padding:8px;margin:6px 0;border-radius:4px;border-left:3px solid #fdcb00">
<b>📈 Attributes</b><pre style="margin:4px 0;color:#9ca3af;font-size:10px;line-height:1.3">${getAttrAdvice(style)}</pre>
</div>
<div style="background:#1f2937;padding:8px;margin:6px 0;border-radius:4px;border-left:3px solid #8b5cf6">
<b>✨ Spells</b><pre style="margin:4px 0;color:#9ca3af;font-size:10px">${getSpellAdvice()}</pre>
</div>
<div style="background:#1f2937;padding:8px;margin:6px 0;border-radius:4px;border-left:3px solid ${da.upgrade ? '#f59e0b' : '#10b981'}">
<b>⚔ Difficulty</b><div style="color:#9ca3af;font-size:10px">Current: <b>${diff}</b>${da.upgrade ? ` → Suggested: <b style="color:#fdcb00">${da.suggested}</b> — ${da.reason}` : ' ✓ Optimal'}</div>
</div>
<div style="background:#1f2937;padding:8px;margin:6px 0;border-radius:4px;border-left:3px solid #ef4444">
<b>🎯 Training</b><div style="color:#9ca3af;font-size:10px">Adept Learner → Scavenger → Ability Boost → Quartermaster</div>
</div>
</div>`;
document.body.appendChild(p);
// Draggable
let dragging = false, startX, startY, origX, origY;
const header = document.getElementById('hv-guide-header');
const onDrag = (e) => {
if (!dragging) return;
p.style.left = (origX + e.clientX - startX) + 'px';
p.style.top = (origY + e.clientY - startY) + 'px';
p.style.transform = 'none';
};
header.addEventListener('mousedown', e => {
if (e.target.id === 'hv-guide-minimize' || e.target.id === 'hv-guide-close') return;
dragging = true;
const rect = p.getBoundingClientRect();
startX = e.clientX; startY = e.clientY;
origX = rect.left; origY = rect.top;
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', () => {
dragging = false;
document.removeEventListener('mousemove', onDrag);
const rect = p.getBoundingClientRect();
try { localStorage[SP + 'guidePos'] = JSON.stringify({ left: rect.left, top: rect.top }); } catch (e) {}
});
e.preventDefault();
});
document.getElementById('hv-guide-minimize').onclick = () => {
const body = document.getElementById('hv-guide-body');
const hidden = body.style.display === 'none';
body.style.display = hidden ? 'block' : 'none';
document.getElementById('hv-guide-minimize').textContent = hidden ? '🗕' : '📌';
localStorage[SP + 'guideCollapsed'] = hidden ? '0' : '1';
};
document.getElementById('hv-guide-close').onclick = () => p.remove();
if (nextAttr) highlightAttributeButton(nextAttr);
// Recalculate on + button clicks
$$('[id$="_inc"]').forEach(btn => {
btn.addEventListener('click', () => {
setTimeout(() => {
$$('[id$="_inc"]').forEach(b => { b.style.outline = ''; b.title = ''; });
const ny = getNextAttributeAdvice(detectFightingStyle());
if (ny) highlightAttributeButton(ny);
}, 10);
});
});
}
// ── Difficulty banner in battle ──
function showDifficultyBanner() {
if (!CFG.autoDifficulty) return;
if (!isBattlePage()) return;
if (document.getElementById('hv-diff-banner')) return;
const advice = getDifficultyAdvice();
if (!advice.upgrade) return;
const banner = document.createElement('div');
banner.id = 'hv-diff-banner';
banner.style.cssText = css({
position: 'fixed',
top: '30px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '99998',
background: '#1a1a2e',
color: '#fdcb00',
padding: '6px 16px',
borderRadius: '6px',
fontSize: '11px',
fontFamily: 'monospace',
border: '1px solid #fdcb00',
whiteSpace: 'pre-line',
});
banner.textContent = advice.message;
document.body.appendChild(banner);
setTimeout(() => { const b = document.getElementById('hv-diff-banner'); if (b) b.remove(); }, 5000);
}
// ═══════════════════════════════════════════════════════════════════════
// PROGRESS TRACKER — daily task checklist on all pages
// ═══════════════════════════════════════════════════════════════════════
function buildTaskList() {
const today = new Date().toDateString();
const saved = JSON.parse(localStorage[SP + 'tasks'] || '{"_date":"","done":[],"stats":{}}');
if (saved._date !== today) { saved._date = today; saved.done = []; saved.stats = {}; }
const tasks = [];
const tier = STATE.tier;
const lv = STATE.level;
// Core tasks (all tiers)
tasks.push({ id: 'arenas', icon: '⚔', text: 'Clear all available Arenas', tier: 'all', urgent: true });
tasks.push({ id: 'battle', icon: '👊', text: 'Complete at least one battle', tier: 'all', urgent: false });
tasks.push({ id: 'feed', icon: '🍖', text: 'Feed monsters in Monster Lab', tier: 'all', urgent: false });
tasks.push({ id: 'training', icon: '🎓', text: 'Start a new Training session', tier: 'all', urgent: true });
// Novice-specific
if (tier === 'novice') {
tasks.push({ id: 'items', icon: '🧪', text: 'Keep Health/Mana Draughts stocked', detail: 'Items > spells', tier: 'novice', urgent: true });
tasks.push({ id: 'first-blood', icon: '⚔', text: 'Clear "First Blood" arena daily', detail: '100 credits, 2 rounds', tier: 'novice', urgent: true });
tasks.push({ id: 'normal-diff', icon: '⚠', text: 'Stay on Normal difficulty', tier: 'novice', urgent: false });
}
// Adept
if (tier === 'adept') {
tasks.push({ id: 'hard-mode', icon: '⬆', text: 'Switch to Hard difficulty', tier: 'adept', urgent: false });
tasks.push({ id: 'scavenger', icon: '🎓', text: 'Train Scavenger to Lv25', tier: 'adept', urgent: false });
}
// Veteran
if (tier === 'veteran') {
tasks.push({ id: 'pfudor', icon: '💀', text: 'Run Grindfest on PFUDOR', tier: 'veteran', urgent: false });
tasks.push({ id: 'spirit-stance', icon: '✨', text: 'Use Spirit Stance when OC > 70%', tier: 'veteran', urgent: false });
}
// Master
if (tier === 'master') {
tasks.push({ id: 'all-arenas', icon: '🏆', text: 'Clear all 17 arenas daily', tier: 'master', urgent: true });
tasks.push({ id: 'tower', icon: '🗼', text: 'Progress in the Tower', tier: 'master', urgent: false });
}
// Tips
tasks.push({ id: 'dawn', icon: '🌅', text: 'Battles reset at Dawn (~midnight UTC)', tier: 'all', urgent: false, tip: true });
tasks.push({ id: 'hath', icon: '💰', text: 'H@H generates Hath passively', tier: 'all', urgent: false, tip: true });
return { tasks, saved };
}
function renderProgressPanel() {
if (document.getElementById('hv-progress')) return;
const { tasks, saved } = buildTaskList();
const done = saved.done || [];
const doneCount = done.length;
const totalCount = tasks.filter(t => !t.tip).length;
const collapsed = localStorage[SP + 'progressCollapsed'] === '1';
const panel = document.createElement('div');
panel.id = 'hv-progress';
panel.style.cssText = css({
position: 'fixed',
top: '60px',
right: '4px',
zIndex: '9997',
background: '#111827',
color: '#d1d5db',
padding: '8px 10px',
borderRadius: '6px',
fontSize: '10px',
fontFamily: 'monospace',
maxWidth: '320px',
boxShadow: '0 0 15px rgba(0,0,0,0.6)',
border: '1px solid #374151',
maxHeight: '70vh',
overflowY: 'auto',
});
panel.innerHTML = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;cursor:pointer" id="hv-progress-header">
<b style="color:#fdcb00;font-size:11px">📋 Today: ${doneCount}/${totalCount}</b>
<span id="hv-progress-toggle" style="color:#888;font-size:14px">${collapsed ? '▶' : '▼'}</span>
</div>
<div id="hv-progress-body" style="display:${collapsed ? 'none' : 'block'}">${
tasks.map(t => {
const isDone = done.includes(t.id);
const style = isDone ? 'text-decoration:line-through;color:#666'
: t.urgent ? 'color:#fdcb00'
: t.tip ? 'color:#888;font-style:italic'
: 'color:#9ca3af';
const cb = isDone ? '☑' : '☐';
return `<div style="padding:2px 0;${style};cursor:${t.tip ? 'default' : 'pointer'}"
data-task="${t.id}" class="hv-task-item">
${cb} ${t.icon} ${t.text}
${t.detail ? `<br><span style="margin-left:18px;font-size:9px;color:#666">↳ ${t.detail}</span>` : ''}
</div>`;
}).join('')
}</div>`;
document.body.appendChild(panel);
document.getElementById('hv-progress-header').onclick = () => {
const body = document.getElementById('hv-progress-body');
const toggle = document.getElementById('hv-progress-toggle');
const hidden = body.style.display === 'none';
body.style.display = hidden ? 'block' : 'none';
toggle.textContent = hidden ? '▼' : '▶';
localStorage[SP + 'progressCollapsed'] = hidden ? '0' : '1';
};
panel.querySelectorAll('.hv-task-item').forEach(el => {
if (el.dataset.task === 'dawn' || el.dataset.task === 'hath') return;
el.addEventListener('click', () => {
const id = el.dataset.task;
const s = JSON.parse(localStorage[SP + 'tasks'] || '{}');
if (!s.done) s.done = [];
const idx = s.done.indexOf(id);
if (idx >= 0) s.done.splice(idx, 1);
else s.done.push(id);
localStorage[SP + 'tasks'] = JSON.stringify(s);
el.remove();
panel.remove();
renderProgressPanel();
});
});
}
function autoCheckTask(taskId) {
const saved = JSON.parse(localStorage[SP + 'tasks'] || '{}');
if (!saved.done) saved.done = [];
if (!saved.done.includes(taskId)) {
saved.done.push(taskId);
localStorage[SP + 'tasks'] = JSON.stringify(saved);
const panel = document.getElementById('hv-progress');
if (panel) { panel.remove(); renderProgressPanel(); }
}
}
// ═══════════════════════════════════════════════════════════════════════
// ABILITIES — spell & skill unlock guide for abilities page
// ═══════════════════════════════════════════════════════════════════════
function enhanceAbilities() {
if (document.getElementById('hv-abilities')) return;
const lv = STATE.level || 1;
// Detect current tree
const url = window.location.href || '';
const treeMap = {
'tree=general': 'General',
'tree=onehanded': 'One-Handed',
'tree=twohanded': 'Two-Handed',
'tree=dualwield': 'Dual Wield',
'tree=niten': 'Niten',
'tree=staff': 'Staff',
'tree=cloth': 'Cloth',
'tree=light': 'Light',
'tree=heavy': 'Heavy',
'tree=deprecating1': 'Deprecating 1',
'tree=deprecating2': 'Deprecating 2',
'tree=supportive1': 'Supportive 1',
'tree=supportive2': 'Supportive 2',
'tree=elemental': 'Elemental',
'tree=forbidden': 'Forbidden',
'tree=divine': 'Divine',
};
let tree = 'General';
for (const [k, v] of Object.entries(treeMap)) {
if (url.includes(k)) { tree = v; break; }
}
// Read AP from CSS-digit counter
let ap = 0;
$$('#ability_top [class*="f4b"]').some(div => {
const txt = div.textContent || '';
if (txt.includes('Ability') || txt.includes('Mastery')) {
const d = readCSSDigits(div);
if (d !== null) ap = d;
return true;
}
});
// Parse all visible abilities
const abilities = [];
$$('[id^="slot_"][onmouseover]').forEach(el => {
const mo = el.getAttribute('onmouseover') || '';
const m = mo.match(/overability\((\d+),\s*'([^']+)',\s*'([^']*)',\s*'([^']*)',\s*'([^']*)'/);
if (!m) return;
const name = m[2];
const status = m[4];
const nextHtml = m[5];
const lvlMatch = nextHtml.match(/Level\s*(\d+)/i);
const lvlReq = lvlMatch ? parseInt(lvlMatch[1]) : 0;
const apMatch = nextHtml.match(/(\d+)\s*Ability\s*Points?/i);
const apCost = apMatch ? parseInt(apMatch[1]) : 0;
const tierMatch = status.match(/Tier\s*(\d+)/i);
const curTier = tierMatch ? parseInt(tierMatch[1]) : 0;
const acquired = status !== 'Not Acquired' && status !== 'Locked';
abilities.push({ name, status, acquired, curTier, lvlReq, apCost, el });
});
// Free slots
const freeSlots = $$('#ability_top [id^="slot_"]').filter(el => {
return (el.style.backgroundImage || '').includes('t/0.png');
}).length;
// Build panel
let body = `<b>💎 AP: ${ap}</b><br>`;
if (freeSlots > 0) {
body += `<div style="color:#f88;font-size:9px">⚠ ${freeSlots} empty slots — assign abilities!</div>`;
}
body += `<div style="border-top:1px solid #374151;margin:4px 0;padding:4px 0"><b>🌳 ${tree}</b>`;
const affordable = abilities.filter(a => !a.acquired && lv >= a.lvlReq && ap >= a.apCost);
const lockedByLevel = abilities.filter(a => !a.acquired && lv < a.lvlReq);
const owned = abilities.filter(a => a.acquired);
if (owned.length > 0) body += `<div style="color:#8f8;font-size:9px">✅ ${owned.length} owned</div>`;
if (affordable.length > 0) {
const best = affordable.sort((a, b) => a.apCost - b.apCost)[0];
body += `<div style="color:#0f0;font-size:9px">⬆ <b>Buy: ${best.name}</b> (${best.apCost} AP)</div>`;
}
if (lockedByLevel.length > 0) {
const next = lockedByLevel.sort((a, b) => a.lvlReq - b.lvlReq)[0];
body += `<div style="color:#fdcb00;font-size:9px">🔒 ${next.name} @ Lv${next.lvlReq}</div>`;
}
body += `</div>`;
// Full table
if (abilities.length > 0) {
body += `<div style="border-top:1px solid #374151;margin:4px 0;padding:4px 0;font-size:9px">
<table style="width:100%;border-collapse:collapse">
<tr style="color:#888"><th style="text-align:left;padding:1px 4px">Ability</th>
<th style="padding:1px 4px">AP</th><th style="padding:1px 4px">Lv</th>
<th style="text-align:right;padding:1px 4px">Status</th></tr>`;
const sorted = [].concat(
affordable.sort((a, b) => a.apCost - b.apCost),
owned,
lockedByLevel.sort((a, b) => a.lvlReq - b.lvlReq),
);
const seen = new Set();
for (const a of sorted) {
const k = a.name + a.curTier;
if (seen.has(k)) continue;
seen.add(k);
const color = a.acquired ? '#8f8'
: (lv >= a.lvlReq && ap >= a.apCost) ? '#0f0'
: lv >= a.lvlReq ? '#fdcb00' : '#888';
const lbl = a.acquired ? `T${a.curTier}`
: (lv >= a.lvlReq && ap >= a.apCost) ? '⬆ Buy'
: lv >= a.lvlReq ? '🔒AP' : `Lv${a.lvlReq}`;
body += `<tr><td style="padding:1px 4px;color:${color}">${a.name}</td>
<td style="padding:1px 4px;color:#888">${a.apCost}</td>
<td style="padding:1px 4px;color:#888">${a.lvlReq}</td>
<td style="padding:1px 4px;text-align:right;color:${color}">${lbl}</td></tr>`;
}
body += `</table></div>`;
}
// Global priority
body += `<div style="border-top:1px solid #374151;margin:4px 0;padding:4px 0;font-size:9px;color:#888">
<b>🗺 Priority:</b> General (Tanks) → Supportive → Weapon → Elemental → Deprecating<br>
Visit each tree tab for detailed recommendations.
</div>`;
const panel = document.createElement('div');
panel.id = 'hv-abilities';
panel.style.cssText = css({
position: 'fixed',
top: '60px',
right: '4px',
zIndex: '9995',
background: '#111827',
color: '#d1d5db',
padding: '0',
borderRadius: '8px',
fontSize: '10px',
fontFamily: 'monospace',
maxWidth: '320px',
boxShadow: '0 0 15px rgba(0,0,0,0.6)',
border: '1px solid #374151',
});
const collapsed = localStorage[SP + 'abCollapsed'] === '1';
panel.innerHTML = `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 8px;cursor:pointer" id="hv-ab-header">
<b style="color:#fdcb00;font-size:10px">📖 Abilities (Lv${lv})</b>
<span id="hv-ab-toggle" style="color:#888;font-size:12px">${collapsed ? '▶' : '▼'}</span>
</div>
<div id="hv-ab-body" style="padding:0 8px 6px;display:${collapsed ? 'none' : 'block'}">${body}</div>`;
panel.querySelector('#hv-ab-header').onclick = () => {
const b = document.getElementById('hv-ab-body');
const t = document.getElementById('hv-ab-toggle');
const h = b.style.display === 'none';
b.style.display = h ? 'block' : 'none';
t.textContent = h ? '▼' : '▶';
localStorage[SP + 'abCollapsed'] = h ? '0' : '1';
};
document.body.appendChild(panel);
}
// ═══════════════════════════════════════════════════════════════════════
// ARMORY — smart equipment inventory management
// ═══════════════════════════════════════════════════════════════════════
//
// The Armory (ss=am) has native tabs: Organize, Modify, Repair, Soulbind,
// Purchase, Sell, Salvage. Each tab has its own equipment list with
// checkboxes (<input name="eqids[]">). The page has native submit buttons
// for each action (Sell Equipment, Salvage Equipment, etc.).
//
// Our enhancements:
// - Add bulk-select buttons above the equipment list
// - Auto-check items based on quality thresholds
// - Show quick summary of what's worth keeping vs discarding
// ───────────────────────────────────────────────────────────────────────
function gradeEquipment(name) {
const q = KB.qualities.find(q => name.includes(q));
if (!q) return { action: 'keep', label: '❓', color: '#888', reason: 'Unknown' };
const qi = KB.qualities.indexOf(q);
if (qi >= 5) return { action: 'keep', label: '✅ Keep', color: '#0f0', reason: `${q}+` };
if (qi >= 3) return { action: 'keep', label: '⬆ Keep', color: '#fdcb00', reason: q };
if (qi >= 1) return { action: 'salvage', label: '♻ Salvage', color: '#888', reason: q };
return { action: 'sell', label: '💰 Sell', color: '#f80', reason: 'Crude' };
}
function enhanceArmory() {
// Physical bulk select/sell/salvage/shrine buttons in Armory are owned by HVUT.
// HV Unified provides advisory scoring via HV.analyzeGear() and gear-analysis panel.
}
// ═══════════════════════════════════════════════════════════════════════
// GEAR SCRAPER — capture full equipment details for analysis
// ═══════════════════════════════════════════════════════════════════════
// V2: Uses window.dynjs_equip / dynjs_eqstore directly from HV's JS
// memory store — no hover simulation or popup box delays needed.
// Every item's full tooltip HTML is pre-loaded on page load.
const GEAR_DB_KEY = SP + 'geardb';
// ── Access HV's in-memory equipment store ──
function getHVEquipStore() {
const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
// Check window properties (works for var-declared globals like dynjs_equip)
if (w.dynjs_eqstore && Object.keys(w.dynjs_eqstore).length) return w.dynjs_eqstore;
if (w.dynjs_equip && Object.keys(w.dynjs_equip).length) return w.dynjs_equip;
// Check bare names (works for const/let globals that don't create window props)
try {
if (typeof dynjs_eqstore !== 'undefined' && dynjs_eqstore && Object.keys(dynjs_eqstore).length)
return dynjs_eqstore;
} catch(e) {}
try {
if (typeof dynjs_equip !== 'undefined' && dynjs_equip && Object.keys(dynjs_equip).length)
return dynjs_equip;
} catch(e) {}
return null;
}
function getHVEquipData(itemId) {
const store = getHVEquipStore();
return (store && store[itemId]) ? store[itemId] : null;
}
// ── Public API ──
function getGearDB() {
try { return JSON.parse(localStorage[GEAR_DB_KEY] || '[]'); } catch (e) { return []; }
}
function saveGearDB(db) {
try { localStorage[GEAR_DB_KEY] = JSON.stringify(db); } catch (e) {}
}
function logGearSummary() {
const db = getGearDB();
const equipped = db.filter(e => e.source === 'character');
const armory = db.filter(e => e.source === 'armory');
const buy = db.filter(e => e.source === 'buy');
console.log(`%c[HV] 📦 Gear DB: ${db.length} total (${equipped.length} 🟢 equipped, ${armory.length} 📦 stored, ${buy.length} 🛒 purchasable)`, 'color:#0f0');
equipped.forEach(item => {
console.log(` 🟢 [${item.slotType}] ${item.name} (ID: ${item.id}) — ${item.stats ? Object.keys(item.stats).length + ' stats' : 'no stats'}`);
});
if (buy.length > 0) {
const used = new Set(armory.map(i => i.id).concat(equipped.filter(i => i.id).map(i => i.id)));
const newOnly = buy.filter(i => !used.has(i.id));
console.log(`%c 🛒 ${buy.length} purchasable (${newOnly.length} not in armory)`, 'color:#0f0');
newOnly.slice(0, 5).forEach(item => {
console.log(` 💰 ${item.name}${item.price || '?'}${item.stats ? ' (' + Object.keys(item.stats).length + ' stats)' : ''}`);
});
}
}
// ── Parse tooltip HTML string into structured stats ──
// HV stores the full .eq HTML in dynjs_equip[ID].d
function parseEquipHTML(htmlStr) {
if (!htmlStr) return null;
try {
const doc = new DOMParser().parseFromString(htmlStr, 'text/html');
const eq = doc.querySelector('.eq');
if (!eq) return null;
const stats = {};
// Header: type, level, binding
const eqt = eq.querySelector('.eqt');
if (eqt) {
const t = eqt.textContent || '';
const typeM = t.match(/^([A-Za-z\s]+?)(?:\s{2,}|$)/);
if (typeM) stats.type = typeM[1].trim();
const lvM = t.match(/Level\s*(\d+|Unassigned)/i);
if (lvM) stats.level = lvM[1];
const bindM = t.match(/(Tradeable|Soulbound|Blessed)/i);
if (bindM) stats.bind = bindM[1];
}
// Condition / Energy
const eqr = eq.querySelector('.eqr');
if (eqr) {
const r = eqr.textContent || '';
const cM = r.match(/Condition:\s*(\d+)%/i);
if (cM) stats.condition = parseInt(cM[1]);
const eM = r.match(/Energy:\s*([^\s]+)/i);
if (eM) stats.energy = eM[1];
}
// Burden / Interference
const eqc = eq.querySelector('.eqc');
if (eqc) {
const c = eqc.textContent || '';
const bM = c.match(/Burden:\s*([\d.]+)/i);
if (bM) stats.burden = parseFloat(bM[1]);
const iM = c.match(/Interference:\s*([\d.]+)/i);
if (iM) stats.interference = parseFloat(iM[1]);
}
// Parse label/value pairs from stat divs
const parseRow = (div) => {
const label = div.querySelector(':scope > div:first-child');
const val = div.querySelector(':scope > div:nth-child(2)');
if (!label || !val) return;
const key = (label.textContent || '').trim();
const raw = (val.textContent || '').trim();
if (key && raw) {
stats[key] = raw;
const title = div.getAttribute('title') || '';
const baseM = title.match(/Base:\s*(\d+)/i);
if (baseM) stats[key + ' Base'] = parseInt(baseM[1]);
}
};
// Main stats (.ex)
const ex = eq.querySelector('.ex');
if (ex) ex.querySelectorAll(':scope > div').forEach(parseRow);
// Direct-child rows of .eq — weapon damage + proc lines.
// e.g. <div><span>Penetrated Armor</span>: <span>21.7% chance</span></div>
// <div title="Base: 134">+<span>708 Piercing Damage</span></div>
Array.from(eq.children).forEach(child => {
if (child.classList && (child.classList.contains('eqt') || child.classList.contains('eqr')
|| child.classList.contains('eqc') || child.classList.contains('ex') || child.classList.contains('ep'))) return;
const text = (child.textContent || '').trim();
if (!text) return;
// Proc line: "Name: 21.7% chance"
const procM = text.match(/^([A-Za-z][A-Za-z ]+):\s*([\d.]+%?\s*(?:chance|procs?)?)/);
if (procM && child.querySelector('span')) {
stats['Proc ' + procM[1].trim()] = procM[2].trim();
return;
}
// Elemental Strike line (wiki: "X Strike (Y%)" — e.g. "Void Strike (50%)")
// Additional ~50%-of-physical-damage hit; max 2 strikes + Void Strike.
const strikeM = text.match(/^([A-Za-z]+)\s+Strike\s*\(\s*(\d+)%\s*\)$/);
if (strikeM) {
stats['Strike ' + strikeM[1]] = parseInt(strikeM[2]);
return;
}
// Weapon damage line: "+523 Void Damage" / "+708 Piercing Damage"
// Base type can be Slashing/Crushing/Piercing, or Void for Ethereal.
const dmgM = text.match(/^\+([\d,]+)\s*([A-Za-z]+)\s*Damage$/);
if (dmgM) {
stats['Weapon Damage'] = parseInt(dmgM[1].replace(/,/g, ''));
stats['Damage Type'] = dmgM[2];
const title = child.getAttribute('title') || '';
const baseM = title.match(/Base:\s*(\d+)/i);
if (baseM) stats['Weapon Damage Base'] = parseInt(baseM[1]);
return;
}
// Elemental damage lines: "+25 Fire Damage" or "+30 Elec Damage"
const elemM = text.match(/^\+([\d,]+)\s*(Fire|Cold|Elec|Wind|Holy|Dark|Ethereal|Elemental)\s*(?:Damage|Strike)$/);
if (elemM) {
stats['Elemental ' + elemM[2]] = parseInt(elemM[1].replace(/,/g, ''));
return;
}
});
// Extra stat groups (.ep)
eq.querySelectorAll('.ep').forEach(g => {
g.querySelectorAll(':scope > div:not(:first-child)').forEach(parseRow);
});
return stats;
} catch (e) {
return null;
}
}
// ── Scrape Character Equipment page ──
const SLOT_TYPES = ['Mainhand', 'Offhand', 'Head', 'Body', 'Hands', 'Legs', 'Feet'];
function scrapeCharacterEquip() {
const scraped = [];
const slots = document.querySelectorAll('#eqsb > .eqb');
slots.forEach((slot, i) => {
const nameDiv = slot.querySelector(':scope > div[onmouseover]');
const omo = nameDiv ? (nameDiv.getAttribute('onmouseover') || '') : '';
const idM = omo.match(/equips\.set\((\d+)/);
const itemId = idM ? idM[1] : '';
const name = nameDiv ? (nameDiv.textContent || '').trim() : '';
const disabled = slot.classList.contains('eqdisabled');
const obj = {
id: itemId, name, disabled,
slotType: SLOT_TYPES[i] || '',
source: 'character',
scrapedAt: Date.now(),
};
// Pull full stats from HV's in-memory store — instant, no hover
if (itemId) {
const data = getHVEquipData(itemId);
if (data) {
if (data.t) obj.name = data.t;
if (data.q != null) obj.quality = data.q;
if (data.d) obj.stats = parseEquipHTML(data.d);
}
}
scraped.push(obj);
});
if (scraped.length > 0) {
const db = getGearDB();
saveGearDB([...db.filter(e => e.source !== 'character'), ...scraped]);
console.log(`%c[HV] 🗡 Character: ${scraped.length} slots (${scraped.filter(s => s.stats).length} with stats)`, 'color:#0f0');
}
return scraped;
}
// ── Scrape Armory page ──
function scrapeArmory() {
const scraped = [];
const el = document.getElementById('equiplist');
// First, scrape any items visible in the current filter tab
if (el) {
let cat = '';
el.querySelectorAll('table tr').forEach(row => {
if (row.classList.contains('eqtplabel')) { cat = (row.textContent || '').trim(); return; }
if (row.classList.contains('eqselall')) return;
const omo = row.getAttribute('onmouseover') || '';
const idM = omo.match(/hover_equip\((\d+)\)/);
const cb = row.querySelector('input[name="eqids[]"]');
const itemId = idM ? idM[1] : (cb ? cb.value : '');
const label = row.querySelector('label');
let name = label ? (label.textContent || '').trim() : '';
if (cb && label) name = (label.textContent || '').replace(cb.outerHTML, '').trim();
if (!itemId && !name) return;
const obj = { id: itemId, name: name || 'Unknown', category: cat, source: 'armory', scrapedAt: Date.now() };
if (itemId) {
const data = getHVEquipData(itemId);
if (data) {
if (data.t) obj.name = data.t;
if (data.q != null) obj.quality = data.q;
if (data.d) obj.stats = parseEquipHTML(data.d);
}
}
scraped.push(obj);
});
}
// Second, scan the full store for items NOT in the visible list
// This catches all inventory regardless of which filter tab you're on
const store = getHVEquipStore();
if (store) {
const visibleIds = new Set(scraped.map(s => s.id));
Object.entries(store).forEach(([id, data]) => {
if (visibleIds.has(id)) return; // already scraped from DOM
const stats = data.d ? parseEquipHTML(data.d) : null;
// Try to determine category from stats type
let category = 'All Items';
if (stats && stats.type) {
if (stats.type.includes('Cloth')) category = 'Cloth Armor';
else if (stats.type.includes('Light')) category = 'Light Armor';
else if (stats.type.includes('Heavy')) category = 'Heavy Armor';
else if (stats.type.includes('Shield')) category = 'Shield';
else if (stats.type.includes('Staff')) category = 'Staff';
else if (stats.type.includes('Axe') || stats.type.includes('Mace') || stats.type.includes('Sword') || stats.type.includes('Estoc') || stats.type.includes('Rapier') || stats.type.includes('Club') || stats.type.includes('Dagger')) category = 'Weapon';
}
if (!stats || (!data.t && !data.d)) return; // skip empty entries
scraped.push({
id, name: data.t || 'Unknown', category,
quality: data.q, stats, source: 'armory', scrapedAt: Date.now()
});
});
}
if (scraped.length > 0) {
const db = getGearDB();
saveGearDB([...db.filter(e => e.source !== 'armory'), ...scraped]);
console.log(`%c[HV] 📦 Armory: ${scraped.length} items (${scraped.filter(s => s.stats).length} with stats, ${scraped.length - (el ? el.querySelectorAll('table tr[onmouseover]').length : 0)} from store)`, 'color:#0f0');
}
return scraped;
}
// ── Debug ──
function debugScrapeGear() {
const url = window.location.href || '';
const store = getHVEquipStore();
const storeCount = store ? Object.keys(store).length : 0;
const eqsb = document.getElementById('eqsb');
const slots = eqsb ? eqsb.querySelectorAll(':scope > .eqb').length : 0;
const el = document.getElementById('equiplist');
const rows = el ? el.querySelectorAll('table tr[onmouseover]').length : 0;
console.log(`%c[HV] 🔍 URL=${url} | store=${storeCount} items | eqsb=${slots} slots | equiplist=${rows} rows`, 'color:#f80');
return { url, storeCount, slots, rows };
}
// ── Scrape Bazaar Buy page (equipment for sale by other players) ──
// URL: ?s=Bazaar&ss=am&screen=buy or ?s=Bazaar&ss=bi
function scrapeBuyPage() {
const scraped = [];
const el = document.getElementById('equiplist');
if (!el) return scraped;
let cat = '';
el.querySelectorAll('table tr').forEach(row => {
if (row.classList.contains('eqtplabel')) { cat = (row.textContent || '').trim(); return; }
if (row.classList.contains('eqselall')) return;
const omo = row.getAttribute('onmouseover') || '';
const idM = omo.match(/hover_equip\((\d+)\)/);
const cb = row.querySelector('input[name="eqids[]"]');
const itemId = idM ? idM[1] : (cb ? cb.value : '');
const label = row.querySelector('label');
let name = label ? (label.textContent || '').trim() : '';
if (cb && label) name = (label.textContent || '').replace(cb.outerHTML, '').trim();
// Try to read price from buy pages (second td with credit cost)
let price = '';
if (!idM && !name && !itemId) return;
const cells = row.querySelectorAll('td');
if (cells.length >= 2) {
const priceCell = cells[cells.length - 1]; // last td = price column
if (priceCell) price = (priceCell.textContent || '').trim();
}
const obj = { id: itemId, name: name || 'Unknown', category: cat, price, source: 'buy', scrapedAt: Date.now() };
if (itemId) {
const data = getHVEquipData(itemId);
if (data) {
if (data.t) obj.name = data.t;
if (data.q != null) obj.quality = data.q;
if (data.d) obj.stats = parseEquipHTML(data.d);
}
}
scraped.push(obj);
});
if (scraped.length > 0) {
const db = getGearDB();
// Don't overwrite equipped/armory items — merge buy items
const existing = db.filter(e => e.source !== 'buy');
saveGearDB([...existing, ...scraped]);
console.log(`%c[HV] 🛒 Buy: ${scraped.length} items (${scraped.filter(s => s.stats).length} with stats)`, 'color:#0f0');
}
// Second, scan the full store for items NOT in the visible list
// The Purchase page loads ALL items into dynjs_eqstore regardless of
// the filter tab — one visit captures the whole store.
const store = getHVEquipStore();
if (store) {
const visibleIds = new Set(scraped.map(s => s.id));
const missing = Object.entries(store).filter(([id, data]) => !visibleIds.has(id) && data && (data.t || data.d));
if (missing.length > 0) {
missing.forEach(([id, data]) => {
const stats = data.d ? parseEquipHTML(data.d) : null;
let category = 'All Items';
if (stats && stats.type) {
if (stats.type.includes('Cloth')) category = 'Cloth Armor';
else if (stats.type.includes('Light')) category = 'Light Armor';
else if (stats.type.includes('Heavy')) category = 'Heavy Armor';
else if (stats.type.includes('Shield')) category = 'Shield';
else if (stats.type.includes('Staff')) category = 'Staff';
else category = 'Weapon';
}
scraped.push({ id, name: data.t || 'Unknown', category, quality: data.q, stats, source: 'buy', scrapedAt: Date.now() });
});
const db2 = getGearDB();
saveGearDB([...db2.filter(e => e.source !== 'buy'), ...scraped]);
console.log(`%c[HV] 🛒 Buy: +${missing.length} from store (total ${scraped.length})`, 'color:#0f0');
}
}
return scraped;
}
// ── Auto-detect ──
function autoScrapeGear() {
const url = window.location.href || '';
if (url.includes('ss=eq') || url.includes('ss=ch')) return scrapeCharacterEquip();
if (url.includes('ss=am') && (url.includes('screen=organize') || !url.includes('screen='))) return scrapeArmory();
if (url.includes('ss=am') && url.includes('screen=purchase')) return scrapeBuyPage();
if (url.includes('ss=am') && url.includes('screen=modify')) return scrapeModifyDetail();
if (url.includes('ss=bi')) return scrapeBuyPage();
return [];
}
// ── Modify detail page ──
function scrapeModifyDetail() {
const mainPane = document.getElementById('mainpane');
if (!mainPane) return null;
const idM = window.location.href.match(/eqids?\[\]=(\d+)/);
const itemId = idM ? idM[1] : '';
const nameEl = mainPane.querySelector('h3, .itemname, .eqname');
let name = nameEl ? (nameEl.textContent || '').trim() : '';
let stats = {};
// Parse right-side tooltip if present
const eqDiv = mainPane.querySelector('#equipmodify_right .eq, #equipinfo .eq');
if (eqDiv) stats = parseEquipHTML(eqDiv.outerHTML) || {};
// Parse modify tables
mainPane.querySelectorAll('table.gry, table.grl').forEach(tbl => {
tbl.querySelectorAll('tr').forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 2) {
const k = (cells[0].textContent || '').trim().replace(':', '');
const v = (cells[1].textContent || '').trim();
if (k && v) stats[k] = v;
}
});
});
const text = mainPane.textContent || '';
const durM = text.match(/Durability:\s*(\d+)\s*\/\s*(\d+)/i);
const potM = text.match(/(?:Potency|Item World)\s*:\s*([^\n<]+)/i);
// Enrich from memory
if (itemId) {
const data = getHVEquipData(itemId);
if (data) {
if (!name && data.t) name = data.t;
if (data.d) stats = Object.assign(parseEquipHTML(data.d) || {}, stats);
}
}
const item = { id: itemId, name, stats, durability: durM ? `${durM[1]}/${durM[2]}` : '', potency: potM ? potM[1].trim() : '', source: 'modify', scrapedAt: Date.now() };
if (itemId) {
const db = getGearDB();
const idx = db.findIndex(e => e.id === itemId);
if (idx >= 0) Object.assign(db[idx], item);
else db.push(item);
saveGearDB(db);
}
return item;
}
// ═══════════════════════════════════════════════════════════════════════
// GEAR ANALYSIS — in-game gear comparison (port of analyze_gear.py)
// ═══════════════════════════════════════════════════════════════════════
// Adds a toolbar button on Armory pages. Clicking it:
// 1. Clears old buy data (optional)
// 2. Rescrapes the current page (store or inventory)
// 3. Scores all items and shows per-slot recommendations
// Sources: 🟢 equipped (character), 📦 stored (armory), 🛒 purchasable (buy)
function _num(v) {
if (v == null) return 0;
const s = String(v).replace('+', '').replace('%', '').trim();
const n = parseFloat(s);
return isNaN(n) ? 0 : n;
}
// ── Scoring (mirrors analyze_gear.py) ──
function gearScoreArmor(item) {
const st = item.stats || {};
let s = 0;
s += _num(st['Physical Mitigation']) * 3;
s += _num(st['Evade']) * 2;
s += _num(st['Strength']) * 2;
s += _num(st['Dexterity']) * 2;
s += _num(st['Agility']) * 1.5;
s += _num(st['Endurance']);
s += _num(st['Crushing']);
s += _num(st['Slashing']);
s += _num(st['Piercing']);
s -= _num(st['burden']) * 0.5;
s += (item.quality || 0) * 5;
return s;
}
function gearScoreWeapon(item) {
const st = item.stats || {};
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
const quality = item.quality || 0;
let s = 0;
s += _num(st['Attack Accuracy']) * 2;
s += _num(st['Attack Crit Damage']) * 60;
s += _num(st['Strength']) * 2;
s += _num(st['Dexterity']) * 1.5;
s += _num(st['Parry']) * 0.5;
s += _num(st['Block']) * 0.5;
s += _num(st['Agility']) * 0.5;
s -= _num(st['burden']) * 0.5;
// Base weapon damage (e.g. "+708 Piercing Damage", "+523 Void Damage") — scales with quality
const wd = _num(st['Weapon Damage']);
s += wd * 0.30;
// Elemental Strikes (wiki: "X Strike (Y%)" — separate hit for ~50% of physical damage)
// Expected damage = chance% × 0.5 × weapon damage. Weight same as base damage (0.30).
['Fire', 'Cold', 'Elec', 'Wind', 'Holy', 'Dark', 'Void', 'Ethereal'].forEach(el => {
const chance = _num(st['Strike ' + el]);
if (chance > 0) {
const expected = (chance / 100) * 0.5 * wd;
s += expected > 0 ? expected * 0.30 : chance * 0.15;
}
});
// Proc lines: elemental/status procs add value (e.g. "Proc Penetrated Armor: 21.7%")
let procBonus = 0;
Object.keys(st).forEach(k => {
if (!k.startsWith('Proc ')) return;
const pct = _num(st[k]);
const pname = k.replace('Proc ', '').toLowerCase();
if (pname.includes('penetrated armor') || pname.includes('armor')) procBonus += pct * 0.35;
else if (pname.includes('stun') || pname.includes('freeze') || pname.includes('slow') || pname.includes('paraly')) procBonus += pct * 0.45;
else if (pname.includes('bleeding') || pname.includes('wound') || pname.includes('poison') || pname.includes('burn') || pname.includes('shock')) procBonus += pct * 0.25;
else if (pname.includes('weaken') || pname.includes('impair')) procBonus += pct * 0.25;
else procBonus += pct * 0.15; // generic proc value
});
s += procBonus;
// Quality multiplier (q6=Magnificent, q5=Exquisite, q4=Superior)
s *= 0.8 + (quality * 0.12);
// Weapon type bonus (wiki procs: Piercing→Penetrated Armor, Crushing→Stun,
// Slashing→Bleeding Wound; Estoc=2H Penetrated Armor is the 2H physical build's core)
if (name.includes('estoc')) s *= 1.25;
else if (name.includes('rapier')) s *= 1.20; // 1H Penetrated Armor
else if (name.includes('longsword') || name.includes('katana') || name.includes('shortsword') || name.includes('axe') || name.includes('wakizashi')) s *= 1.10; // Bleeding Wound
else if (name.includes('great mace') || name.includes('club') || name.includes('mace')) s *= 1.15; // Stun
return s;
}
// ── Slot detection ──
const GEAR_SLOT_KEYWORDS = {
'Head': ['helmet','cap','goggles','mask','hood','hat','circlet','crown','visor'],
'Body': ['breastplate','cuirass','robe','tunic','chest','gi','jacket','vest','hauberk'],
'Hands': ['gauntlets','gloves','mitts','bracers','handguards','fists'],
'Legs': ['leggings','greaves','pants','trousers','loincloth','kilt','breeches','chaps'],
'Feet': ['boots','sabatons','shoes','slippers','sandals'],
};
function gearDetectSlot(item) {
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
for (const [slot, kws] of Object.entries(GEAR_SLOT_KEYWORDS)) {
for (const kw of kws) {
// Word-boundary match: prevents 'gi' matching inside 'leggings',
// 'cap' inside 'capacity', etc.
if (new RegExp('\\b' + kw + '\\b').test(name)) return slot;
}
}
return null;
}
function gearIsTwoHandWeapon(item) {
const st = item.stats || {};
const itype = st.type || '';
if (itype.includes('Armor') || itype.includes('Shield') || itype.includes('Staff')) return false;
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
if (['estoc','longsword','great mace','scythe','axe','club'].some(k => name.includes(k))) return true;
return false;
}
function gearUsable(item, playerLv) {
const lv = (item.stats || {}).level;
if (!lv || lv === 'Unassigned') return true;
const n = parseInt(lv);
if (isNaN(n)) return true;
return n <= (playerLv || 155) + 15;
}
function gearPlayerLevel() {
// Prefer battle-parser cached level (STATE.level)
if (STATE && STATE.level) return STATE.level;
// Parse from the HV cfg button (e.g. "Lv195")
const btn = document.getElementById('hv-cfg-btn');
if (btn) {
const m = (btn.textContent || '').match(/Lv(\d+)/i);
if (m) return parseInt(m[1]);
}
// Fallback: level_readout (may be CSS-font with no text)
const el = document.getElementById('level_readout');
if (el) {
const m = (el.textContent || '').match(/(\d+)/);
if (m) return parseInt(m[1]);
}
return 155;
}
// ── Filter state (persisted) ──
const GEAR_FILTER_KEY = SP + 'gearFilter';
function getGearFilter() {
try {
const f = JSON.parse(localStorage[GEAR_FILTER_KEY] || 'null');
if (f && f.armor && f.weapon) return f;
} catch (e) {}
return { armor: 'Light', weapon: '2H' };
}
function setGearFilter(f) {
try { localStorage[GEAR_FILTER_KEY] = JSON.stringify(f); } catch (e) {}
}
// ── Main analysis ──
function analyzeGear() {
const db = getGearDB();
const equipped = db.filter(e => e.source === 'character' && e.id);
const armory = db.filter(e => e.source === 'armory' && e.stats);
const buy = db.filter(e => e.source === 'buy' && e.stats);
const playerLv = gearPlayerLevel();
const filter = getGearFilter();
// Find equipped slot map (slotType from character page)
const slotMap = {};
equipped.forEach(e => { if (e.slotType) slotMap[e.slotType] = e; });
// Does this item match the armor type filter (Cloth/Light/Heavy/All)?
function matchesArmorType(item) {
if (filter.armor === 'All') return true;
const type = (item.stats && item.stats.type) || '';
const cat = (item.category || '') + ' ' + (item.name || '');
return type.includes(filter.armor) || cat.includes(filter.armor);
}
// Does this weapon match the weapon class filter (Estoc/Longsword/.../2H)?
function matchesWeaponClass(item) {
if (filter.weapon === '2H') return true;
const name = (item.name || '').toLowerCase();
return name.includes(filter.weapon.toLowerCase());
}
const results = [];
const slots = ['Mainhand', 'Head', 'Body', 'Hands', 'Legs', 'Feet'];
slots.forEach(slot => {
const current = slotMap[slot];
const cands = [];
const consider = (item, source) => {
if (!item || !item.stats) return;
const isWeapon = slot === 'Mainhand';
if (isWeapon) {
if (!gearIsTwoHandWeapon(item)) return;
if (!item.name || !item.stats['Attack Accuracy']) return;
// Apply weapon class filter — but always include the equipped item
if (source !== 'equipped' && !matchesWeaponClass(item)) return;
} else {
const det = gearDetectSlot(item);
if (det !== slot) return;
if (!item.stats['Physical Mitigation'] && !item.stats['Evade']) return;
// Apply armor type filter — but always include the equipped item
if (source !== 'equipped' && !matchesArmorType(item)) return;
}
// Level filter — always include the equipped item (it's what you have on)
if (source !== 'equipped' && !gearUsable(item, playerLv)) return;
const score = isWeapon ? gearScoreWeapon(item) : gearScoreArmor(item);
cands.push({ item, source, score });
};
if (current) consider(current, 'equipped');
armory.forEach(i => consider(i, 'armory'));
buy.forEach(i => consider(i, 'buy'));
cands.sort((a, b) => b.score - a.score);
results.push({ slot, current, cands });
});
return { playerLv, filter, results };
}
// ── UI ──
function buildGearPanel() {
// Remove old panel
const old = document.getElementById('hv-gear-panel');
if (old) old.remove();
const panel = document.createElement('div');
panel.id = 'hv-gear-panel';
panel.style.cssText = css({
position: 'fixed', right: '8px', top: '48px', zIndex: 99999,
width: '420px', maxHeight: '80vh', overflowY: 'auto',
background: '#12121e', border: '1px solid #333', borderRadius: '6px',
padding: '8px', fontSize: '11px', fontFamily: 'monospace',
boxShadow: '0 4px 16px rgba(0,0,0,.6)',
});
const close = document.createElement('button');
close.textContent = '✕';
close.style.cssText = 'float:right;cursor:pointer;background:#333;border:none;color:#fff;border-radius:3px;padding:2px 6px';
close.onclick = () => panel.remove();
panel.appendChild(close);
const title = document.createElement('div');
title.textContent = '🛡️ Gear Analysis';
title.style.cssText = 'font-weight:bold;margin-bottom:6px;color:#ffd';
panel.appendChild(title);
// ── Filters row ──
const filterRow = document.createElement('div');
filterRow.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:8px;font-size:10px;color:#aaa';
const filter = getGearFilter();
filterRow.appendChild(Object.assign(document.createElement('span'), { textContent: 'Armor:' }));
const armorSel = document.createElement('select');
armorSel.style.cssText = 'background:#222;color:#fff;border:1px solid #444;border-radius:3px;font-size:10px;font-family:monospace';
['Light', 'Heavy', 'Cloth', 'All'].forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === filter.armor) opt.selected = true;
armorSel.appendChild(opt);
});
filterRow.appendChild(armorSel);
filterRow.appendChild(Object.assign(document.createElement('span'), { textContent: 'Weapon:' }));
const weaponSel = document.createElement('select');
weaponSel.style.cssText = 'background:#222;color:#fff;border:1px solid #444;border-radius:3px;font-size:10px;font-family:monospace';
['2H', 'Estoc', 'Longsword', 'Great Mace', 'Club', 'Axe', 'Scythe'].forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === filter.weapon) opt.selected = true;
weaponSel.appendChild(opt);
});
filterRow.appendChild(weaponSel);
panel.appendChild(filterRow);
// Rebuild panel when filter changes
function applyFilter() {
setGearFilter({ armor: armorSel.value, weapon: weaponSel.value });
buildGearPanel();
}
armorSel.onchange = applyFilter;
weaponSel.onchange = applyFilter;
const { results } = analyzeGear();
results.forEach(({ slot, current, cands }) => {
const block = document.createElement('div');
block.style.cssText = 'margin-bottom:10px;border-top:1px solid #222;padding-top:6px';
const head = document.createElement('div');
head.textContent = `${slot}: ${current ? current.name : '—'}`;
head.style.cssText = 'color:#9cf;font-weight:bold;margin-bottom:2px';
block.appendChild(head);
if (!cands.length) {
const none = document.createElement('div');
none.textContent = ' (no data)';
none.style.cssText = 'color:#666';
block.appendChild(none);
panel.appendChild(block);
return;
}
// Show top 4 candidates
cands.slice(0, 4).forEach((c, i) => {
const icon = c.source === 'equipped' ? '🟢' : c.source === 'armory' ? '📦' : '🛒';
const row = document.createElement('div');
const price = c.item.price ? ` 💰${c.item.price}` : '';
const isCurrent = c.source === 'equipped';
row.textContent = ` ${i + 1}. [${c.score.toFixed(0)}] ${icon} ${c.item.name}${isCurrent ? ' ⬅' : ''}${price}`;
row.style.cssText = isCurrent ? 'color:#8f8' : 'color:#ccc';
if (i === 0 && !isCurrent) row.style.color = '#ff0';
block.appendChild(row);
});
// Highlight best option
const best = cands[0];
if (best && best.source !== 'equipped' && current) {
const equippedCand = cands.find(c => c.source === 'equipped');
// If equipped somehow missing from candidates, score it directly
const eqScore = equippedCand ? equippedCand.score
: (current.stats ? (slot === 'Mainhand' ? gearScoreWeapon(current) : gearScoreArmor(current)) : 0);
const rec = document.createElement('div');
const icon = best.source === 'armory' ? '📦 Stored' : '🛒 For Sale';
rec.textContent = ` ✅ Upgrade: ${icon} ${best.item.name} (${best.score.toFixed(0)} vs ${eqScore.toFixed(0)})`;
rec.style.cssText = 'color:#0f0;margin-top:2px';
block.appendChild(rec);
} else if (best && best.source === 'equipped') {
const ok = document.createElement('div');
ok.textContent = ' ✅ Already best';
ok.style.cssText = 'color:#0f0';
block.appendChild(ok);
}
panel.appendChild(block);
});
document.body.appendChild(panel);
}
function enhanceGearAnalysis() {
// Works on Bazaar pages (equiplist) AND equipment pages (eqsb)
const equiplist = document.getElementById('equiplist');
const eqsb = document.getElementById('eqsb');
if (!equiplist && !eqsb) return;
if (document.getElementById('hv-gear-btn')) return;
const url = window.location.href || '';
const isBuy = url.includes('screen=purchase') || url.includes('ss=bi');
// On equipment pages, only show the Analyze button (no rescan — no store data there)
const isEquipPage = !!eqsb && !equiplist;
const bar = document.createElement('div');
bar.id = 'hv-gear-btn';
bar.style.cssText = css({
display: 'inline-flex', gap: '4px', margin: '2px 0',
});
// Analyze button
const analyzeBtn = document.createElement('button');
analyzeBtn.type = 'button'; // CRITICAL: prevent form submit (inside armory form)
analyzeBtn.textContent = '🔍 Analyze Gear';
analyzeBtn.style.cssText = css({
padding: '3px 8px', background: '#2a2a4a', color: '#fff',
border: '1px solid #555', borderRadius: '3px', cursor: 'pointer',
fontSize: '11px', fontFamily: 'monospace',
});
analyzeBtn.onclick = () => {
try {
autoScrapeGear();
} catch (e) { console.error('[HV] scrape failed:', e); }
setTimeout(() => {
try { buildGearPanel(); }
catch (e) { console.error('[HV] gear panel failed:', e); }
}, 300);
};
bar.appendChild(analyzeBtn);
// Rescan button (clears buy data then rescrapes)
const rescanBtn = document.createElement('button');
rescanBtn.type = 'button'; // CRITICAL: prevent form submit (inside armory form)
rescanBtn.textContent = '🔄 Clear Buy + Rescan';
rescanBtn.style.cssText = css({
padding: '3px 8px', background: '#3a2a2a', color: '#fff',
border: '1px solid #555', borderRadius: '3px', cursor: 'pointer',
fontSize: '11px', fontFamily: 'monospace',
});
rescanBtn.onclick = () => {
// Clear old buy data
const db = getGearDB();
saveGearDB(db.filter(e => e.source !== 'buy'));
console.log('%c[HV] 🧹 Cleared old buy data', 'color:#f80');
// Rescan current page
const scraped = autoScrapeGear();
const count = scraped.length || 0;
console.log(`%c[HV] 🔄 Rescanned: ${count} items`, 'color:#0f0');
// If on buy page, also scan store after short delay
if (isBuy) {
setTimeout(() => {
autoScrapeGear();
buildGearPanel();
}, 500);
} else {
setTimeout(buildGearPanel, 300);
}
};
bar.appendChild(rescanBtn);
if (isEquipPage) rescanBtn.style.display = 'none';
// Insert into the page — before equiplist (Bazaar) or after eqsb (equipment page)
const container = document.createElement('div');
container.style.cssText = 'margin-bottom:4px';
container.appendChild(bar);
if (equiplist) {
equiplist.parentNode.insertBefore(container, equiplist);
} else if (eqsb) {
eqsb.parentNode.insertBefore(container, eqsb.nextSibling);
}
console.log('%c[HV] 🛡️ Gear analysis enabled — click 🔍 or 🔄', 'color:#0f0');
}
// ═══════════════════════════════════════════════════════════════════════
// BATTLE LOGGER — REMOVED (Handled by Monsterbation combat log)
// ═══════════════════════════════════════════════════════════════════════
function initBattleLog() {}
function logRound() {}
function getBattleLog() { return null; }
function getBattleSummary() { return null; }
// ═══════════════════════════════════════════════════════════════════════
// RE TIMER — REMOVED (Handled by HVUtils / HVUT)
// ═══════════════════════════════════════════════════════════════════════
function setupRETimer() {}
function updateRETimer() {}
// ── Settings page: auto-sell/salvage defaults ──
function enhanceSettings() {
if (document.getElementById('hv-settings-enh')) return;
const autoTable = document.getElementById('settings_autosalvage');
if (!autoTable) return;
// Check current state
const rows = [];
$$('tr', autoTable).forEach(tr => {
const sellSelect = tr.querySelector('select[name^="as_c_"]');
const salvSelect = tr.querySelector('select[name^="as_s_"]');
if (!sellSelect || !salvSelect) return;
const itemName = (tr.querySelector('td:first-child')?.textContent || '').trim();
rows.push({
name: itemName,
sell: sellSelect,
salv: salvSelect,
sellVal: parseInt(sellSelect.value),
salvVal: parseInt(salvSelect.value),
});
});
if (rows.length === 0) return;
// Build toolbar
const bar = document.createElement('div');
bar.id = 'hv-settings-enh';
bar.style.cssText = css({
margin: '8px 0',
padding: '6px 8px',
background: '#1a1a2e',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'monospace',
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
alignItems: 'center',
border: '1px solid #374151',
});
const label = document.createElement('span');
label.style.cssText = 'color:#fdcb00;font-weight:bold;margin-right:6px';
label.textContent = '⚙ Auto-Sell/Salvage';
bar.appendChild(label);
// Preset: Sell Fair (2), Salvage Average (3)
const preset1 = document.createElement('input');
preset1.type = 'button';
preset1.value = '💰 Sell ≤Fair / ♻ Salvage ≤Average';
preset1.style.cssText = 'padding:3px 8px;cursor:pointer;background:#2a5a2a;color:#fff;border:none;border-radius:3px;font-size:10px';
preset1.onclick = () => applyPreset(rows, 2, 3);
bar.appendChild(preset1);
// Preset: Sell Crude (1), Salvage Fair (2) — more conservative
const preset2 = document.createElement('input');
preset2.type = 'button';
preset2.value = '💰 Sell ≤Crude / ♻ Salvage ≤Fair';
preset2.style.cssText = 'padding:3px 8px;cursor:pointer;background:#5a3a2a;color:#fff;border:none;border-radius:3px;font-size:10px';
preset2.onclick = () => applyPreset(rows, 1, 2);
bar.appendChild(preset2);
// Reset to No Auto-Sell/Salvage
const resetBtn = document.createElement('input');
resetBtn.type = 'button';
resetBtn.value = '✗ Disable All';
resetBtn.style.cssText = 'padding:3px 8px;cursor:pointer;background:#555;color:#fff;border:none;border-radius:3px;font-size:10px';
resetBtn.onclick = () => applyPreset(rows, 0, 0);
bar.appendChild(resetBtn);
// Summary of current state
const summary = document.createElement('span');
summary.style.cssText = 'color:#888;margin-left:6px';
const sellVals = rows.map(r => r.sellVal);
const salvVals = rows.map(r => r.salvVal);
const minSell = Math.min(...sellVals);
const minSalv = Math.min(...salvVals);
const maxSell = Math.max(...sellVals);
const maxSalv = Math.max(...salvVals);
const sellLabel = minSell === maxSell ? VAL_LABELS_SELL[minSell] : 'mixed';
const salvLabel = minSalv === maxSalv ? VAL_LABELS_SALV[minSalv] : 'mixed';
summary.textContent = `Current: Sell ${sellLabel} | Salvage ${salvLabel}`;
bar.appendChild(summary);
// Insert above the auto-salvage table
autoTable.parentNode.insertBefore(bar, autoTable);
function applyPreset(rows, sellVal, salvVal) {
rows.forEach(r => {
r.sell.value = sellVal;
r.salv.value = salvVal;
});
// Update summary
summary.textContent = `Current: Sell ${VAL_LABELS_SELL[sellVal]} | Salvage ${VAL_LABELS_SALV[salvVal]}`;
}
}
const VAL_LABELS_SELL = ['Off', 'Crude', 'Fair', 'Average', 'Superior', 'Exquisite', 'Magnificent', 'Legendary'];
const VAL_LABELS_SALV = ['Off', 'Crude', 'Fair', 'Average', 'Superior', 'Exquisite', 'Magnificent', 'Legendary'];
// ═══════════════════════════════════════════════════════════════════════
// PUBLIC API — window.HV for console power users
// ═══════════════════════════════════════════════════════════════════════
window.HV = {
state: () => STATE,
config: () => CFG,
set: (k, v) => setConfig(k, v),
action: () => getRecommendedAction(),
execute: (a) => executeAction(a || getRecommendedAction()),
tier: () => STATE.tier,
stats: () => JSON.parse(localStorage[SP + 'stats'] || '{"battles":0,"credits":0,"drops":0}'),
settings: () => toggleSettings(),
// jpx bridge control
chain: (on) => { CFG.autoChainJpx = !!on; saveConfig(); if (on) { jpxBridgeWire(); jpxChainStart(); } else { jpxChainStop('manual'); } return 'jpx chain: ' + CFG.autoChainJpx; },
chainState: () => ({ active: jpxChainEnabled, rounds: jpxChainRounds, jpx: jpxPresent(), maxRounds: CFG.chainMaxRounds, minHP: CFG.chainMinHP }),
advice: () => {
const s = detectFightingStyle();
return { style: s, attrs: getAttrAdvice(s), spells: getSpellAdvice(), difficulty: getDifficultyAdvice() };
},
difficulty: () => getDifficultyAdvice(),
// Battle log analysis
log: () => getBattleLog(),
summary: () => getBattleSummary(),
saveLog: () => {}, // Auto-saves in real-time now
// Gear database
gear: () => getGearDB(),
gearSummary: () => logGearSummary(),
scrapeGear: () => autoScrapeGear(),
gearDebug: () => debugScrapeGear(),
gearStore: () => { const s = getHVEquipStore(); return s ? `Loaded: ${Object.keys(s).length} items` : 'Not loaded yet'; },
// Gear analysis
analyzeGear: () => analyzeGear(),
gearPanel: () => buildGearPanel(),
clearBuy: () => { const db = getGearDB(); saveGearDB(db.filter(e => e.source !== 'buy')); return 'Buy data cleared'; },
// ── Integration diagnostics (run in battle or on any HV page) ──
diag: () => {
const out = {
version: VERSION,
page: STATE.page,
url: location.href,
difficulty: (document.getElementById('level_readout') || {}).textContent ?
document.getElementById('level_readout').textContent.replace(/\\s+/g, ' ').trim().slice(0, 40) : 'n/a',
tier: STATE.tier,
// Community script detection
scripts: {
monsterbation: !!(document.getElementById('homosex') ||
document.getElementById('mbcfgbt') ||
document.getElementById('gay_sex') ||
typeof window.Monsterbation !== 'undefined'),
hvutils: !!(document.getElementById('hvut_menu') ||
document.getElementById('hvut') ||
window.HVUT ||
document.querySelector('[class*="hvut"], [id*="hvut"]')),
jpx: jpxPresent(),
unified: true,
},
// jpx bridge state
jpxBridge: {
chainEnabled: jpxChainEnabled,
chainRounds: jpxChainRounds,
jpxLastActive: jpxLastActive,
retryCount: jpxChainRetryCount || 0,
},
// State availability
state_ready: !!STATE && !!STATE.page,
monsters: STATE.monsters ? STATE.monsters.length : null,
oc: STATE.oc !== undefined ? STATE.oc : null,
sp: STATE.sp !== undefined ? STATE.sp : null,
hp: STATE.hp !== undefined ? STATE.hp : null,
mp: STATE.mp !== undefined ? STATE.mp : null,
skillsKnown: STATE.skillsKnown ? STATE.skillsKnown.slice(0, 10) : null,
// Advisory engine
advice: (typeof getRecommendedAction === 'function') ? (() => {
try { return getRecommendedAction(); } catch (e) { return { error: e.message }; }
})() : 'NOT DEFINED',
// Gear DB size
gearCount: (() => { try { const db = getGearDB(); return db ? db.length : null; } catch (e) { return null; } })(),
// Errors to watch
lastErrors: (window.__hvErrors || []).slice(-5),
};
return out;
},
};
function init() {
loadConfig();
detectLevel();
STATE.page = detectPage();
// Community script presence detection
STATE.hasMB = !!(window.Monsterbation || document.getElementById('mb_vitals') || document.querySelector('.mb_vital'));
STATE.hasHVUT = !!(window.HVUT || document.querySelector('.hvut-main') || document.getElementById('hvut_menu'));
// Always-on features
addConfigButton();
if (STATE.page === 'battle') {
initializeBattle();
autoCheckTask('battle');
} else {
// Non-battle page enhancements & advisory tools
if (STATE.page === 'itemshop') { enhanceItemShop(); autoCheckTask('buy-health'); }
if (STATE.page === 'equipshop') { enhanceEquipShopWithAdvice(); enhanceGearAnalysis(); }
if (STATE.page === 'shrine') enhanceShrine();
if (STATE.page === 'training') { enhanceTraining(); autoCheckTask('training'); }
if (STATE.page === 'arena') { autoCheckTask('arenas'); autoCheckTask('first-blood'); }
if (STATE.page === 'character') showGuidancePanel();
if (STATE.page === 'settings') enhanceSettings();
if (STATE.page === 'armory') { enhanceArmory(); enhanceGearAnalysis(); }
if (STATE.page === 'abilities') enhanceAbilities();
}
// Watch for dynamic page transitions (arena/grindfest → battle)
const bodyObserver = new MutationObserver(() => {
if (!STATE.inBattle && isBattlePage()) {
initializeBattle();
}
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
}
function initializeBattle() {
if (STATE.battleInitialized && STATE.page === 'battle') return;
STATE.page = 'battle';
STATE.inBattle = true;
parseBattleState();
if (STATE.level > 1) updateTier();
setupKeybindings();
addConfigButton();
showDifficultyBanner();
// Passive battle state parser (updates STATE without interfering with MB UI/clicks)
const log = document.getElementById('textlog');
if (log && !log.dataset.hvObserved) {
log.dataset.hvObserved = '1';
const obs = new MutationObserver(() => {
parseBattleState();
updateConfigButton();
});
obs.observe(log, { childList: true, subtree: true, characterData: true });
}
const vitals = document.getElementById('pane_vitals');
if (vitals && !vitals.dataset.hvObserved) {
vitals.dataset.hvObserved = '1';
const vobs = new MutationObserver(() => {
parseBattleState();
updateConfigButton();
});
vobs.observe(vitals, { childList: true, subtree: true, attributes: true });
}
// jpx bridge: engage the round-chaining loop
if (CFG.autoChainJpx) {
jpxBridgeWire(); // listen for jpx state events
jpxChainStart(); // start first auto round
if (!jpxChainTimer) {
jpxChainTimer = setInterval(() => {
// Fallback: if jpx went inactive but we missed the event
// (page reload etc.), re-run the state handler to re-arm the
// wait-for-monsters chain (guarded by the cooldown inside).
if (jpxChainEnabled && jpxLastActive === false && !jpxChainWaiting && jpxMonstersPresent() && !jpxBattleOver()) {
jpxChainOnState({ active: false });
}
}, 2000);
}
}
// Auto-scrape gear data on character/armory pages
const url = window.location.href || '';
if (url.includes('ss=eq') || url.includes('ss=ch') || url.includes('ss=am')) {
let attempts = 0;
const tryScrape = () => {
const store = getHVEquipStore();
if (store && Object.keys(store).length > 0) {
autoScrapeGear();
return;
}
if (++attempts < 10) {
setTimeout(tryScrape, 500);
} else {
autoScrapeGear();
}
};
setTimeout(tryScrape, 300);
}
STATE.battleInitialized = true;
}
// ── Bootstrap ──
// Global error trap — HV.diag().lastErrors surfaces runtime exceptions
window.__hvErrors = [];
window.addEventListener('error', function (e) {
try {
window.__hvErrors.push(String(e.message || e.type).slice(0, 200));
if (window.__hvErrors.length > 20) window.__hvErrors.shift();
} catch (err) {}
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
console.log('%c🛡 HV Unified v' + VERSION + ' Bridge & Advisory Mode%c | ,=settings',
'color:#fdcb00;font-weight:bold', '');
console.log('%c HV.analyzeGear() for gear scoring | HV.advice() for build guidance | HV.difficulty() for difficulty check | HV.diag() for integration diagnostics',
'color:#888;font-size:10px');
})();