v0.14.0 - Major strategy overhaul from agy research report

BUG FIXES (P0):
- Spark of Life SP safety: consume spirit items when SP < 55% BEFORE
  buffs, Cure, or any other action (both Adept and Veteran)
- Spirit Stance safety auto-disable: auto-toggle OFF if SP drops below 60%
- useItem() fallback: auto_ slot IDs now lookup by onmouseover attribute
  (on-cooldown items without DOM id were silently failing)
- setTimeout targeting race: removed 50ms delay in castTargetSpell/useSkill
  (was causing targeting failures with fast Q-repeat)
- maxBuffDur pre-seeded with L150+ baseline values (haste=80, heartseeker=120,
  sparklife=50, etc.) so fill-level scoring works from first turn

STRATEGY (P1):
- Channeling waste eliminated for 2H physical builds: no more Fiery Blast
  fallback — let channeling expire on a basic attack (Domino Strike +
  Penetrated Armor do more damage)
- Weapon skill thresholds lowered from 5+ to 3+ enemies (IWBTH survivability)
- Expanded debuffs: Imperil on any 2+ mobs, Weaken on high-threat targets
  (not just bosses anymore)
- Domino Strike optimal targeting: findOptimalDominoTarget() picks center
  monsters to max splash coverage (up to 2 targets each side)

NEW FEATURES:
- Domino Strike splash positioning algorithm added to all tiers
- Full _reason tags on every action return for decision tracing
- agy research report saved at hermes_report.md (554 lines)
This commit is contained in:
GaboGG 2026-07-27 14:01:05 -04:00
parent 467e844aae
commit c9ad92e28d
7 changed files with 372 additions and 129 deletions

View file

