diff --git a/scripts/hv-unified.user.js b/scripts/hv-unified.user.js index e67d3b9..2c607d0 100644 --- a/scripts/hv-unified.user.js +++ b/scripts/hv-unified.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name HV Unified // @namespace hvunified -// @version 0.13.34 +// @version 0.14.0 // @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse // @author GaboGG + Hermes // @match *://*.hentaiverse.org/* @@ -17,7 +17,7 @@ // CONFIG — default settings // ═══════════════════════════════════════════════════════════════════════ -const VERSION = '0.13.34'; +const VERSION = '0.14.0'; const CFG = { // — Battle automation @@ -127,6 +127,17 @@ try { } // Load max buff durations from localStorage (learned across sessions) +// Pre-seed with L150+ baseline values so fill-level formula works from first turn +const DEFAULT_MAX_BUFF_DUR = { + 'haste': 80, + 'protection': 80, + 'sparklife': 50, + 'heartseeker': 120, + 'regen': 50, + 'absorb': 75, + 'shadowveil': 75, +}; +STATE.maxBuffDur = { ...DEFAULT_MAX_BUFF_DUR }; try { const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}'); for (const [k, v] of Object.entries(saved)) { @@ -851,6 +862,27 @@ function hasHighThreat(threshold) { return count >= 2; } +// Find optimal target for Domino Strike splash (2H passive) — center-aligned +// For 2H builds, Domino Strike splashes up to 2 enemies on each side of the target. +// Attacking edge monsters wastes 50% of the splash potential. +function findOptimalDominoTarget() { + const total = STATE.monsters.length; + if (total <= 2) return findWeakestMonster(); + + let best = -1; + let bestScore = -1; + STATE.monsters.forEach((m, i) => { + if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return; + let splash = 0; + if (i - 1 >= 0 && STATE.monsters[i - 1] && STATE.monsters[i - 1].hasAttribute('onclick')) splash++; + if (i - 2 >= 0 && STATE.monsters[i - 2] && STATE.monsters[i - 2].hasAttribute('onclick')) splash++; + if (i + 1 < total && STATE.monsters[i + 1] && STATE.monsters[i + 1].hasAttribute('onclick')) splash++; + if (i + 2 < total && STATE.monsters[i + 2] && STATE.monsters[i + 2].hasAttribute('onclick')) splash++; + if (splash > bestScore) { bestScore = splash; best = i; } + }); + return best >= 0 ? best : 0; +} + function checkMonsterDebuff(idx, db) { const m = STATE.monsters[idx]; if (!m) return false; @@ -1021,8 +1053,15 @@ function findBestChannelingTarget() { } } - // Fallback: ANY damage spell rather than waste the charge on basic attack - // Try elementals first (cheapest), then any spell we know + // Fallback: for physical 2H builds, don't waste channeling on weak damage spells. + // Channeling improves the next spell, but Fiery Blast does negligible damage at IWBTH. + // Better to use the charge on a basic attack that procs Domino Strike and Penetrated Armor. + const isPhysical2H = STATE.skillsKnown.includes('Great Cleave') || STATE.skillsKnown.includes('Rending Blow'); + if (isPhysical2H) { + return null; // Let channeling expire naturally — basic attacks do more than Fiery Blast + } + + // Mage fallback: use any cheap spell for mage builds const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale', 'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst', 'Banishment', 'Disintegrate']); @@ -1145,8 +1184,8 @@ function strategyNovice() { 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')) { + // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) { const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); if (hasArmorBreak) { const t = findStrongestMonster(); @@ -1192,7 +1231,21 @@ function strategyAdept() { // 0. Mystic Gem for channeling — only use once per battle const gem = hasUsableGem('channel'); if (gem && !STATE.channeling && !STATE._mysticUsed) - return { type: 'item', id: gem.id, selfTarget: true }; + return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' }; + + // 0b. Spark of Life SP safety + if (STATE.sp < 0.55) { + for (const [slot, info] of Object.entries(STATE.itemsKnown)) { + if (slot === 'p') continue; + const def = ITEMS[parseInt(info)]; + if (def && def.t === 'spirit') return { type: 'item', id: slot, selfTarget: true, _reason: `sp${(STATE.sp*100).toFixed(0)}` }; + } + } + + // 0c. Spirit Stance safety auto-disable + if (STATE.spiritStance && STATE.sp < 0.60) { + return { type: 'toggle_spirit', _reason: 'sp_safety' }; + } // 1. Health items — safe to use early if (STATE.hp < CFG.cureItemHP) { @@ -1256,19 +1309,17 @@ function strategyAdept() { } } - // 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')) { + // 4. Debuffs — Imperil on 2+ mobs or bosses; Weaken on high-threat + if (CFG.autoDebuff && STATE.mp > 0.20) { + if (hasSpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) { const b = findStrongestMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) - return { type: 'spell', name: 'Imperil', target: b }; + return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' }; } - if (hasSpell('Weaken')) { - const b = findStrongestMonster(); + if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) { + const b = findDangerousMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) - return { type: 'spell', name: 'Weaken', target: b }; + return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' }; } } @@ -1278,13 +1329,13 @@ function strategyAdept() { const skillOC_N = CFG.skillOC || 75; if (STATE.oc >= skillOC_N || isHighThreat) { if (isHighThreat && STATE.oc < skillOC_N) console.log('%c[HV] ⚠ emergency skill at OC=' + STATE.oc.toFixed(0), 'color:#f80'); - // Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { + // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t }; } - // Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { + // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) { const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); if (hasArmorBreak) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); @@ -1318,9 +1369,9 @@ function strategyAdept() { if (ch) return ch; } - const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : findWeakestMonster(); + const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster()); if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick')) - return { type: 'attack', target: t }; + return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' }; return null; } @@ -1332,6 +1383,22 @@ function strategyVeteran() { if (gem && !STATE.channeling && !STATE._mysticUsed) return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' }; + // 0b. Spark of Life SP safety — Spark costs 50% SP when triggered. + // If SP < 55%, consume a spirit item before anything else. + // Also suppress Spark recast if SP is too low to survive a proc. + if (STATE.sp < 0.55) { + for (const [slot, info] of Object.entries(STATE.itemsKnown)) { + if (slot === 'p') continue; + const def = ITEMS[parseInt(info)]; + if (def && def.t === 'spirit') return { type: 'item', id: slot, selfTarget: true, _reason: `sp${(STATE.sp*100).toFixed(0)}` }; + } + } + + // 0c. Spirit Stance safety auto-disable — if left on, drains 10% SP/turn + if (STATE.spiritStance && STATE.sp < 0.60) { + return { type: 'toggle_spirit', _reason: 'sp_safety' }; + } + // 1. Health items — safe to use early if (STATE.hp < CFG.cureItemHP) { const gem = hasUsableGem('heal'); @@ -1394,17 +1461,21 @@ function strategyVeteran() { } } - // 4. Debuffs — only on actual boss fights (gold border) - if (CFG.autoDebuff && STATE.mp > 0.2 && anyRareMonster()) { - if (hasSpell('Imperil')) { + // 4. Debuffs — Imperil on any 2+ mobs or bosses; Weaken on high-threat enemies + // On IWBTH, non-boss elites hit hard — debuffs are worth the MP cost + if (CFG.autoDebuff && STATE.mp > 0.20) { + // Imperil: reduce physical mitigation by 25-50% (stacks with Penetrated Armor) + // Use on any tanky enemy when facing 2+ monsters or a rare + if (hasSpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) { const b = findStrongestMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) - return { type: 'spell', name: 'Imperil', target: b }; + return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' }; } - if (hasSpell('Weaken')) { - const b = findStrongestMonster(); + // Weaken: reduce incoming damage by 50% — use on high-threat or 3+ mobs + if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) { + const b = findDangerousMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) - return { type: 'spell', name: 'Weaken', target: b }; + return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' }; } } @@ -1417,13 +1488,13 @@ function strategyVeteran() { if (STATE.oc >= askillOC3 || isHighThreat) { if (isHighThreat && STATE.oc < askillOC3) console.log('%c[HV] ⚠ emergency: using skill at OC=' + STATE.oc.toFixed(0), 'color:#f80'); // Emergency: 2+ monsters about to special — spend OC to kill them fast - // Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { + // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5 for IWBTH survivability) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t, _reason: isHighThreat ? 'threat' : 'oc' }; } - // Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { + // Shatter Strike: AoE stun vs 3+ enemies with armor break + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) { const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); if (hasArmorBreak) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); @@ -1483,7 +1554,7 @@ function strategyVeteran() { if (ch) return ch; } - const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : findWeakestMonster(); + const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster()); if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick')) return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' }; return null; @@ -1532,17 +1603,28 @@ function castTargetSpell(n, i) { (el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`)); if (!s) return false; s.click(); + // Target selection: click immediately, no setTimeout delay needed. + // HV's native lock_action handles the state transition synchronously. if (i >= 0 && i < STATE.monsters.length) { - setTimeout(() => { - const m = STATE.monsters[i]; - if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); - }, 50); + const m = STATE.monsters[i]; + if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); } return true; } function useItem(id) { - const it = document.getElementById('ikey_' + id); + let it = document.getElementById('ikey_' + id); + + // Fallback for auto_ slot IDs (on-cooldown items without explicit DOM id): + // search for element with matching onmouseover item ID + if (!it && typeof id === 'string' && id.startsWith('auto_')) { + const rawId = id.replace('auto_', ''); + const itemPane = document.getElementById('pane_item'); + if (itemPane) { + it = $$('div[onmouseover*="set_infopane_item(' + rawId + ')"]', itemPane)[0]; + } + } + if (!it) return false; // Track that we used the P-slot gem so we don't retry stale state if (id === 'p') STATE._mysticUsed = true; @@ -1583,11 +1665,10 @@ function useSkill(n, i) { if (!el) return false; el.click(); + // Click target immediately — no setTimeout needed if (i >= 0 && i < STATE.monsters.length) { - setTimeout(() => { - const m = STATE.monsters[i]; - if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); - }, 50); + const m = STATE.monsters[i]; + if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); } return true; } diff --git a/scripts/latest.user.js b/scripts/latest.user.js index e67d3b9..2c607d0 100644 --- a/scripts/latest.user.js +++ b/scripts/latest.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name HV Unified // @namespace hvunified -// @version 0.13.34 +// @version 0.14.0 // @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse // @author GaboGG + Hermes // @match *://*.hentaiverse.org/* @@ -17,7 +17,7 @@ // CONFIG — default settings // ═══════════════════════════════════════════════════════════════════════ -const VERSION = '0.13.34'; +const VERSION = '0.14.0'; const CFG = { // — Battle automation @@ -127,6 +127,17 @@ try { } // Load max buff durations from localStorage (learned across sessions) +// Pre-seed with L150+ baseline values so fill-level formula works from first turn +const DEFAULT_MAX_BUFF_DUR = { + 'haste': 80, + 'protection': 80, + 'sparklife': 50, + 'heartseeker': 120, + 'regen': 50, + 'absorb': 75, + 'shadowveil': 75, +}; +STATE.maxBuffDur = { ...DEFAULT_MAX_BUFF_DUR }; try { const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}'); for (const [k, v] of Object.entries(saved)) { @@ -851,6 +862,27 @@ function hasHighThreat(threshold) { return count >= 2; } +// Find optimal target for Domino Strike splash (2H passive) — center-aligned +// For 2H builds, Domino Strike splashes up to 2 enemies on each side of the target. +// Attacking edge monsters wastes 50% of the splash potential. +function findOptimalDominoTarget() { + const total = STATE.monsters.length; + if (total <= 2) return findWeakestMonster(); + + let best = -1; + let bestScore = -1; + STATE.monsters.forEach((m, i) => { + if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return; + let splash = 0; + if (i - 1 >= 0 && STATE.monsters[i - 1] && STATE.monsters[i - 1].hasAttribute('onclick')) splash++; + if (i - 2 >= 0 && STATE.monsters[i - 2] && STATE.monsters[i - 2].hasAttribute('onclick')) splash++; + if (i + 1 < total && STATE.monsters[i + 1] && STATE.monsters[i + 1].hasAttribute('onclick')) splash++; + if (i + 2 < total && STATE.monsters[i + 2] && STATE.monsters[i + 2].hasAttribute('onclick')) splash++; + if (splash > bestScore) { bestScore = splash; best = i; } + }); + return best >= 0 ? best : 0; +} + function checkMonsterDebuff(idx, db) { const m = STATE.monsters[idx]; if (!m) return false; @@ -1021,8 +1053,15 @@ function findBestChannelingTarget() { } } - // Fallback: ANY damage spell rather than waste the charge on basic attack - // Try elementals first (cheapest), then any spell we know + // Fallback: for physical 2H builds, don't waste channeling on weak damage spells. + // Channeling improves the next spell, but Fiery Blast does negligible damage at IWBTH. + // Better to use the charge on a basic attack that procs Domino Strike and Penetrated Armor. + const isPhysical2H = STATE.skillsKnown.includes('Great Cleave') || STATE.skillsKnown.includes('Rending Blow'); + if (isPhysical2H) { + return null; // Let channeling expire naturally — basic attacks do more than Fiery Blast + } + + // Mage fallback: use any cheap spell for mage builds const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale', 'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst', 'Banishment', 'Disintegrate']); @@ -1145,8 +1184,8 @@ function strategyNovice() { 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')) { + // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) { const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); if (hasArmorBreak) { const t = findStrongestMonster(); @@ -1192,7 +1231,21 @@ function strategyAdept() { // 0. Mystic Gem for channeling — only use once per battle const gem = hasUsableGem('channel'); if (gem && !STATE.channeling && !STATE._mysticUsed) - return { type: 'item', id: gem.id, selfTarget: true }; + return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' }; + + // 0b. Spark of Life SP safety + if (STATE.sp < 0.55) { + for (const [slot, info] of Object.entries(STATE.itemsKnown)) { + if (slot === 'p') continue; + const def = ITEMS[parseInt(info)]; + if (def && def.t === 'spirit') return { type: 'item', id: slot, selfTarget: true, _reason: `sp${(STATE.sp*100).toFixed(0)}` }; + } + } + + // 0c. Spirit Stance safety auto-disable + if (STATE.spiritStance && STATE.sp < 0.60) { + return { type: 'toggle_spirit', _reason: 'sp_safety' }; + } // 1. Health items — safe to use early if (STATE.hp < CFG.cureItemHP) { @@ -1256,19 +1309,17 @@ function strategyAdept() { } } - // 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')) { + // 4. Debuffs — Imperil on 2+ mobs or bosses; Weaken on high-threat + if (CFG.autoDebuff && STATE.mp > 0.20) { + if (hasSpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) { const b = findStrongestMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) - return { type: 'spell', name: 'Imperil', target: b }; + return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' }; } - if (hasSpell('Weaken')) { - const b = findStrongestMonster(); + if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) { + const b = findDangerousMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) - return { type: 'spell', name: 'Weaken', target: b }; + return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' }; } } @@ -1278,13 +1329,13 @@ function strategyAdept() { const skillOC_N = CFG.skillOC || 75; if (STATE.oc >= skillOC_N || isHighThreat) { if (isHighThreat && STATE.oc < skillOC_N) console.log('%c[HV] ⚠ emergency skill at OC=' + STATE.oc.toFixed(0), 'color:#f80'); - // Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { + // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t }; } - // Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { + // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) { const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); if (hasArmorBreak) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); @@ -1318,9 +1369,9 @@ function strategyAdept() { if (ch) return ch; } - const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : findWeakestMonster(); + const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster()); if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick')) - return { type: 'attack', target: t }; + return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' }; return null; } @@ -1332,6 +1383,22 @@ function strategyVeteran() { if (gem && !STATE.channeling && !STATE._mysticUsed) return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' }; + // 0b. Spark of Life SP safety — Spark costs 50% SP when triggered. + // If SP < 55%, consume a spirit item before anything else. + // Also suppress Spark recast if SP is too low to survive a proc. + if (STATE.sp < 0.55) { + for (const [slot, info] of Object.entries(STATE.itemsKnown)) { + if (slot === 'p') continue; + const def = ITEMS[parseInt(info)]; + if (def && def.t === 'spirit') return { type: 'item', id: slot, selfTarget: true, _reason: `sp${(STATE.sp*100).toFixed(0)}` }; + } + } + + // 0c. Spirit Stance safety auto-disable — if left on, drains 10% SP/turn + if (STATE.spiritStance && STATE.sp < 0.60) { + return { type: 'toggle_spirit', _reason: 'sp_safety' }; + } + // 1. Health items — safe to use early if (STATE.hp < CFG.cureItemHP) { const gem = hasUsableGem('heal'); @@ -1394,17 +1461,21 @@ function strategyVeteran() { } } - // 4. Debuffs — only on actual boss fights (gold border) - if (CFG.autoDebuff && STATE.mp > 0.2 && anyRareMonster()) { - if (hasSpell('Imperil')) { + // 4. Debuffs — Imperil on any 2+ mobs or bosses; Weaken on high-threat enemies + // On IWBTH, non-boss elites hit hard — debuffs are worth the MP cost + if (CFG.autoDebuff && STATE.mp > 0.20) { + // Imperil: reduce physical mitigation by 25-50% (stacks with Penetrated Armor) + // Use on any tanky enemy when facing 2+ monsters or a rare + if (hasSpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) { const b = findStrongestMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) - return { type: 'spell', name: 'Imperil', target: b }; + return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' }; } - if (hasSpell('Weaken')) { - const b = findStrongestMonster(); + // Weaken: reduce incoming damage by 50% — use on high-threat or 3+ mobs + if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) { + const b = findDangerousMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) - return { type: 'spell', name: 'Weaken', target: b }; + return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' }; } } @@ -1417,13 +1488,13 @@ function strategyVeteran() { if (STATE.oc >= askillOC3 || isHighThreat) { if (isHighThreat && STATE.oc < askillOC3) console.log('%c[HV] ⚠ emergency: using skill at OC=' + STATE.oc.toFixed(0), 'color:#f80'); // Emergency: 2+ monsters about to special — spend OC to kill them fast - // Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { + // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5 for IWBTH survivability) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t, _reason: isHighThreat ? 'threat' : 'oc' }; } - // Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { + // Shatter Strike: AoE stun vs 3+ enemies with armor break + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) { const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); if (hasArmorBreak) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); @@ -1483,7 +1554,7 @@ function strategyVeteran() { if (ch) return ch; } - const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : findWeakestMonster(); + const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster()); if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick')) return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' }; return null; @@ -1532,17 +1603,28 @@ function castTargetSpell(n, i) { (el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`)); if (!s) return false; s.click(); + // Target selection: click immediately, no setTimeout delay needed. + // HV's native lock_action handles the state transition synchronously. if (i >= 0 && i < STATE.monsters.length) { - setTimeout(() => { - const m = STATE.monsters[i]; - if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); - }, 50); + const m = STATE.monsters[i]; + if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); } return true; } function useItem(id) { - const it = document.getElementById('ikey_' + id); + let it = document.getElementById('ikey_' + id); + + // Fallback for auto_ slot IDs (on-cooldown items without explicit DOM id): + // search for element with matching onmouseover item ID + if (!it && typeof id === 'string' && id.startsWith('auto_')) { + const rawId = id.replace('auto_', ''); + const itemPane = document.getElementById('pane_item'); + if (itemPane) { + it = $$('div[onmouseover*="set_infopane_item(' + rawId + ')"]', itemPane)[0]; + } + } + if (!it) return false; // Track that we used the P-slot gem so we don't retry stale state if (id === 'p') STATE._mysticUsed = true; @@ -1583,11 +1665,10 @@ function useSkill(n, i) { if (!el) return false; el.click(); + // Click target immediately — no setTimeout needed if (i >= 0 && i < STATE.monsters.length) { - setTimeout(() => { - const m = STATE.monsters[i]; - if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); - }, 50); + const m = STATE.monsters[i]; + if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); } return true; } diff --git a/src/action-executor.js b/src/action-executor.js index 8463892..ed03b47 100644 --- a/src/action-executor.js +++ b/src/action-executor.js @@ -41,17 +41,28 @@ function castTargetSpell(n, i) { (el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`)); if (!s) return false; s.click(); + // Target selection: click immediately, no setTimeout delay needed. + // HV's native lock_action handles the state transition synchronously. if (i >= 0 && i < STATE.monsters.length) { - setTimeout(() => { - const m = STATE.monsters[i]; - if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); - }, 50); + const m = STATE.monsters[i]; + if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); } return true; } function useItem(id) { - const it = document.getElementById('ikey_' + id); + let it = document.getElementById('ikey_' + id); + + // Fallback for auto_ slot IDs (on-cooldown items without explicit DOM id): + // search for element with matching onmouseover item ID + if (!it && typeof id === 'string' && id.startsWith('auto_')) { + const rawId = id.replace('auto_', ''); + const itemPane = document.getElementById('pane_item'); + if (itemPane) { + it = $$('div[onmouseover*="set_infopane_item(' + rawId + ')"]', itemPane)[0]; + } + } + if (!it) return false; // Track that we used the P-slot gem so we don't retry stale state if (id === 'p') STATE._mysticUsed = true; @@ -92,11 +103,10 @@ function useSkill(n, i) { if (!el) return false; el.click(); + // Click target immediately — no setTimeout needed if (i >= 0 && i < STATE.monsters.length) { - setTimeout(() => { - const m = STATE.monsters[i]; - if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); - }, 50); + const m = STATE.monsters[i]; + if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); } return true; } diff --git a/src/config.js b/src/config.js index 57094ca..821f130 100644 --- a/src/config.js +++ b/src/config.js @@ -2,7 +2,7 @@ // CONFIG — default settings // ═══════════════════════════════════════════════════════════════════════ -const VERSION = '0.13.34'; +const VERSION = '0.14.0'; const CFG = { // — Battle automation diff --git a/src/header.user.js b/src/header.user.js index 194c0e6..adfd8b0 100644 --- a/src/header.user.js +++ b/src/header.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name HV Unified // @namespace hvunified -// @version 0.13.34 +// @version 0.14.0 // @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse // @author GaboGG + Hermes // @match *://*.hentaiverse.org/* diff --git a/src/state.js b/src/state.js index cd51e6c..795d7f1 100644 --- a/src/state.js +++ b/src/state.js @@ -63,6 +63,17 @@ try { } // Load max buff durations from localStorage (learned across sessions) +// Pre-seed with L150+ baseline values so fill-level formula works from first turn +const DEFAULT_MAX_BUFF_DUR = { + 'haste': 80, + 'protection': 80, + 'sparklife': 50, + 'heartseeker': 120, + 'regen': 50, + 'absorb': 75, + 'shadowveil': 75, +}; +STATE.maxBuffDur = { ...DEFAULT_MAX_BUFF_DUR }; try { const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}'); for (const [k, v] of Object.entries(saved)) { diff --git a/src/strategy-engine.js b/src/strategy-engine.js index 43c6b4d..8b4404a 100644 --- a/src/strategy-engine.js +++ b/src/strategy-engine.js @@ -137,6 +137,27 @@ function hasHighThreat(threshold) { return count >= 2; } +// Find optimal target for Domino Strike splash (2H passive) — center-aligned +// For 2H builds, Domino Strike splashes up to 2 enemies on each side of the target. +// Attacking edge monsters wastes 50% of the splash potential. +function findOptimalDominoTarget() { + const total = STATE.monsters.length; + if (total <= 2) return findWeakestMonster(); + + let best = -1; + let bestScore = -1; + STATE.monsters.forEach((m, i) => { + if (!m || !m.hasAttribute || !m.hasAttribute('onclick')) return; + let splash = 0; + if (i - 1 >= 0 && STATE.monsters[i - 1] && STATE.monsters[i - 1].hasAttribute('onclick')) splash++; + if (i - 2 >= 0 && STATE.monsters[i - 2] && STATE.monsters[i - 2].hasAttribute('onclick')) splash++; + if (i + 1 < total && STATE.monsters[i + 1] && STATE.monsters[i + 1].hasAttribute('onclick')) splash++; + if (i + 2 < total && STATE.monsters[i + 2] && STATE.monsters[i + 2].hasAttribute('onclick')) splash++; + if (splash > bestScore) { bestScore = splash; best = i; } + }); + return best >= 0 ? best : 0; +} + function checkMonsterDebuff(idx, db) { const m = STATE.monsters[idx]; if (!m) return false; @@ -307,8 +328,15 @@ function findBestChannelingTarget() { } } - // Fallback: ANY damage spell rather than waste the charge on basic attack - // Try elementals first (cheapest), then any spell we know + // Fallback: for physical 2H builds, don't waste channeling on weak damage spells. + // Channeling improves the next spell, but Fiery Blast does negligible damage at IWBTH. + // Better to use the charge on a basic attack that procs Domino Strike and Penetrated Armor. + const isPhysical2H = STATE.skillsKnown.includes('Great Cleave') || STATE.skillsKnown.includes('Rending Blow'); + if (isPhysical2H) { + return null; // Let channeling expire naturally — basic attacks do more than Fiery Blast + } + + // Mage fallback: use any cheap spell for mage builds const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale', 'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst', 'Banishment', 'Disintegrate']); @@ -431,8 +459,8 @@ function strategyNovice() { 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')) { + // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) { const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); if (hasArmorBreak) { const t = findStrongestMonster(); @@ -478,7 +506,21 @@ function strategyAdept() { // 0. Mystic Gem for channeling — only use once per battle const gem = hasUsableGem('channel'); if (gem && !STATE.channeling && !STATE._mysticUsed) - return { type: 'item', id: gem.id, selfTarget: true }; + return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' }; + + // 0b. Spark of Life SP safety + if (STATE.sp < 0.55) { + for (const [slot, info] of Object.entries(STATE.itemsKnown)) { + if (slot === 'p') continue; + const def = ITEMS[parseInt(info)]; + if (def && def.t === 'spirit') return { type: 'item', id: slot, selfTarget: true, _reason: `sp${(STATE.sp*100).toFixed(0)}` }; + } + } + + // 0c. Spirit Stance safety auto-disable + if (STATE.spiritStance && STATE.sp < 0.60) { + return { type: 'toggle_spirit', _reason: 'sp_safety' }; + } // 1. Health items — safe to use early if (STATE.hp < CFG.cureItemHP) { @@ -542,19 +584,17 @@ function strategyAdept() { } } - // 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')) { + // 4. Debuffs — Imperil on 2+ mobs or bosses; Weaken on high-threat + if (CFG.autoDebuff && STATE.mp > 0.20) { + if (hasSpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) { const b = findStrongestMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) - return { type: 'spell', name: 'Imperil', target: b }; + return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' }; } - if (hasSpell('Weaken')) { - const b = findStrongestMonster(); + if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) { + const b = findDangerousMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) - return { type: 'spell', name: 'Weaken', target: b }; + return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' }; } } @@ -564,13 +604,13 @@ function strategyAdept() { const skillOC_N = CFG.skillOC || 75; if (STATE.oc >= skillOC_N || isHighThreat) { if (isHighThreat && STATE.oc < skillOC_N) console.log('%c[HV] ⚠ emergency skill at OC=' + STATE.oc.toFixed(0), 'color:#f80'); - // Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { + // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t }; } - // Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { + // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) { const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); if (hasArmorBreak) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); @@ -604,9 +644,9 @@ function strategyAdept() { if (ch) return ch; } - const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : findWeakestMonster(); + const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster()); if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick')) - return { type: 'attack', target: t }; + return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' }; return null; } @@ -618,6 +658,22 @@ function strategyVeteran() { if (gem && !STATE.channeling && !STATE._mysticUsed) return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' }; + // 0b. Spark of Life SP safety — Spark costs 50% SP when triggered. + // If SP < 55%, consume a spirit item before anything else. + // Also suppress Spark recast if SP is too low to survive a proc. + if (STATE.sp < 0.55) { + for (const [slot, info] of Object.entries(STATE.itemsKnown)) { + if (slot === 'p') continue; + const def = ITEMS[parseInt(info)]; + if (def && def.t === 'spirit') return { type: 'item', id: slot, selfTarget: true, _reason: `sp${(STATE.sp*100).toFixed(0)}` }; + } + } + + // 0c. Spirit Stance safety auto-disable — if left on, drains 10% SP/turn + if (STATE.spiritStance && STATE.sp < 0.60) { + return { type: 'toggle_spirit', _reason: 'sp_safety' }; + } + // 1. Health items — safe to use early if (STATE.hp < CFG.cureItemHP) { const gem = hasUsableGem('heal'); @@ -680,17 +736,21 @@ function strategyVeteran() { } } - // 4. Debuffs — only on actual boss fights (gold border) - if (CFG.autoDebuff && STATE.mp > 0.2 && anyRareMonster()) { - if (hasSpell('Imperil')) { + // 4. Debuffs — Imperil on any 2+ mobs or bosses; Weaken on high-threat enemies + // On IWBTH, non-boss elites hit hard — debuffs are worth the MP cost + if (CFG.autoDebuff && STATE.mp > 0.20) { + // Imperil: reduce physical mitigation by 25-50% (stacks with Penetrated Armor) + // Use on any tanky enemy when facing 2+ monsters or a rare + if (hasSpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) { const b = findStrongestMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) - return { type: 'spell', name: 'Imperil', target: b }; + return { type: 'spell', name: 'Imperil', target: b, _reason: 'imperil' }; } - if (hasSpell('Weaken')) { - const b = findStrongestMonster(); + // Weaken: reduce incoming damage by 50% — use on high-threat or 3+ mobs + if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) { + const b = findDangerousMonster(); if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) - return { type: 'spell', name: 'Weaken', target: b }; + return { type: 'spell', name: 'Weaken', target: b, _reason: 'weaken' }; } } @@ -703,13 +763,13 @@ function strategyVeteran() { if (STATE.oc >= askillOC3 || isHighThreat) { if (isHighThreat && STATE.oc < askillOC3) console.log('%c[HV] ⚠ emergency: using skill at OC=' + STATE.oc.toFixed(0), 'color:#f80'); // Emergency: 2+ monsters about to special — spend OC to kill them fast - // Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { + // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5 for IWBTH survivability) + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t, _reason: isHighThreat ? 'threat' : 'oc' }; } - // Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor) - if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { + // Shatter Strike: AoE stun vs 3+ enemies with armor break + if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) { const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); if (hasArmorBreak) { const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); @@ -769,7 +829,7 @@ function strategyVeteran() { if (ch) return ch; } - const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : findWeakestMonster(); + const t = hasHighThreat(THREAT_PX) ? findDangerousMonster() : (STATE.monsters.length > 2 ? findOptimalDominoTarget() : findWeakestMonster()); if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick')) return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' }; return null;