- Listen for jpx_ctrlWidget_update (detail.active) — jpx's own state signal - Re-trigger M when jpx reports inactive + monsters present + not battle over - Fallback 2s poll in case the event is missed on page reload - Cooldown guard (2.5s) to avoid double-trigger - Stops on maxRounds / low HP / battle finish
3961 lines
167 KiB
JavaScript
3961 lines
167 KiB
JavaScript
// ==UserScript==
|
||
// @name HV Unified
|
||
// @namespace hvunified
|
||
// @version 0.17.1
|
||
// @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.1';
|
||
|
||
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
|
||
|
||
function jpxPresent() {
|
||
return !!(document.getElementById('ctrl-widget') ||
|
||
document.querySelector('#ctrl-widget, .ctrl-widget'));
|
||
}
|
||
|
||
// Trigger jpx's auto-battle toggle (equivalent to pressing M).
|
||
// jpx listens for keydown on document (capture phase) and maps key 'm' to
|
||
// toggleActive via userKeybinds. A synthetic KeyboardEvent works because
|
||
// jpx reads e.key, not e.isTrusted.
|
||
function jpxTriggerAuto() {
|
||
try {
|
||
const evt = new KeyboardEvent('keydown', {
|
||
key: 'm',
|
||
code: 'KeyM',
|
||
keyCode: 77,
|
||
which: 77,
|
||
bubbles: true,
|
||
cancelable: true,
|
||
});
|
||
document.dispatchEvent(evt);
|
||
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();
|
||
}
|
||
|
||
// Start the chain (called after battle initializes)
|
||
function jpxChainStart() {
|
||
if (!CFG.autoChainJpx) return;
|
||
if (jpxChainEnabled) return;
|
||
if (!jpxPresent()) return;
|
||
|
||
jpxChainEnabled = true;
|
||
jpxChainRounds = 0;
|
||
jpxChainCooldown = Date.now();
|
||
// Start the first auto round (equivalent to pressing M once)
|
||
setTimeout(jpxTriggerAuto, 800);
|
||
console.log('%c[HV] ⛓ jpx auto-chain engaged (re-triggering M each round)', 'color:#0f0');
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Re-trigger for the next round — with a small delay so jpx finishes
|
||
// its re-init (reDoBattle resets state) before we press M again.
|
||
// Guard against double-firing within 2.5s.
|
||
const now = Date.now();
|
||
if (now - jpxChainCooldown < 2500) return;
|
||
jpxChainCooldown = now;
|
||
jpxChainRounds++;
|
||
setTimeout(jpxTriggerAuto, 600);
|
||
console.log(`%c[HV] ⛓ chaining round ${jpxChainRounds}${CFG.chainMaxRounds ? '/' + CFG.chainMaxRounds : ''}`, 'color:#0f0');
|
||
}
|
||
}
|
||
|
||
function jpxChainStop(reason) {
|
||
if (!jpxChainEnabled) return;
|
||
jpxChainEnabled = false;
|
||
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) { jpxChainStart(); if (!jpxChainTimer) jpxChainTimer = setInterval(jpxChainTick, 1500); } else { jpxChainStop('manual'); } return 'jpx chain: ' + CFG.autoChainJpx; },
|
||
chainState: () => ({ active: jpxChainStarted, 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"]')),
|
||
unified: true,
|
||
},
|
||
// 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 poll: if jpx reports inactive but we missed the event
|
||
// (page reload etc.), check monsters + continue button
|
||
if (jpxChainEnabled && jpxLastActive === false && 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');
|
||
|
||
})();
|
||
|
||
|