@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name HV Unified // @name HV Unified
// @namespace hvunified // @namespace hvunified
// @version 0.13.34 // @version 0.14.0
// @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse // @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse
// @author GaboGG + Hermes // @author GaboGG + Hermes
// @match *://*.hentaiverse.org/* // @match *://*.hentaiverse.org/*
@ -17,7 +17,7 @@
// CONFIG — default settings // CONFIG — default settings
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
const VERSION = '0.13.34'; const VERSION = '0.14.0';
const CFG = { const CFG = {
// — Battle automation // — Battle automation
@ -127,6 +127,17 @@ try {
} }
// Load max buff durations from localStorage (learned across sessions) // 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 { try {
const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}'); const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}');
for (const [k, v] of Object.entries(saved)) { for (const [k, v] of Object.entries(saved)) {
@ -851,6 +862,27 @@ function hasHighThreat(threshold) {
return count >= 2; 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) { function checkMonsterDebuff(idx, db) {
const m = STATE.monsters[idx]; const m = STATE.monsters[idx];
if (!m) return false; if (!m) return false;
@ -1021,8 +1053,15 @@ function findBestChannelingTarget() {
} }
} }
// Fallback: ANY damage spell rather than waste the charge on basic attack // Fallback: for physical 2H builds, don't waste channeling on weak damage spells.
// Try elementals first (cheapest), then any spell we know // 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', const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale',
'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst', 'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst',
'Banishment', 'Disintegrate']); 'Banishment', 'Disintegrate']);
@ -1145,8 +1184,8 @@ function strategyNovice() {
const t = findStrongestMonster(); const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t }; if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
} }
// Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow) // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) { if (hasArmorBreak) {
const t = findStrongestMonster(); const t = findStrongestMonster();
@ -1192,7 +1231,21 @@ function strategyAdept() {
// 0. Mystic Gem for channeling — only use once per battle // 0. Mystic Gem for channeling — only use once per battle
const gem = hasUsableGem('channel'); const gem = hasUsableGem('channel');
if (gem && !STATE.channeling && !STATE._mysticUsed) 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 // 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) { if (STATE.hp < CFG.cureItemHP) {
@ -1256,19 +1309,17 @@ function strategyAdept() {
} }
} }
// 4. Debuffs — only on actual boss fights (gold border style) // 4. Debuffs — Imperil on 2+ mobs or bosses; Weaken on high-threat
// At high levels/difficulties, all monsters have SP bars, so if (CFG.autoDebuff && STATE.mp > 0.20) {
// we only debuff when a gold-bordered rare/boss is present. if (hasSpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) {
if (anyRareMonster()) {
if (hasSpell('Imperil')) {
const b = findStrongestMonster(); const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) 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')) { if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) {
const b = findStrongestMonster(); const b = findDangerousMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) 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; const skillOC_N = CFG.skillOC || 75;
if (STATE.oc >= skillOC_N || isHighThreat) { 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'); 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) // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t }; if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
} }
// Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow) // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) { if (hasArmorBreak) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
@ -1318,9 +1369,9 @@ function strategyAdept() {
if (ch) return ch; 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')) 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; return null;
} }
@ -1332,6 +1383,22 @@ function strategyVeteran() {
if (gem && !STATE.channeling && !STATE._mysticUsed) if (gem && !STATE.channeling && !STATE._mysticUsed)
return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' }; 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 // 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) { if (STATE.hp < CFG.cureItemHP) {
const gem = hasUsableGem('heal'); const gem = hasUsableGem('heal');
@ -1394,17 +1461,21 @@ function strategyVeteran() {
} }
} }
// 4. Debuffs — only on actual boss fights (gold border) // 4. Debuffs — Imperil on any 2+ mobs or bosses; Weaken on high-threat enemies
if (CFG.autoDebuff && STATE.mp > 0.2 && anyRareMonster()) { // On IWBTH, non-boss elites hit hard — debuffs are worth the MP cost
if (hasSpell('Imperil')) { 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(); const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) 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')) { // Weaken: reduce incoming damage by 50% — use on high-threat or 3+ mobs
const b = findStrongestMonster(); if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) {
const b = findDangerousMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) 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 (STATE.oc >= askillOC3 || isHighThreat) {
if (isHighThreat && STATE.oc < askillOC3) console.log('%c[HV] ⚠ emergency: using skill at OC=' + STATE.oc.toFixed(0), 'color:#f80'); 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 // Emergency: 2+ monsters about to special — spend OC to kill them fast
// Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest) // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5 for IWBTH survivability)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t, _reason: isHighThreat ? 'threat' : 'oc' }; 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) // Shatter Strike: AoE stun vs 3+ enemies with armor break
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) { if (hasArmorBreak) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
@ -1483,7 +1554,7 @@ function strategyVeteran() {
if (ch) return ch; 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')) if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' }; return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' };
return null; return null;
@ -1532,17 +1603,28 @@ function castTargetSpell(n, i) {
(el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`)); (el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`));
if (!s) return false; if (!s) return false;
s.click(); 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) { if (i >= 0 && i < STATE.monsters.length) {
setTimeout(() => {
const m = STATE.monsters[i]; const m = STATE.monsters[i];
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
}, 50);
} }
return true; return true;
} }
function useItem(id) { 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; if (!it) return false;
// Track that we used the P-slot gem so we don't retry stale state // Track that we used the P-slot gem so we don't retry stale state
if (id === 'p') STATE._mysticUsed = true; if (id === 'p') STATE._mysticUsed = true;
@ -1583,11 +1665,10 @@ function useSkill(n, i) {
if (!el) return false; if (!el) return false;
el.click(); el.click();
// Click target immediately — no setTimeout needed
if (i >= 0 && i < STATE.monsters.length) { if (i >= 0 && i < STATE.monsters.length) {
setTimeout(() => {
const m = STATE.monsters[i]; const m = STATE.monsters[i];
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
}, 50);
} }
return true; return true;
} }

View file

