- Moved findBestChannelingTarget() BEFORE the BUFF_PRIORITY loop - When Q is pressed and channeling is active, the script now picks the most expensive buff for the channeling charge instead of wasting it on the first buff in priority order (Haste at 32 MP instead of Heartseeker at 175) - Reverted the vitals auto-execute (Q-key workflow, not auto-battle) - All three tiers: channeling check fires after castInitialBuffs but before the regular shouldBuff loop
3319 lines
131 KiB
JavaScript
3319 lines
131 KiB
JavaScript
// ==UserScript==
|
||
// @name HV Unified
|
||
// @namespace hvunified
|
||
// @version 0.13.0
|
||
// @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse
|
||
// @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.13.0';
|
||
|
||
const CFG = {
|
||
// — Battle automation
|
||
hotkey: 'KeyQ',
|
||
hotkeyMod: '',
|
||
hoverEnabled: true,
|
||
autoBuff: true,
|
||
autoDebuff: true,
|
||
autoCure: true,
|
||
autoSpirit: false, // Manual spirit stance only (auto-disable at 65% SP for Spark)
|
||
|
||
// — 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
|
||
spiritPotionSP: 0.30, // Use spirit gems/potions below this SP
|
||
spiritStanceOC: 80, // Manual spirit stance only (auto-disable at 65% SP for Spark)
|
||
skillOC: 75, // Use weapon skills at this OC
|
||
useAttackSpells: false, // Don't use attack spells (let channel proc naturally)
|
||
|
||
// — Out of battle
|
||
autoSell: true,
|
||
sellQuality: 'Average',
|
||
bulkShrine: true,
|
||
reTimer: true,
|
||
trainingQueue: true,
|
||
|
||
// — UI
|
||
showCooldowns: true,
|
||
showMonsterHP: true,
|
||
showDurations: true,
|
||
alertColours: true,
|
||
showMonsterNumbers: true,
|
||
cfgButton: true,
|
||
showGuidance: true,
|
||
showEquipAdvice: true,
|
||
autoDifficulty: true,
|
||
|
||
// — Combat style
|
||
useAttackSpells: true,
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// STATE — global runtime state
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
const SP = 'hvunified_';
|
||
|
||
const STATE = {
|
||
page: null,
|
||
level: 1,
|
||
tier: 'novice',
|
||
isekai: false,
|
||
battleMode: '',
|
||
inBattle: false,
|
||
battleInitialized: false,
|
||
|
||
hoverTarget: -1,
|
||
interruptHover: false,
|
||
interruptAlert: false,
|
||
|
||
monsters: [],
|
||
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 = {};
|
||
}
|
||
|
||
// ── 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) {}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// 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');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── 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]');
|
||
|
||
// ── Spells from spell pane ──
|
||
STATE.spellsKnown = [];
|
||
const magicPane = document.getElementById('pane_magic');
|
||
if (magicPane) {
|
||
$$('.btsd[onclick]', magicPane).forEach(el => {
|
||
const omo = el.getAttribute('onmouseover') || '';
|
||
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]) });
|
||
});
|
||
}
|
||
|
||
// ── 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 = {};
|
||
$$('[id^="ikey_"]').forEach(el => {
|
||
const id = el.id.replace('ikey_', '');
|
||
const omo = el.getAttribute('onmouseover') || '';
|
||
const m = omo.match(/battle\.set_infopane_item\((\d+)\)/);
|
||
if (m) STATE.itemsKnown[id] = 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/);
|
||
// 'permanent' means the buff lasts indefinitely (Absorb, autocast effects)
|
||
const isPermanent = omo.includes("'permanent'");
|
||
const name = (img.src || '').split('/').pop().replace('.png', '');
|
||
STATE.buffs[name] = isPermanent ? 999 : (dur ? parseInt(dur[1]) : 50);
|
||
});
|
||
}
|
||
// 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
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
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);
|
||
}
|
||
|
||
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
|
||
// 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;
|
||
}
|
||
|
||
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 (!hasSpell(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;
|
||
|
||
if (STATE.channeling) {
|
||
// Channeling is charged — cast the MOST expensive buff for best value
|
||
candidates.sort((a, b) => b.mpCost - a.mpCost);
|
||
} 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 };
|
||
}
|
||
|
||
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) {
|
||
candidates.sort((a, b) => b.mpCost - a.mpCost);
|
||
return { type: 'spell', name: candidates[0].spell, selfTarget: true };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── Channeling consumption — pick the buff that benefits most from being free ──
|
||
// Channeling gives: 1 MP cost + 50% longer duration.
|
||
// The best target is the buff where:
|
||
// - MP cost is high (more savings from the free cast)
|
||
// - Remaining duration is low (the 50% extension won't be wasted)
|
||
// Score formula: mp_cost / (dur + 1)
|
||
// - Expiring now (dur=0): cost × 1.0 — best candidate
|
||
// - Expiring soon (dur=5): cost × 0.17 — good
|
||
// - Fresh (dur=80): cost × 0.01 — barely worth refreshing
|
||
// No arbitrary cutoffs — every buff is a candidate, scored continuously.
|
||
function findBestChannelingTarget() {
|
||
if (!STATE.channeling || STATE.mp < 0.10) return null;
|
||
|
||
const candidates = [];
|
||
for (const b of BUFF_PRIORITY) {
|
||
if (!hasSpell(b.spell)) continue;
|
||
const dur = buffDuration(b.icon);
|
||
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
|
||
// Skip completely fresh Heartseeker (> 50 turns means just cast)
|
||
if (b.icon === 'heartseeker' && dur > 50) continue;
|
||
const sp = findSpell(b.spell);
|
||
const mpCost = sp ? sp.mp : 0;
|
||
// Score: higher MP cost + lower remaining turns = better channeling target
|
||
const score = mpCost / (dur + 1);
|
||
candidates.push({ ...b, mpCost, dur, score });
|
||
}
|
||
|
||
if (candidates.length > 0) {
|
||
candidates.sort((a, b) => b.score - a.score);
|
||
return { type: 'spell', name: candidates[0].spell, selfTarget: true };
|
||
}
|
||
|
||
// Fallback: ANY damage spell rather than waste the charge on basic attack
|
||
// Try elementals first (cheapest), then any spell we know
|
||
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 — free OC
|
||
// (channeling status is tracked from the battle log)
|
||
const gem = hasUsableGem('channel');
|
||
if (gem && !STATE.channeling)
|
||
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 = hasSpell('Full-Cure') ? 'Full-Cure' : hasSpell('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;
|
||
}
|
||
for (const b of BUFF_PRIORITY) {
|
||
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.2 && hasSpell(b.spell)) {
|
||
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
|
||
return { type: 'spell', name: b.spell, selfTarget: true };
|
||
}
|
||
}
|
||
|
||
// 4. Mana/spirit items — AFTER buffs
|
||
if (STATE.mp < CFG.manaGemMP) {
|
||
const gem = hasUsableGem('mana');
|
||
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
|
||
}
|
||
// Spirit gem (P-slot) — use immediately when available (free to refill)
|
||
const pGem = document.getElementById('ikey_p');
|
||
const pGemId = pGem ? (pGem.getAttribute('onmouseover') || '').match(/set_infopane_item\((\d+)\)/) : null;
|
||
if (pGemId && pGemId[1] === '10007') {
|
||
// P-slot has a Spirit Gem (10007) — use it
|
||
if (hasUsableGem('spirit')) return { type: 'item', id: 'p', selfTarget: true };
|
||
}
|
||
// Spirit draughts/potions — only when SP drops below threshold
|
||
if (STATE.sp < CFG.spiritPotionSP) {
|
||
// Check non-P-slot spirit items only
|
||
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 };
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Spirit Stance at OC 60 — but only if we have SP to sustain it
|
||
if (STATE.oc >= 60 && !STATE.spiritStance && STATE.sp > 0.10)
|
||
return { type: 'toggle_spirit' };
|
||
|
||
// 6. Weapon skills only at OC thresholds with specific conditions
|
||
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 5+ enemies (needs Penetrated Armor from Rending Blow)
|
||
if (STATE.monsters.length >= 5 && 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 = 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 — free OC
|
||
const gem = hasUsableGem('channel');
|
||
if (gem && !STATE.channeling)
|
||
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 = hasSpell('Full-Cure') ? 'Full-Cure' : hasSpell('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
|
||
if (STATE.channeling && STATE.mp > 0.10) {
|
||
const ch = findBestChannelingTarget();
|
||
if (ch) return ch;
|
||
}
|
||
for (const b of BUFF_PRIORITY) {
|
||
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.2 && hasSpell(b.spell)) {
|
||
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
|
||
return { type: 'spell', name: b.spell, selfTarget: true };
|
||
}
|
||
}
|
||
|
||
// 4. Mana/spirit items — AFTER buffs
|
||
if (STATE.mp < CFG.manaGemMP) {
|
||
const gem = hasUsableGem('mana');
|
||
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
|
||
}
|
||
// Spirit gem (P-slot) — use immediately
|
||
const apGem = document.getElementById('ikey_p');
|
||
const apGemId = apGem ? (apGem.getAttribute('onmouseover') || '').match(/set_infopane_item\((\d+)\)/) : null;
|
||
if (apGemId && apGemId[1] === '10007') {
|
||
if (hasUsableGem('spirit')) return { type: 'item', id: 'p', selfTarget: true };
|
||
}
|
||
// Spirit draughts/potions — only at SP < threshold
|
||
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 };
|
||
}
|
||
}
|
||
|
||
// 4. Debuffs — only on actual boss fights (gold border style)
|
||
// At high levels/difficulties, all monsters have SP bars, so
|
||
// we only debuff when a gold-bordered rare/boss is present.
|
||
if (anyRareMonster()) {
|
||
if (hasSpell('Imperil')) {
|
||
const b = findStrongestMonster();
|
||
if (b >= 0 && !checkMonsterDebuff(b, 'imperil'))
|
||
return { type: 'spell', name: 'Imperil', target: b };
|
||
}
|
||
if (hasSpell('Weaken')) {
|
||
const b = findStrongestMonster();
|
||
if (b >= 0 && !checkMonsterDebuff(b, 'weaken'))
|
||
return { type: 'spell', name: 'Weaken', target: b };
|
||
}
|
||
}
|
||
|
||
// 5. Weapon skills — before spirit stance
|
||
const skillOC_N = CFG.skillOC || 75;
|
||
if (STATE.oc >= skillOC_N) {
|
||
// Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest)
|
||
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 5+ enemies (needs Penetrated Armor from Rending Blow)
|
||
if (STATE.monsters.length >= 5 && 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 };
|
||
}
|
||
}
|
||
// Great Cleave: boss/rare fights only (single target, lower priority than AoE)
|
||
if (anyRareMonster() && STATE.skillsKnown.includes('Great Cleave')) {
|
||
const t = findStrongestMonster();
|
||
if (t >= 0) return { type: 'skill', name: 'Great Cleave', target: t };
|
||
}
|
||
}
|
||
|
||
// 6. Spirit Stance — manual only (no auto-activation)
|
||
// Spark of Life consumes 50% SP when triggered. Auto-disable if SP
|
||
// drops below 65% to ensure Spark always has enough SP.
|
||
if (STATE.spiritStance && STATE.sp < 0.65 && !STATE._spiritCooldown) {
|
||
STATE._spiritCooldown = 5;
|
||
return { type: 'toggle_spirit' };
|
||
}
|
||
if (STATE._spiritCooldown > 0) STATE._spiritCooldown--;
|
||
|
||
// 7. 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 = findWeakestMonster();
|
||
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
|
||
return { type: 'attack', target: t };
|
||
return null;
|
||
}
|
||
|
||
// ── Veteran (150-300) / Master (300+): full rotation ──
|
||
|
||
function strategyVeteran() {
|
||
// 0. Mystic Gem for channeling
|
||
const gem = hasUsableGem('channel');
|
||
if (gem && !STATE.channeling)
|
||
return { type: 'item', id: gem.id, selfTarget: true };
|
||
|
||
// 1. Health items — safe to use early
|
||
if (STATE.hp < 0.60) {
|
||
const gem = hasUsableGem('heal');
|
||
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
|
||
}
|
||
|
||
// 2. Cure
|
||
if (STATE.hp < CFG.cureHP) {
|
||
const c = hasSpell('Full-Cure') ? 'Full-Cure' : hasSpell('Cure') ? 'Cure' : null;
|
||
if (c) return { type: 'spell', name: c, selfTarget: true };
|
||
}
|
||
|
||
// 3. Buffs
|
||
// On first round, cast cheapest-first to proc channeling
|
||
if (CFG.autoBuff) {
|
||
{
|
||
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;
|
||
}
|
||
for (const b of BUFF_PRIORITY) {
|
||
if (shouldBuff(b.icon, b.minTurns) && STATE.mp > 0.2 && hasSpell(b.spell)) {
|
||
if (b.icon === 'regen' && STATE.hp >= CFG.cureRegenHP) continue;
|
||
return { type: 'spell', name: b.spell, selfTarget: true };
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. Mana/spirit items — AFTER buffs
|
||
if (STATE.mp < CFG.manaGemMP) {
|
||
const gem = hasUsableGem('mana');
|
||
if (gem) return { type: 'item', id: gem.id, selfTarget: true };
|
||
}
|
||
// Spirit gem (P-slot) — use immediately
|
||
const vpGem = document.getElementById('ikey_p');
|
||
const vpGemId = vpGem ? (vpGem.getAttribute('onmouseover') || '').match(/set_infopane_item\((\d+)\)/) : null;
|
||
if (vpGemId && vpGemId[1] === '10007') {
|
||
if (hasUsableGem('spirit')) return { type: 'item', id: 'p', selfTarget: true };
|
||
}
|
||
// Spirit draughts/potions — only at SP < threshold
|
||
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 };
|
||
}
|
||
}
|
||
|
||
// 4. Debuffs — only on actual boss fights (gold border)
|
||
if (CFG.autoDebuff && STATE.mp > 0.2 && anyRareMonster()) {
|
||
if (hasSpell('Imperil')) {
|
||
const b = findStrongestMonster();
|
||
if (b >= 0 && !checkMonsterDebuff(b, 'imperil'))
|
||
return { type: 'spell', name: 'Imperil', target: b };
|
||
}
|
||
if (hasSpell('Weaken')) {
|
||
const b = findStrongestMonster();
|
||
if (b >= 0 && !checkMonsterDebuff(b, 'weaken'))
|
||
return { type: 'spell', name: 'Weaken', target: b };
|
||
}
|
||
}
|
||
|
||
// 5. Spirit Stance — manual only. Auto-disable if SP < 65% (Spark needs 50%)
|
||
if (CFG.autoSpirit && STATE.oc >= CFG.spiritStanceOC && !STATE.spiritStance && STATE.sp > 0.65)
|
||
return { type: 'toggle_spirit' };
|
||
if (STATE.spiritStance && STATE.sp < 0.65 && !STATE._spiritCooldown) {
|
||
STATE._spiritCooldown = 5;
|
||
return { type: 'toggle_spirit' };
|
||
}
|
||
if (STATE._spiritCooldown > 0) STATE._spiritCooldown--;
|
||
|
||
// 6. Weapon skills only at OC thresholds (let OC build via basic attacks)
|
||
const askillOC3 = CFG.skillOC || 80;
|
||
if (STATE.oc >= askillOC3) {
|
||
// 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 + Shatter Strike: AoE vs 5+ enemies
|
||
if (STATE.monsters.length >= 5) {
|
||
if (STATE.skillsKnown.includes('Rending Blow')) {
|
||
const t = findStrongestMonster();
|
||
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
|
||
}
|
||
if (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 };
|
||
}
|
||
}
|
||
}
|
||
// Other weapon skills (non-2H): generic use
|
||
if (!STATE.skillsKnown.includes('Great Cleave')) {
|
||
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 = findStrongestMonster();
|
||
if (t >= 0) return { type: 'skill', name: sk, target: t };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 7. 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 };
|
||
}
|
||
|
||
// 8. 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;
|
||
}
|
||
|
||
const t = findWeakestMonster();
|
||
if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
|
||
return { type: 'attack', target: t };
|
||
return null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// ACTION EXECUTOR — carry out recommended actions
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
function executeAction(a) {
|
||
if (!a) return false;
|
||
switch (a.type) {
|
||
case 'attack': return attackMonster(a.target);
|
||
case 'spell': return a.selfTarget ? castSelfSpell(a.name) : castTargetSpell(a.name, a.target);
|
||
case 'skill': return useSkill(a.name, a.target);
|
||
case 'item': return useItem(a.id);
|
||
case 'toggle_spirit': return toggleSpiritStance();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
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();
|
||
if (i >= 0 && i < STATE.monsters.length) {
|
||
setTimeout(() => {
|
||
const m = STATE.monsters[i];
|
||
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
|
||
}, 50);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function useItem(id) {
|
||
const it = document.getElementById('ikey_' + id);
|
||
if (!it) return false;
|
||
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();
|
||
if (i >= 0 && i < STATE.monsters.length) {
|
||
setTimeout(() => {
|
||
const m = STATE.monsters[i];
|
||
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
|
||
}, 50);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function toggleSpiritStance() {
|
||
const t = document.getElementById('ckey_spirit');
|
||
if (!t) return false;
|
||
t.click();
|
||
return true;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// HOVER SYSTEM — mouse-over monster actions
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
function setupHover() {
|
||
if (!CFG.hoverEnabled) return;
|
||
|
||
STATE.monsters.forEach((m, i) => {
|
||
if (!m || m.dataset.hvHover) return;
|
||
m.dataset.hvHover = '1';
|
||
|
||
const area = m.querySelector('.btm6') || m;
|
||
area.addEventListener('mouseenter', () => {
|
||
STATE.hoverTarget = i;
|
||
if (!STATE.interruptHover && !STATE.interruptAlert) {
|
||
const ac = getSmartAction();
|
||
if (ac) executeAction(ac);
|
||
}
|
||
});
|
||
area.addEventListener('mouseleave', () => { STATE.hoverTarget = -1; });
|
||
});
|
||
|
||
// RiddleMaster blocks hover
|
||
if (document.getElementById('riddlemaster')) {
|
||
STATE.interruptHover = true;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// KEYBINDINGS — keyboard shortcuts for battle
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
let keybindingsSetup = false;
|
||
|
||
function setupKeybindings() {
|
||
if (keybindingsSetup) return;
|
||
keybindingsSetup = true;
|
||
|
||
document.addEventListener('keydown', function(e) {
|
||
// Settings — comma key
|
||
if (e.keyCode === KEYS.COMMA) {
|
||
if (isBattlePage()) {
|
||
e.preventDefault();
|
||
toggleSettings();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Main action hotkey (default: Q)
|
||
if (e.code === CFG.hotkey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||
if (!isInputFocused() && isBattlePage()) {
|
||
console.log('%c[HV] Q pressed — checking action...', 'color:#888');
|
||
parseBattleState();
|
||
const a = getRecommendedAction();
|
||
if (a) {
|
||
e.preventDefault();
|
||
const label = a.target >= 0 ? 'monster ' + a.target : a.name || a.id;
|
||
console.log(`%c[HV] ▶ ${a.type} → ${label}`, 'color:#0f0');
|
||
executeAction(a);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Hover toggle (H)
|
||
if (e.code === 'KeyH' && !e.ctrlKey && !e.altKey && !e.metaKey && !isInputFocused()) {
|
||
STATE.interruptHover = !STATE.interruptHover;
|
||
console.log(`%c[HV] Hover ${STATE.interruptHover ? 'OFF' : 'ON'}`, 'color:#f80');
|
||
}
|
||
|
||
// Emergency heal (C) — always cast Cure/Full-Cure regardless of strategy
|
||
if (e.code === 'KeyC' && !e.ctrlKey && !e.altKey && !e.metaKey && !isInputFocused()) {
|
||
const c = hasSpell('Full-Cure') ? 'Full-Cure' : hasSpell('Cure') ? 'Cure' : null;
|
||
if (c) {
|
||
console.log(`%c[HV] 🚑 Emergency: casting ${c}`, 'color:#f44');
|
||
castSelfSpell(c);
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log('%c⌨ [HV] Keys: Q=action H=hover C=cure ,=settings', 'color:#0f0;font-size:11px');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// UI OVERLAYS — cooldowns, durations, alerts, monster numbers, monster HP
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
function updateCooldownDisplay() {
|
||
if (!CFG.showCooldowns) return;
|
||
$$('.hv-cd-overlay').forEach(e => e.remove());
|
||
$$('.btqb').forEach(el => {
|
||
const img = el.querySelector('img');
|
||
if (!img) return;
|
||
const src = (img.src || '').toLowerCase();
|
||
for (const [k, v] of Object.entries(STATE.cooldowns)) {
|
||
if (v <= 0) continue;
|
||
if (src.includes(k.toLowerCase()) || src.includes(k.replace(/_/g, ''))) {
|
||
const o = document.createElement('div');
|
||
o.className = 'hv-cd-overlay';
|
||
o.style.cssText = 'position:absolute;top:0;right:0;background:rgba(0,0,0,0.7);color:#f00;font-size:9px;font-weight:bold;padding:0 2px;border-radius:2px;pointer-events:none';
|
||
o.textContent = v;
|
||
el.style.position = 'relative';
|
||
el.appendChild(o);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateDurationDisplay() {
|
||
if (!CFG.showDurations) return;
|
||
$$('.hv-dur-overlay').forEach(e => e.remove());
|
||
const pane = document.getElementById('pane_effects');
|
||
if (!pane) return;
|
||
$$('img[onmouseover]', pane).forEach(img => {
|
||
const omo = img.getAttribute('onmouseover') || '';
|
||
const dur = omo.match(/(\d+)\s*turns?\s*rem/);
|
||
if (!dur) return;
|
||
const t = parseInt(dur[1]);
|
||
const st = omo.match(/(\d+)\s*stacks?/i);
|
||
const o = document.createElement('div');
|
||
o.className = 'hv-dur-overlay';
|
||
o.style.cssText = `position:absolute;bottom:-2px;left:50%;transform:translateX(-50%);background:${t < 3 ? '#f44' : '#44f'};color:#fff;font-size:8px;font-weight:bold;padding:0 2px;border-radius:2px;pointer-events:none;z-index:1`;
|
||
o.textContent = st ? `${t}x${parseInt(st[1])}` : t;
|
||
img.parentElement.style.position = 'relative';
|
||
img.parentElement.appendChild(o);
|
||
});
|
||
}
|
||
|
||
function updateAlertColours() {
|
||
if (!CFG.alertColours) return;
|
||
let bg = '#EDEBDF';
|
||
STATE.interruptAlert = false;
|
||
|
||
if (!$('img[src$="bar_dgreen.png"]') && $('img[src$="fallenshield.png"]')) {
|
||
bg = 'magenta';
|
||
STATE.interruptAlert = true;
|
||
} else if (STATE.hp < 0.25) {
|
||
bg = '#ff4488';
|
||
STATE.interruptAlert = true;
|
||
} else if (STATE.mp < 0.15) {
|
||
bg = '#4444aa';
|
||
} else if (Object.values(STATE.buffs).some(d => d < 2 && d > 0)) {
|
||
bg = '#88ccff';
|
||
}
|
||
|
||
const v = document.getElementById('pane_vitals');
|
||
if (v) v.style.background = bg;
|
||
}
|
||
|
||
function addMonsterNumbers() {
|
||
if (!CFG.showMonsterNumbers) return;
|
||
STATE.monsters.forEach((m, i) => {
|
||
if (!m || !m.querySelector || m.querySelector('.hv-monster-num')) return;
|
||
const b = m.querySelector('.btm1');
|
||
if (!b) return;
|
||
const s = document.createElement('span');
|
||
s.className = 'hv-monster-num';
|
||
s.style.cssText = 'font-weight:bold;margin-right:3px;color:#999';
|
||
s.textContent = (i + 1);
|
||
b.insertBefore(s, b.firstChild);
|
||
});
|
||
}
|
||
|
||
function updateMonsterHPDisplay() {
|
||
if (!CFG.showMonsterHP) return;
|
||
STATE.monsters.forEach((m, i) => {
|
||
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
|
||
const n = m.querySelector('.btm1');
|
||
if (!n) return;
|
||
const nm = (n.textContent || '').replace(/^\d+\s*/, '').trim();
|
||
const md = STATE.monsterData[nm];
|
||
if (!md || !md.hp || md.seen < 3) return;
|
||
const hpBar = m.querySelector('img[src$="nbargreen.png"]');
|
||
if (!hpBar) return;
|
||
const r = clamp(parseInt(hpBar.style.width || '') / 120, 0.01, 1);
|
||
const txt = `HP: ${Math.max(1, Math.round(r * md.hp)).toLocaleString()} / ${md.hp.toLocaleString()}`;
|
||
const ex = m.querySelector('.hv-monster-hp');
|
||
if (ex) { ex.textContent = txt; return; }
|
||
const d = document.createElement('div');
|
||
d.className = 'hv-monster-hp';
|
||
d.style.cssText = 'display:inline-block;position:relative;left:204px;top:-20px;font-size:9px;color:#888';
|
||
d.textContent = txt;
|
||
m.appendChild(d);
|
||
});
|
||
}
|
||
|
||
function saveMonsterData() {
|
||
STATE.monsters.forEach((m, i) => {
|
||
if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return;
|
||
const n = m.querySelector('.btm1');
|
||
if (!n) return;
|
||
const nm = (n.textContent || '').replace(/^\d+\s*/, '').trim();
|
||
if (!nm) return;
|
||
const hpBar = m.querySelector('img[src$="nbargreen.png"]');
|
||
if (hpBar) {
|
||
const r = clamp(parseInt(hpBar.style.width || '') / 120, 0.01, 1);
|
||
if (!STATE.monsterData[nm]) STATE.monsterData[nm] = { hp: 0, seen: 0 };
|
||
STATE.monsterData[nm].hp = Math.max(
|
||
STATE.monsterData[nm].hp,
|
||
Math.round(STATE.monsterData[nm].hp * 0.7 + 1000 / r * 0.3)
|
||
);
|
||
STATE.monsterData[nm].seen++;
|
||
}
|
||
});
|
||
try { localStorage[SP + 'monsters'] = JSON.stringify(STATE.monsterData); } catch (e) {}
|
||
}
|
||
|
||
function refreshUI() {
|
||
if (CFG.showDurations) updateDurationDisplay();
|
||
if (CFG.showCooldowns) updateCooldownDisplay();
|
||
if (CFG.alertColours) updateAlertColours();
|
||
if (CFG.showMonsterNumbers) addMonsterNumbers();
|
||
if (CFG.showMonsterHP) updateMonsterHPDisplay();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// 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}</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">Battle</b>
|
||
${mkTog('Auto-buff (Haste, Protection, etc.)', 'autoBuff')}
|
||
${mkTog('Auto-debuff (Imperil, Weaken)', 'autoDebuff')}
|
||
${mkTog('Auto-cure when HP low', 'autoCure')}
|
||
${mkTog('Auto Spirit Stance', 'autoSpirit')}
|
||
${mkTog('Hover-to-attack mode', 'hoverEnabled')}
|
||
${mkTog('Auto-difficulty suggestion', 'autoDifficulty')}
|
||
${mkTog('Use attack spells', 'useAttackSpells')}
|
||
${mkNum('Cure HP threshold', 'cureHP')}
|
||
${mkNum('Item HP threshold', 'cureItemHP')}
|
||
${mkNum('Mana gem MP threshold', 'manaGemMP')}
|
||
${mkNum('Mana potion MP threshold', 'manaPotionMP')}
|
||
${mkNum('Spirit Stance OC%', 'spiritStanceOC', 5)}
|
||
<div style="border-top:1px solid #444;margin:8px 0"></div>
|
||
<b style="color:#0f0">Out of Battle</b>
|
||
${mkTog('Equip quick sell buttons', 'autoSell')}
|
||
${mkTog('Bulk Shrine buttons', 'bulkShrine')}
|
||
${mkTog('RE Timer', 'reTimer')}
|
||
${mkTog('Training queue info', 'trainingQueue')}
|
||
${mkTog('Advisor panel', 'showGuidance')}
|
||
${mkTog('Equip KEEP/SELL tags', 'showEquipAdvice')}
|
||
<div style="border-top:1px solid #444;margin:8px 0"></div>
|
||
<b style="color:#0f0">UI</b>
|
||
${mkTog('Cooldown timers', 'showCooldowns')}
|
||
${mkTog('Monster HP numbers', 'showMonsterHP')}
|
||
${mkTog('Buff duration counters', 'showDurations')}
|
||
${mkTog('Alert colours', 'alertColours')}
|
||
${mkTog('Monster numbers', 'showMonsterNumbers')}
|
||
${mkTog('Config button', 'cfgButton')}
|
||
<div style="margin-top:12px;text-align:center;color:#666;font-size:10px">
|
||
Changes apply immediately. Press <b>,</b> in battle 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() {
|
||
if (!CFG.bulkShrine) return;
|
||
const m = document.getElementById('mainpane');
|
||
if (!m || document.getElementById('hv-bulk-shrine')) return;
|
||
|
||
const r = document.createElement('div');
|
||
r.id = 'hv-bulk-shrine';
|
||
r.style.cssText = 'margin:6px;display:flex;gap:6px';
|
||
|
||
const b1 = document.createElement('input');
|
||
b1.type = 'button';
|
||
b1.value = '⛩️ Select Non-Figurines';
|
||
b1.style.cssText = 'padding:4px 10px;cursor:pointer;background:#fdcb00;color:#000;border:none;border-radius:3px;font-size:12px';
|
||
b1.onclick = () => {
|
||
$$('tr', m).forEach(row => {
|
||
const t = (row.textContent || '');
|
||
if (!t.includes('Figurine') && !t.includes('Collectable')) {
|
||
const cb = row.querySelector('input[type="checkbox"]');
|
||
if (cb) cb.checked = true;
|
||
}
|
||
});
|
||
};
|
||
|
||
const b2 = document.createElement('input');
|
||
b2.type = 'button';
|
||
b2.value = '✗ Clear';
|
||
b2.style.cssText = 'padding:4px 10px;cursor:pointer;background:#666;color:#fff;border:none;border-radius:3px;font-size:12px';
|
||
b2.onclick = () => { $$('input[type="checkbox"]', m).forEach(cb => cb.checked = false); };
|
||
|
||
r.appendChild(b1);
|
||
r.appendChild(b2);
|
||
const ta = m.querySelector('div');
|
||
if (ta) ta.insertBefore(r, ta.firstChild);
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
|
||
// ── Monster Lab: floating info panel + feed buttons ──
|
||
|
||
let _mlDragOffset = { x: 0, y: 0 };
|
||
|
||
function enhanceMonsterLab() {
|
||
// Create floating draggable panel
|
||
if (document.getElementById('hv-monster')) return;
|
||
|
||
const panel = document.createElement('div');
|
||
panel.id = 'hv-monster';
|
||
panel.style.cssText = css({
|
||
position: 'fixed',
|
||
top: '80px',
|
||
right: '4px',
|
||
zIndex: '9999',
|
||
background: '#111827',
|
||
color: '#d1d5db',
|
||
padding: '8px 10px',
|
||
borderRadius: '8px',
|
||
fontSize: '10px',
|
||
fontFamily: 'monospace',
|
||
maxWidth: '320px',
|
||
boxShadow: '0 0 15px rgba(0,0,0,0.6)',
|
||
border: '1px solid #374151',
|
||
cursor: 'move',
|
||
userSelect: 'none',
|
||
});
|
||
|
||
const body = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;cursor:move" id="hv-monster-header">
|
||
<b style="color:#fdcb00;font-size:11px">🧬 Monster Lab</b>
|
||
<span id="hv-monster-toggle" style="color:#888;font-size:14px;cursor:pointer">−</span>
|
||
</div>
|
||
<div id="hv-monster-body">
|
||
<div style="margin-bottom:6px;display:flex;gap:4px">
|
||
<input type="button" value="🍖 Feed All" style="padding:4px 10px;cursor:pointer;background:#2a5a2a;color:#fff;border:none;border-radius:3px;font-size:11px" id="hv-feed-all">
|
||
<input type="button" value="💊 Happy Pills All" style="padding:4px 10px;cursor:pointer;background:#5a3a2a;color:#fff;border:none;border-radius:3px;font-size:11px" id="hv-pill-all">
|
||
</div>
|
||
<div style="color:#9ca3af;font-size:9px;border-top:1px solid #374151;padding-top:4px">
|
||
New to ML? Pick a <b style="color:#fdcb00">Beast-class</b> (Dog/Cow/Pig) for STR/END crystals.<br>
|
||
<span style="color:#888">Click + drag header to move.</span>
|
||
</div>
|
||
</div>`;
|
||
|
||
panel.innerHTML = body;
|
||
document.body.appendChild(panel);
|
||
|
||
// ── Feed All ──
|
||
panel.querySelector('#hv-feed-all').onclick = () => {
|
||
const m = document.getElementById('mainpane');
|
||
if (m) $$('input[value="Feed"]', m).forEach(b => b.click());
|
||
};
|
||
|
||
// ── Happy Pills All ──
|
||
panel.querySelector('#hv-pill-all').onclick = () => {
|
||
const m = document.getElementById('mainpane');
|
||
if (m) {
|
||
const pills = $$('option', m).filter(o => (o.textContent || '').includes('Happy Pill'));
|
||
if (pills.length > 0) {
|
||
pills[0].selected = true;
|
||
$$('input[value="Feed"]', m).forEach(b => b.click());
|
||
}
|
||
}
|
||
};
|
||
|
||
// ── Toggle ──
|
||
panel.querySelector('#hv-monster-toggle').onclick = () => {
|
||
const body = panel.querySelector('#hv-monster-body');
|
||
const isHidden = body.style.display === 'none';
|
||
body.style.display = isHidden ? 'block' : 'none';
|
||
panel.querySelector('#hv-monster-toggle').textContent = isHidden ? '−' : '+';
|
||
};
|
||
|
||
// ── Make draggable ──
|
||
const header = panel.querySelector('#hv-monster-header');
|
||
if (header) {
|
||
header.addEventListener('mousedown', (e) => {
|
||
_mlDragOffset.x = e.clientX - panel.getBoundingClientRect().left;
|
||
_mlDragOffset.y = e.clientY - panel.getBoundingClientRect().top;
|
||
const onMove = (ev) => {
|
||
panel.style.left = (ev.clientX - _mlDragOffset.x) + 'px';
|
||
panel.style.right = 'auto';
|
||
panel.style.top = (ev.clientY - _mlDragOffset.y) + 'px';
|
||
};
|
||
const onUp = () => {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
};
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
}
|
||
|
||
// Remove the old inline bar if it exists
|
||
const oldBar = document.getElementById('hv-lab-enhance');
|
||
if (oldBar) oldBar.remove();
|
||
}
|
||
|
||
// ── 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 = 'PFUDOR';
|
||
reason = '20x EXP. You can handle PFUDOR at L150+.';
|
||
break;
|
||
case 'master':
|
||
suggested = 'PFUDOR';
|
||
reason = 'Max rewards. Always PFUDOR.';
|
||
break;
|
||
default:
|
||
suggested = 'Normal';
|
||
reason = '';
|
||
}
|
||
|
||
const currentIdx = difficulties.indexOf(difficulty);
|
||
const suggestedIdx = difficulties.indexOf(suggested);
|
||
|
||
// 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() {
|
||
const equipList = document.getElementById('equiplist');
|
||
if (!equipList || document.getElementById('hv-armory-bar')) return;
|
||
|
||
// ── Parse current equipment in the list ──
|
||
const items = [];
|
||
let currentCategory = '';
|
||
|
||
$$('tr', equipList).forEach(row => {
|
||
if (row.className === 'eqtplabel') {
|
||
currentCategory = (row.textContent || '').trim();
|
||
return;
|
||
}
|
||
|
||
const cb = row.querySelector('input[name="eqids[]"]');
|
||
if (!cb) return;
|
||
|
||
const id = cb.value;
|
||
const label = row.querySelector('label');
|
||
const name = label ? (label.textContent || '').trim() : '';
|
||
|
||
// Status icons in the label text
|
||
const equipped = name.includes('🗡');
|
||
const locked = name.includes('🔒');
|
||
const pinned = name.includes('📌');
|
||
const stored = name.includes('📦');
|
||
const protected_ = name.includes('🛡');
|
||
|
||
// Strip status icons for clean name
|
||
const cleanName = name.replace(/[🗡🔒📌📦🛡️]/g, '').trim();
|
||
|
||
items.push({
|
||
id, name: cleanName, slot: currentCategory,
|
||
equipped, locked, pinned, stored, protected: protected_,
|
||
checkbox: cb, row,
|
||
grade: gradeEquipment(cleanName),
|
||
});
|
||
});
|
||
|
||
if (items.length === 0) return;
|
||
|
||
// ── Detect which armory tab we're on ──
|
||
let screen = 'organize';
|
||
const url = window.location.href || '';
|
||
const sm = url.match(/screen=(\w+)/);
|
||
if (sm) screen = sm[1];
|
||
|
||
// ── Counts ──
|
||
const sellCount = items.filter(i => i.grade.action === 'sell' && !i.equipped && !i.locked).length;
|
||
const salvCount = items.filter(i => i.grade.action === 'salvage' && !i.equipped && !i.locked).length;
|
||
const keepCount = items.filter(i => i.grade.action === 'keep').length;
|
||
|
||
// ── Build the toolbar ──
|
||
const toolbar = document.createElement('div');
|
||
toolbar.id = 'hv-armory-bar';
|
||
toolbar.style.cssText = css({
|
||
display: 'flex',
|
||
gap: '4px',
|
||
flexWrap: 'wrap',
|
||
padding: '4px 6px',
|
||
marginBottom: '2px',
|
||
background: '#1a1a2e',
|
||
borderRadius: '4px',
|
||
fontSize: '10px',
|
||
fontFamily: 'monospace',
|
||
alignItems: 'center',
|
||
});
|
||
|
||
// Summary
|
||
const summary = document.createElement('span');
|
||
summary.style.cssText = 'color:#888;margin-right:6px';
|
||
summary.textContent = `✅${keepCount} ♻${salvCount} 💰${sellCount}`;
|
||
toolbar.appendChild(summary);
|
||
|
||
// ── Smart auto-select: check boxes based on grade ──
|
||
function autoSelect(action) {
|
||
items.forEach(item => {
|
||
if (item.grade.action !== action) return;
|
||
if (item.equipped || item.locked) return;
|
||
item.checkbox.checked = true;
|
||
});
|
||
// Trigger the game's update function if available
|
||
if (typeof update_selected_count === 'function') update_selected_count();
|
||
}
|
||
|
||
// ── Select by quality ──
|
||
function selectByQuality(below) {
|
||
const idx = KB.qualities.indexOf(below);
|
||
items.forEach(item => {
|
||
const qi = KB.qualities.indexOf(KB.qualities.find(q => item.name.includes(q)));
|
||
if (qi < 0) return;
|
||
if (qi <= idx && !item.equipped && !item.locked) {
|
||
item.checkbox.checked = true;
|
||
}
|
||
});
|
||
if (typeof update_selected_count === 'function') update_selected_count();
|
||
}
|
||
|
||
function clearAll() {
|
||
items.forEach(item => item.checkbox.checked = false);
|
||
if (typeof update_selected_count === 'function') update_selected_count();
|
||
}
|
||
|
||
// Buttons — only show relevant ones for current screen
|
||
if (screen === 'sell') {
|
||
const b = document.createElement('input');
|
||
b.type = 'button';
|
||
b.value = `💰 Select Crude (${sellCount})`;
|
||
b.style.cssText = 'padding:2px 6px;cursor:pointer;background:#d50c2d;color:#fff;border:none;border-radius:3px;font-size:10px';
|
||
b.onclick = () => autoSelect('sell');
|
||
toolbar.appendChild(b);
|
||
|
||
const b2 = document.createElement('input');
|
||
b2.type = 'button';
|
||
b2.value = '💰 Select ≤Fair';
|
||
b2.style.cssText = 'padding:2px 6px;cursor:pointer;background:#a00;color:#fff;border:none;border-radius:3px;font-size:10px';
|
||
b2.onclick = () => selectByQuality('Fair');
|
||
toolbar.appendChild(b2);
|
||
}
|
||
|
||
if (screen === 'salvage') {
|
||
const b = document.createElement('input');
|
||
b.type = 'button';
|
||
b.value = `♻ Select Salvage (${salvCount})`;
|
||
b.style.cssText = 'padding:2px 6px;cursor:pointer;background:#555;color:#fff;border:none;border-radius:3px;font-size:10px';
|
||
b.onclick = () => autoSelect('salvage');
|
||
toolbar.appendChild(b);
|
||
|
||
const b2 = document.createElement('input');
|
||
b2.type = 'button';
|
||
b2.value = '♻ Select ≤Average';
|
||
b2.style.cssText = 'padding:2px 6px;cursor:pointer;background:#444;color:#fff;border:none;border-radius:3px;font-size:10px';
|
||
b2.onclick = () => selectByQuality('Average');
|
||
toolbar.appendChild(b2);
|
||
|
||
// "Sell Salvaged Equipment" toggle is already native
|
||
}
|
||
|
||
if (screen === 'organize') {
|
||
const b = document.createElement('input');
|
||
b.type = 'button';
|
||
b.value = '📌 Pin Equipped';
|
||
b.style.cssText = 'padding:2px 6px;cursor:pointer;background:#2a5a2a;color:#fff;border:none;border-radius:3px;font-size:10px';
|
||
b.onclick = () => {
|
||
// Select all equipped items so they can be pinned
|
||
items.forEach(item => { if (item.equipped) item.checkbox.checked = true; });
|
||
if (typeof update_selected_count === 'function') update_selected_count();
|
||
};
|
||
toolbar.appendChild(b);
|
||
}
|
||
|
||
// Clear button always available
|
||
const clear = document.createElement('input');
|
||
clear.type = 'button';
|
||
clear.value = '✗ Clear';
|
||
clear.style.cssText = 'padding:2px 6px;cursor:pointer;background:#555;color:#fff;border:none;border-radius:3px;font-size:10px';
|
||
clear.onclick = clearAll;
|
||
toolbar.appendChild(clear);
|
||
|
||
// Select All / Invert Selection (useful on any screen)
|
||
const selAll = document.createElement('input');
|
||
selAll.type = 'button';
|
||
selAll.value = '☐ All';
|
||
selAll.style.cssText = 'padding:2px 6px;cursor:pointer;background:#3a4a6a;color:#fff;border:none;border-radius:3px;font-size:10px';
|
||
selAll.onclick = () => {
|
||
items.forEach(item => { item.checkbox.checked = true; });
|
||
if (typeof update_selected_count === 'function') update_selected_count();
|
||
};
|
||
toolbar.appendChild(selAll);
|
||
|
||
const invert = document.createElement('input');
|
||
invert.type = 'button';
|
||
invert.value = '⊞ Invert';
|
||
invert.style.cssText = 'padding:2px 6px;cursor:pointer;background:#444;color:#fff;border:none;border-radius:3px;font-size:10px';
|
||
invert.onclick = () => {
|
||
items.forEach(item => { item.checkbox.checked = !item.checkbox.checked; });
|
||
if (typeof update_selected_count === 'function') update_selected_count();
|
||
};
|
||
toolbar.appendChild(invert);
|
||
|
||
// ── Insert into page ──
|
||
const eqSelect = document.getElementById('equipselect_outer') ||
|
||
document.querySelector('#equipselect_left, #armory_right > div');
|
||
if (equipList && equipList.parentNode) {
|
||
equipList.parentNode.insertBefore(toolbar, equipList);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// BATTLE LOGGER — saves raw battle log lines to localStorage in real time
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
let _lastLogCount = 0;
|
||
const LOG_KEY = SP + 'battleLog';
|
||
|
||
function initBattleLog() {
|
||
_lastLogCount = 0;
|
||
// Clear previous log
|
||
try { localStorage.removeItem(LOG_KEY); } catch (e) {}
|
||
}
|
||
|
||
function logRound() {
|
||
const log = document.getElementById('textlog');
|
||
if (!log) return;
|
||
|
||
const allRows = log.querySelectorAll('tr');
|
||
if (allRows.length <= _lastLogCount) return;
|
||
|
||
// Get only the NEW rows since last capture
|
||
const newLines = [];
|
||
for (let i = _lastLogCount; i < allRows.length; i++) {
|
||
const text = (allRows[i].textContent || '').trim();
|
||
if (text) newLines.push(text);
|
||
}
|
||
_lastLogCount = allRows.length;
|
||
|
||
if (newLines.length === 0) return;
|
||
|
||
// Read existing log from localStorage, append new lines, save back
|
||
let existing = [];
|
||
try {
|
||
const saved = localStorage.getItem(LOG_KEY);
|
||
if (saved) existing = JSON.parse(saved);
|
||
} catch (e) {}
|
||
|
||
existing.push(...newLines);
|
||
|
||
try {
|
||
localStorage.setItem(LOG_KEY, JSON.stringify(existing));
|
||
} catch (e) {
|
||
// localStorage full — keep only last 500 lines
|
||
try {
|
||
const trimmed = existing.slice(-500);
|
||
localStorage.setItem(LOG_KEY, JSON.stringify(trimmed));
|
||
} catch (e2) {}
|
||
}
|
||
}
|
||
|
||
function getBattleLog() {
|
||
try {
|
||
const saved = localStorage.getItem(LOG_KEY);
|
||
return saved ? JSON.parse(saved) : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getBattleSummary() {
|
||
const lines = getBattleLog();
|
||
if (!lines || lines.length === 0) return null;
|
||
|
||
let kills = 0, dmgDealt = 0, dmgTaken = 0, healed = 0;
|
||
const spells = {}, skills = {}, items = {};
|
||
|
||
for (const line of lines) {
|
||
const mKill = line.match(/(.+) has been defeated\./);
|
||
if (mKill && !line.includes('gains the effect')) kills++;
|
||
|
||
const mDmg = line.match(/causing (\d+) points/);
|
||
if (mDmg) dmgDealt += parseInt(mDmg[1]);
|
||
|
||
const mTaken = line.match(/take (\d+) (\w+)/);
|
||
if (mTaken) dmgTaken += parseInt(mTaken[1]);
|
||
|
||
const mRegen = line.match(/Regen restores (\d+)/);
|
||
if (mRegen) healed += parseInt(mRegen[1]);
|
||
const mPot = line.match(/Regeneration restores (\d+)/);
|
||
if (mPot) healed += parseInt(mPot[1]);
|
||
const mCure = line.match(/healed for (\d+)/);
|
||
if (mCure) healed += parseInt(mCure[1]);
|
||
|
||
const mSpell = line.match(/You cast (\w[\w\s-]+)\./);
|
||
if (mSpell) spells[mSpell[1]] = (spells[mSpell[1]] || 0) + 1;
|
||
|
||
const mSkill = line.match(/You use (\w[\w\s-]+)\./);
|
||
if (mSkill && !mSkill[1].includes('Draught') && !mSkill[1].includes('Potion') && !mSkill[1].includes('Elixir') && !mSkill[1].includes('Gem')) {
|
||
skills[mSkill[1]] = (skills[mSkill[1]] || 0) + 1;
|
||
}
|
||
|
||
const mItem = line.match(/You use (.+?)\.$/);
|
||
if (mItem && (mItem[1].includes('Draught') || mItem[1].includes('Potion') || mItem[1].includes('Elixir') || mItem[1].includes('Gem'))) {
|
||
items[mItem[1]] = (items[mItem[1]] || 0) + 1;
|
||
}
|
||
}
|
||
|
||
return {
|
||
totalLines: lines.length,
|
||
kills,
|
||
dmgDealt,
|
||
dmgTaken,
|
||
healed,
|
||
spells,
|
||
skills,
|
||
items,
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// RE TIMER — Random Encounter countdown
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
let reTimerEl = null;
|
||
|
||
function setupRETimer() {
|
||
if (!CFG.reTimer) return;
|
||
if (document.getElementById('hv-re-timer')) return;
|
||
|
||
reTimerEl = document.createElement('div');
|
||
reTimerEl.id = 'hv-re-timer';
|
||
reTimerEl.style.cssText = css({
|
||
position: 'fixed',
|
||
top: '2px',
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
zIndex: '99999',
|
||
background: '#1a1a2e',
|
||
color: '#0f0',
|
||
padding: '2px 10px',
|
||
borderRadius: '0 0 6px 6px',
|
||
fontSize: '10px',
|
||
fontFamily: 'monospace',
|
||
opacity: '0.9',
|
||
});
|
||
reTimerEl.textContent = 'RE: --:--';
|
||
document.body.appendChild(reTimerEl);
|
||
updateRETimer();
|
||
setInterval(updateRETimer, 30000);
|
||
}
|
||
|
||
function updateRETimer() {
|
||
if (!reTimerEl) return;
|
||
const now = Date.now();
|
||
const dt = document.body.textContent || '';
|
||
|
||
// Detect dawn
|
||
if (dt.includes('Dawn of a New Day') || dt.includes('A new day dawns')) {
|
||
try { localStorage[SP + 'lastDawn'] = JSON.stringify(now); } catch (e) {}
|
||
}
|
||
|
||
const ld = JSON.parse(localStorage[SP + 'lastDawn'] || now);
|
||
const ms = (now - ld) / 60000;
|
||
const nr = 30 - (ms % 30);
|
||
|
||
if (nr < 1) {
|
||
reTimerEl.textContent = '⚡ RE READY!';
|
||
reTimerEl.style.color = '#f00';
|
||
reTimerEl.style.fontWeight = 'bold';
|
||
} else {
|
||
const mn = Math.floor(nr);
|
||
const sc = Math.floor((nr - mn) * 60);
|
||
reTimerEl.textContent = `RE: ${String(mn).padStart(2, '0')}:${String(sc).padStart(2, '0')}`;
|
||
reTimerEl.style.color = '#0f0';
|
||
reTimerEl.style.fontWeight = 'normal';
|
||
}
|
||
}
|
||
|
||
// ── 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(),
|
||
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
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// INIT — bootstrap everything
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
function init() {
|
||
loadConfig();
|
||
detectLevel();
|
||
STATE.page = detectPage();
|
||
|
||
// Always-on features
|
||
setupRETimer();
|
||
addConfigButton();
|
||
renderProgressPanel();
|
||
|
||
if (STATE.page === 'battle') {
|
||
initializeBattle();
|
||
autoCheckTask('battle');
|
||
} else {
|
||
// Non-battle page enhancements
|
||
if (STATE.page === 'itemshop') { enhanceItemShop(); autoCheckTask('buy-health'); }
|
||
if (STATE.page === 'equipshop') { enhanceEquipShopWithAdvice(); }
|
||
if (STATE.page === 'shrine') enhanceShrine();
|
||
if (STATE.page === 'training') { enhanceTraining(); autoCheckTask('training'); }
|
||
if (STATE.page === 'monsterlab') { enhanceMonsterLab(); autoCheckTask('feed'); }
|
||
if (STATE.page === 'arena') { autoCheckTask('arenas'); autoCheckTask('first-blood'); }
|
||
if (STATE.page === 'character') showGuidancePanel();
|
||
if (STATE.page === 'settings') enhanceSettings();
|
||
if (STATE.page === 'armory') enhanceArmory();
|
||
if (STATE.page === 'abilities') enhanceAbilities();
|
||
if (STATE.page === 'monster') enhanceMonsterLab();
|
||
}
|
||
|
||
// Dawn initialization
|
||
if (!localStorage[SP + 'lastDawn']) {
|
||
try { localStorage[SP + 'lastDawn'] = JSON.stringify(Date.now()); } catch (e) {}
|
||
}
|
||
updateRETimer();
|
||
|
||
// 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();
|
||
// Force tier update from cached level (battle page may not have level_readout)
|
||
if (STATE.level > 1) updateTier();
|
||
setupHover();
|
||
setupKeybindings();
|
||
addMonsterNumbers();
|
||
refreshUI();
|
||
addConfigButton();
|
||
showDifficultyBanner();
|
||
|
||
// Watch battle log
|
||
const log = document.getElementById('textlog');
|
||
if (log && !log.dataset.hvObserved) {
|
||
log.dataset.hvObserved = '1';
|
||
const obs = new MutationObserver(() => {
|
||
parseBattleState();
|
||
saveMonsterData();
|
||
logRound(); // Log this round's data for analysis
|
||
if (CFG.hoverEnabled && !STATE.interruptHover && !STATE.interruptAlert && STATE.hoverTarget >= 0) {
|
||
const a = getSmartAction();
|
||
if (a) executeAction(a);
|
||
}
|
||
refreshUI();
|
||
updateConfigButton();
|
||
});
|
||
obs.observe(log, { childList: true, subtree: true, characterData: true });
|
||
}
|
||
|
||
// Watch vitals pane
|
||
const vitals = document.getElementById('pane_vitals');
|
||
if (vitals && !vitals.dataset.hvObserved) {
|
||
vitals.dataset.hvObserved = '1';
|
||
const vobs = new MutationObserver(() => {
|
||
parseBattleState();
|
||
logRound(); // Also log on vitals changes (catches end-of-round)
|
||
refreshUI();
|
||
updateConfigButton();
|
||
// Check if battle ended (completion pane appeared)
|
||
if (document.getElementById('pane_completion') && battleLog && !battleLog.summary) {
|
||
finalizeBattleLog();
|
||
}
|
||
});
|
||
vobs.observe(vitals, { childList: true, subtree: true, attributes: true });
|
||
}
|
||
|
||
// RiddleMaster detection
|
||
if (document.getElementById('riddlemaster')) {
|
||
STATE.interruptHover = true;
|
||
}
|
||
|
||
STATE.battleInitialized = true;
|
||
}
|
||
|
||
// ── Bootstrap ──
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
console.log('%c🛡️ HV Unified v' + VERSION + '%c | Q=action H=hover C=cure ,=settings',
|
||
'color:#fdcb00;font-weight:bold', '');
|
||
console.log('%c HV.advice() for guidance | HV.difficulty() for difficulty check',
|
||
'color:#888;font-size:10px');
|
||
|
||
})();
|
||
|