@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name HV Unified // @name HV Unified
// @namespace hvunified // @namespace hvunified
// @version 0.13.34 // @version 0.14.0
// @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse // @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse
// @author GaboGG + Hermes // @author GaboGG + Hermes
// @match *://*.hentaiverse.org/* // @match *://*.hentaiverse.org/*
@ -17,7 +17,7 @@
// CONFIG — default settings // CONFIG — default settings
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
const VERSION = '0.13.34'; const VERSION = '0.14.0';
const CFG = { const CFG = {
// — Battle automation // — Battle automation
@ -127,6 +127,17 @@ try {
} }
// Load max buff durations from localStorage (learned across sessions) // 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 { try {
const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}'); const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}');
for (const [k, v] of Object.entries(saved)) { for (const [k, v] of Object.entries(saved)) {
@ -851,6 +862,27 @@ function hasHighThreat(threshold) {
return count >= 2; 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) { function checkMonsterDebuff(idx, db) {
const m = STATE.monsters[idx]; const m = STATE.monsters[idx];
if (!m) return false; if (!m) return false;
@ -1021,8 +1053,15 @@ function findBestChannelingTarget() {
} }
} }
// Fallback: ANY damage spell rather than waste the charge on basic attack // Fallback: for physical 2H builds, don't waste channeling on weak damage spells.
// Try elementals first (cheapest), then any spell we know // 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', const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale',
'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst', 'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst',
'Banishment', 'Disintegrate']); 'Banishment', 'Disintegrate']);
@ -1145,8 +1184,8 @@ function strategyNovice() {
const t = findStrongestMonster(); const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t }; if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
} }
// Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow) // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) { if (hasArmorBreak) {
const t = findStrongestMonster(); const t = findStrongestMonster();
@ -1192,7 +1231,21 @@ function strategyAdept() {
// 0. Mystic Gem for channeling — only use once per battle // 0. Mystic Gem for channeling — only use once per battle
const gem = hasUsableGem('channel'); const gem = hasUsableGem('channel');
if (gem && !STATE.channeling && !STATE._mysticUsed) 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 // 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) { if (STATE.hp < CFG.cureItemHP) {
@ -1256,19 +1309,17 @@ function strategyAdept() {
} }
} }
// 4. Debuffs — only on actual boss fights (gold border style) // 4. Debuffs — Imperil on 2+ mobs or bosses; Weaken on high-threat
// At high levels/difficulties, all monsters have SP bars, so if (CFG.autoDebuff && STATE.mp > 0.20) {
// we only debuff when a gold-bordered rare/boss is present. if (hasSpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) {
if (anyRareMonster()) {
if (hasSpell('Imperil')) {
const b = findStrongestMonster(); const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) 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')) { if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) {
const b = findStrongestMonster(); const b = findDangerousMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) 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; const skillOC_N = CFG.skillOC || 75;
if (STATE.oc >= skillOC_N || isHighThreat) { 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'); 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) // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t }; if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
} }
// Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow) // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) { if (hasArmorBreak) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
@ -1318,9 +1369,9 @@ function strategyAdept() {
if (ch) return ch; 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')) 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; return null;
} }
@ -1332,6 +1383,22 @@ function strategyVeteran() {
if (gem && !STATE.channeling && !STATE._mysticUsed) if (gem && !STATE.channeling && !STATE._mysticUsed)
return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' }; 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 // 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) { if (STATE.hp < CFG.cureItemHP) {
const gem = hasUsableGem('heal'); const gem = hasUsableGem('heal');
@ -1394,17 +1461,21 @@ function strategyVeteran() {
} }
} }
// 4. Debuffs — only on actual boss fights (gold border) // 4. Debuffs — Imperil on any 2+ mobs or bosses; Weaken on high-threat enemies
if (CFG.autoDebuff && STATE.mp > 0.2 && anyRareMonster()) { // On IWBTH, non-boss elites hit hard — debuffs are worth the MP cost
if (hasSpell('Imperil')) { 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(); const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) 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')) { // Weaken: reduce incoming damage by 50% — use on high-threat or 3+ mobs
const b = findStrongestMonster(); if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) {
const b = findDangerousMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) 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 (STATE.oc >= askillOC3 || isHighThreat) {
if (isHighThreat && STATE.oc < askillOC3) console.log('%c[HV] ⚠ emergency: using skill at OC=' + STATE.oc.toFixed(0), 'color:#f80'); 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 // Emergency: 2+ monsters about to special — spend OC to kill them fast
// Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest) // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5 for IWBTH survivability)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t, _reason: isHighThreat ? 'threat' : 'oc' }; 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) // Shatter Strike: AoE stun vs 3+ enemies with armor break
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) { if (hasArmorBreak) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
@ -1483,7 +1554,7 @@ function strategyVeteran() {
if (ch) return ch; 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')) if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' }; return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' };
return null; return null;
@ -1532,17 +1603,28 @@ function castTargetSpell(n, i) {
(el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`)); (el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`));
if (!s) return false; if (!s) return false;
s.click(); 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) { if (i >= 0 && i < STATE.monsters.length) {
setTimeout(() => {
const m = STATE.monsters[i]; const m = STATE.monsters[i];
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
}, 50);
} }
return true; return true;
} }
function useItem(id) { 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; if (!it) return false;
// Track that we used the P-slot gem so we don't retry stale state // Track that we used the P-slot gem so we don't retry stale state
if (id === 'p') STATE._mysticUsed = true; if (id === 'p') STATE._mysticUsed = true;
@ -1583,11 +1665,10 @@ function useSkill(n, i) {
if (!el) return false; if (!el) return false;
el.click(); el.click();
// Click target immediately — no setTimeout needed
if (i >= 0 && i < STATE.monsters.length) { if (i >= 0 && i < STATE.monsters.length) {
setTimeout(() => {
const m = STATE.monsters[i]; const m = STATE.monsters[i];
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
}, 50);
} }
return true; return true;
} }

View file

@ -41,17 +41,28 @@ function castTargetSpell(n, i) {
(el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`)); (el.getAttribute('onmouseover') || '').includes(`set_infopane_spell('${n}'`));
if (!s) return false; if (!s) return false;
s.click(); 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) { if (i >= 0 && i < STATE.monsters.length) {
setTimeout(() => {
const m = STATE.monsters[i]; const m = STATE.monsters[i];
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
}, 50);
} }
return true; return true;
} }
function useItem(id) { 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; if (!it) return false;
// Track that we used the P-slot gem so we don't retry stale state // Track that we used the P-slot gem so we don't retry stale state
if (id === 'p') STATE._mysticUsed = true; if (id === 'p') STATE._mysticUsed = true;
@ -92,11 +103,10 @@ function useSkill(n, i) {
if (!el) return false; if (!el) return false;
el.click(); el.click();
// Click target immediately — no setTimeout needed
if (i >= 0 && i < STATE.monsters.length) { if (i >= 0 && i < STATE.monsters.length) {
setTimeout(() => {
const m = STATE.monsters[i]; const m = STATE.monsters[i];
if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click(); if (m && m.hasAttribute && m.hasAttribute('onclick')) m.click();
}, 50);
} }
return true; return true;
} }

View file

@ -2,7 +2,7 @@
// CONFIG — default settings // CONFIG — default settings
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
const VERSION = '0.13.34'; const VERSION = '0.14.0';
const CFG = { const CFG = {
// — Battle automation // — Battle automation

View file

@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name HV Unified // @name HV Unified
// @namespace hvunified // @namespace hvunified
// @version 0.13.34 // @version 0.14.0
// @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse // @description HV Unified — battle automation, guidance, and UI enhancements for HentaiVerse
// @author GaboGG + Hermes // @author GaboGG + Hermes
// @match *://*.hentaiverse.org/* // @match *://*.hentaiverse.org/*

View file

@ -63,6 +63,17 @@ try {
} }
// Load max buff durations from localStorage (learned across sessions) // 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 { try {
const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}'); const saved = JSON.parse(localStorage[SP + 'maxBuffDur'] || '{}');
for (const [k, v] of Object.entries(saved)) { for (const [k, v] of Object.entries(saved)) {

View file

@ -137,6 +137,27 @@ function hasHighThreat(threshold) {
return count >= 2; 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) { function checkMonsterDebuff(idx, db) {
const m = STATE.monsters[idx]; const m = STATE.monsters[idx];
if (!m) return false; if (!m) return false;
@ -307,8 +328,15 @@ function findBestChannelingTarget() {
} }
} }
// Fallback: ANY damage spell rather than waste the charge on basic attack // Fallback: for physical 2H builds, don't waste channeling on weak damage spells.
// Try elementals first (cheapest), then any spell we know // 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', const cheap = findDmgSpell(['Fiery Blast', 'Freeze', 'Shockblast', 'Gale',
'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst', 'Smite', 'Corruption', 'Inferno', 'Blizzard', 'Chained Lightning', 'Downburst',
'Banishment', 'Disintegrate']); 'Banishment', 'Disintegrate']);
@ -431,8 +459,8 @@ function strategyNovice() {
const t = findStrongestMonster(); const t = findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t }; if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
} }
// Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow) // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) { if (hasArmorBreak) {
const t = findStrongestMonster(); const t = findStrongestMonster();
@ -478,7 +506,21 @@ function strategyAdept() {
// 0. Mystic Gem for channeling — only use once per battle // 0. Mystic Gem for channeling — only use once per battle
const gem = hasUsableGem('channel'); const gem = hasUsableGem('channel');
if (gem && !STATE.channeling && !STATE._mysticUsed) 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 // 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) { if (STATE.hp < CFG.cureItemHP) {
@ -542,19 +584,17 @@ function strategyAdept() {
} }
} }
// 4. Debuffs — only on actual boss fights (gold border style) // 4. Debuffs — Imperil on 2+ mobs or bosses; Weaken on high-threat
// At high levels/difficulties, all monsters have SP bars, so if (CFG.autoDebuff && STATE.mp > 0.20) {
// we only debuff when a gold-bordered rare/boss is present. if (hasSpell('Imperil') && (anyRareMonster() || STATE.monsters.length >= 2)) {
if (anyRareMonster()) {
if (hasSpell('Imperil')) {
const b = findStrongestMonster(); const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) 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')) { if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) {
const b = findStrongestMonster(); const b = findDangerousMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) 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; const skillOC_N = CFG.skillOC || 75;
if (STATE.oc >= skillOC_N || isHighThreat) { 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'); 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) // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t }; if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t };
} }
// Shatter Strike: AoE stun vs 5+ enemies (needs Penetrated Armor from Rending Blow) // Shatter Strike: AoE stun vs 3+ enemies (needs Penetrated Armor from Rending Blow)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) { if (hasArmorBreak) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
@ -604,9 +644,9 @@ function strategyAdept() {
if (ch) return ch; 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')) 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; return null;
} }
@ -618,6 +658,22 @@ function strategyVeteran() {
if (gem && !STATE.channeling && !STATE._mysticUsed) if (gem && !STATE.channeling && !STATE._mysticUsed)
return { type: 'item', id: gem.id, selfTarget: true, _reason: 'mystic' }; 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 // 1. Health items — safe to use early
if (STATE.hp < CFG.cureItemHP) { if (STATE.hp < CFG.cureItemHP) {
const gem = hasUsableGem('heal'); const gem = hasUsableGem('heal');
@ -680,17 +736,21 @@ function strategyVeteran() {
} }
} }
// 4. Debuffs — only on actual boss fights (gold border) // 4. Debuffs — Imperil on any 2+ mobs or bosses; Weaken on high-threat enemies
if (CFG.autoDebuff && STATE.mp > 0.2 && anyRareMonster()) { // On IWBTH, non-boss elites hit hard — debuffs are worth the MP cost
if (hasSpell('Imperil')) { 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(); const b = findStrongestMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'imperil')) 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')) { // Weaken: reduce incoming damage by 50% — use on high-threat or 3+ mobs
const b = findStrongestMonster(); if (hasSpell('Weaken') && (anyRareMonster() || hasHighThreat(80) || STATE.monsters.length >= 3)) {
const b = findDangerousMonster();
if (b >= 0 && !checkMonsterDebuff(b, 'weaken')) 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 (STATE.oc >= askillOC3 || isHighThreat) {
if (isHighThreat && STATE.oc < askillOC3) console.log('%c[HV] ⚠ emergency: using skill at OC=' + STATE.oc.toFixed(0), 'color:#f80'); 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 // Emergency: 2+ monsters about to special — spend OC to kill them fast
// Rending Blow: AoE armor pen vs 5+ enemies (highest priority in Grindfest) // Rending Blow: AoE armor pen vs 3+ enemies (reduced from 5 for IWBTH survivability)
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Rending Blow')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Rending Blow')) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
if (t >= 0) return { type: 'skill', name: 'Rending Blow', target: t, _reason: isHighThreat ? 'threat' : 'oc' }; 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) // Shatter Strike: AoE stun vs 3+ enemies with armor break
if (STATE.monsters.length >= 5 && STATE.skillsKnown.includes('Shatter Strike')) { if (STATE.monsters.length >= 3 && STATE.skillsKnown.includes('Shatter Strike')) {
const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor')); const hasArmorBreak = STATE.monsters.some((m, i) => checkMonsterDebuff(i, 'penetrated_armor'));
if (hasArmorBreak) { if (hasArmorBreak) {
const t = isHighThreat ? findDangerousMonster() : findStrongestMonster(); const t = isHighThreat ? findDangerousMonster() : findStrongestMonster();
@ -769,7 +829,7 @@ function strategyVeteran() {
if (ch) return ch; 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')) if (t >= 0 && STATE.monsters[t] && STATE.monsters[t].hasAttribute('onclick'))
return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' }; return { type: 'attack', target: t, _reason: hasHighThreat(THREAT_PX) ? 'threat' : 'fallback' };
return null; return null;