v0.14.40 - Gear panel: armor/weapon filters + fix level detection

- Added filter dropdowns to the panel: Armor (Light/Heavy/Cloth/All)
  and Weapon (2H/Estoc/Longsword/Great Mace/Club/Axe/Scythe).
  Persisted in localStorage. Default: Light armor, 2H weapons.
  Filters exclude non-matching items from armory/buy candidates
  (equipped item always shown regardless).
- Fixed gearPlayerLevel(): now reads STATE.level first (battle
  parser caches it), then the hv-cfg-btn label (Lv195), then
  level_readout. Previously fell back to 155 when the level readout
  used CSS fonts — this excluded the Lv171 equipped Estoc from
  candidates, causing 'vs ?' in the upgrade line.
- Upgrade line now always shows a real number (scores equipped
  directly if it missed candidates).

Also scrapes correctly on equipment pages: clicking Analyze there
refreshes equipped slots from dynjs_equip store.
This commit is contained in:
GaboGG 2026-07-31 10:50:18 -04:00
parent 7442d7c512
commit 641363b5b0
5 changed files with 273 additions and 18 deletions

View file

@ -1,7 +1,7 @@
// ==UserScript==
// @name HV Unified
// @namespace hvunified
// @version 0.14.39
// @version 0.14.40
// @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.14.39';
const VERSION = '0.14.40';
const CFG = {
// — Battle automation
@ -3801,6 +3801,15 @@ function gearUsable(item, playerLv) {
}
function gearPlayerLevel() {
// Prefer battle-parser cached level (STATE.level)
if (STATE && STATE.level) return STATE.level;
// Parse from the HV cfg button (e.g. "Lv195")
const btn = document.getElementById('hv-cfg-btn');
if (btn) {
const m = (btn.textContent || '').match(/Lv(\d+)/i);
if (m) return parseInt(m[1]);
}
// Fallback: level_readout (may be CSS-font with no text)
const el = document.getElementById('level_readout');
if (el) {
const m = (el.textContent || '').match(/(\d+)/);
@ -3809,6 +3818,21 @@ function gearPlayerLevel() {
return 155;
}
// ── Filter state (persisted) ──
const GEAR_FILTER_KEY = SP + 'gearFilter';
function getGearFilter() {
try {
const f = JSON.parse(localStorage[GEAR_FILTER_KEY] || 'null');
if (f && f.armor && f.weapon) return f;
} catch (e) {}
return { armor: 'Light', weapon: '2H' };
}
function setGearFilter(f) {
try { localStorage[GEAR_FILTER_KEY] = JSON.stringify(f); } catch (e) {}
}
// ── Main analysis ──
function analyzeGear() {
const db = getGearDB();
@ -3816,11 +3840,27 @@ function analyzeGear() {
const armory = db.filter(e => e.source === 'armory' && e.stats);
const buy = db.filter(e => e.source === 'buy' && e.stats);
const playerLv = gearPlayerLevel();
const filter = getGearFilter();
// Find equipped slot map (slotType from character page)
const slotMap = {};
equipped.forEach(e => { if (e.slotType) slotMap[e.slotType] = e; });
// Does this item match the armor type filter (Cloth/Light/Heavy/All)?
function matchesArmorType(item) {
if (filter.armor === 'All') return true;
const type = (item.stats && item.stats.type) || '';
const cat = (item.category || '') + ' ' + (item.name || '');
return type.includes(filter.armor) || cat.includes(filter.armor);
}
// Does this weapon match the weapon class filter (Estoc/Longsword/.../2H)?
function matchesWeaponClass(item) {
if (filter.weapon === '2H') return true;
const name = (item.name || '').toLowerCase();
return name.includes(filter.weapon.toLowerCase());
}
const results = [];
const slots = ['Mainhand', 'Head', 'Body', 'Hands', 'Legs', 'Feet'];
@ -3830,16 +3870,21 @@ function analyzeGear() {
const consider = (item, source) => {
if (!item || !item.stats) return;
if (!gearUsable(item, playerLv)) return;
const isWeapon = slot === 'Mainhand';
if (isWeapon) {
if (!gearIsTwoHandWeapon(item)) return;
if (!item.name || !item.stats['Attack Accuracy']) return; // needs stats
if (!item.name || !item.stats['Attack Accuracy']) return;
// Apply weapon class filter — but always include the equipped item
if (source !== 'equipped' && !matchesWeaponClass(item)) return;
} else {
const det = gearDetectSlot(item);
if (det !== slot) return;
if (!item.stats['Physical Mitigation'] && !item.stats['Evade']) return;
// Apply armor type filter — but always include the equipped item
if (source !== 'equipped' && !matchesArmorType(item)) return;
}
// Level filter — always include the equipped item (it's what you have on)
if (source !== 'equipped' && !gearUsable(item, playerLv)) return;
const score = isWeapon ? gearScoreWeapon(item) : gearScoreArmor(item);
cands.push({ item, source, score });
};
@ -3852,7 +3897,7 @@ function analyzeGear() {
results.push({ slot, current, cands });
});
return { playerLv, results };
return { playerLv, filter, results };
}
// ── UI ──
@ -3882,6 +3927,42 @@ function buildGearPanel() {
title.style.cssText = 'font-weight:bold;margin-bottom:6px;color:#ffd';
panel.appendChild(title);
// ── Filters row ──
const filterRow = document.createElement('div');
filterRow.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:8px;font-size:10px;color:#aaa';
const filter = getGearFilter();
filterRow.appendChild(Object.assign(document.createElement('span'), { textContent: 'Armor:' }));
const armorSel = document.createElement('select');
armorSel.style.cssText = 'background:#222;color:#fff;border:1px solid #444;border-radius:3px;font-size:10px;font-family:monospace';
['Light', 'Heavy', 'Cloth', 'All'].forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === filter.armor) opt.selected = true;
armorSel.appendChild(opt);
});
filterRow.appendChild(armorSel);
filterRow.appendChild(Object.assign(document.createElement('span'), { textContent: 'Weapon:' }));
const weaponSel = document.createElement('select');
weaponSel.style.cssText = 'background:#222;color:#fff;border:1px solid #444;border-radius:3px;font-size:10px;font-family:monospace';
['2H', 'Estoc', 'Longsword', 'Great Mace', 'Club', 'Axe', 'Scythe'].forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === filter.weapon) opt.selected = true;
weaponSel.appendChild(opt);
});
filterRow.appendChild(weaponSel);
panel.appendChild(filterRow);
// Rebuild panel when filter changes
function applyFilter() {
setGearFilter({ armor: armorSel.value, weapon: weaponSel.value });
buildGearPanel();
}
armorSel.onchange = applyFilter;
weaponSel.onchange = applyFilter;
const { results } = analyzeGear();
results.forEach(({ slot, current, cands }) => {
@ -3917,9 +3998,13 @@ function buildGearPanel() {
// Highlight best option
const best = cands[0];
if (best && best.source !== 'equipped' && current) {
const equippedCand = cands.find(c => c.source === 'equipped');
// If equipped somehow missing from candidates, score it directly
const eqScore = equippedCand ? equippedCand.score
: (current.stats ? (slot === 'Mainhand' ? gearScoreWeapon(current) : gearScoreArmor(current)) : 0);
const rec = document.createElement('div');
const icon = best.source === 'armory' ? '📦 Stored' : '🛒 For Sale';
rec.textContent = ` ✅ Upgrade: ${icon} ${best.item.name} (${best.score.toFixed(0)} vs ${cands.find(c => c.source === 'equipped')?.score.toFixed(0) || '?'})`;
rec.textContent = ` ✅ Upgrade: ${icon} ${best.item.name} (${best.score.toFixed(0)} vs ${eqScore.toFixed(0)})`;
rec.style.cssText = 'color:#0f0;margin-top:2px';
block.appendChild(rec);
} else if (best && best.source === 'equipped') {

View file

@ -1,7 +1,7 @@
// ==UserScript==
// @name HV Unified
// @namespace hvunified
// @version 0.14.39
// @version 0.14.40
// @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.14.39';
const VERSION = '0.14.40';
const CFG = {
// — Battle automation
@ -3801,6 +3801,15 @@ function gearUsable(item, playerLv) {
}
function gearPlayerLevel() {
// Prefer battle-parser cached level (STATE.level)
if (STATE && STATE.level) return STATE.level;
// Parse from the HV cfg button (e.g. "Lv195")
const btn = document.getElementById('hv-cfg-btn');
if (btn) {
const m = (btn.textContent || '').match(/Lv(\d+)/i);
if (m) return parseInt(m[1]);
}
// Fallback: level_readout (may be CSS-font with no text)
const el = document.getElementById('level_readout');
if (el) {
const m = (el.textContent || '').match(/(\d+)/);
@ -3809,6 +3818,21 @@ function gearPlayerLevel() {
return 155;
}
// ── Filter state (persisted) ──
const GEAR_FILTER_KEY = SP + 'gearFilter';
function getGearFilter() {
try {
const f = JSON.parse(localStorage[GEAR_FILTER_KEY] || 'null');
if (f && f.armor && f.weapon) return f;
} catch (e) {}
return { armor: 'Light', weapon: '2H' };
}
function setGearFilter(f) {
try { localStorage[GEAR_FILTER_KEY] = JSON.stringify(f); } catch (e) {}
}
// ── Main analysis ──
function analyzeGear() {
const db = getGearDB();
@ -3816,11 +3840,27 @@ function analyzeGear() {
const armory = db.filter(e => e.source === 'armory' && e.stats);
const buy = db.filter(e => e.source === 'buy' && e.stats);
const playerLv = gearPlayerLevel();
const filter = getGearFilter();
// Find equipped slot map (slotType from character page)
const slotMap = {};
equipped.forEach(e => { if (e.slotType) slotMap[e.slotType] = e; });
// Does this item match the armor type filter (Cloth/Light/Heavy/All)?
function matchesArmorType(item) {
if (filter.armor === 'All') return true;
const type = (item.stats && item.stats.type) || '';
const cat = (item.category || '') + ' ' + (item.name || '');
return type.includes(filter.armor) || cat.includes(filter.armor);
}
// Does this weapon match the weapon class filter (Estoc/Longsword/.../2H)?
function matchesWeaponClass(item) {
if (filter.weapon === '2H') return true;
const name = (item.name || '').toLowerCase();
return name.includes(filter.weapon.toLowerCase());
}
const results = [];
const slots = ['Mainhand', 'Head', 'Body', 'Hands', 'Legs', 'Feet'];
@ -3830,16 +3870,21 @@ function analyzeGear() {
const consider = (item, source) => {
if (!item || !item.stats) return;
if (!gearUsable(item, playerLv)) return;
const isWeapon = slot === 'Mainhand';
if (isWeapon) {
if (!gearIsTwoHandWeapon(item)) return;
if (!item.name || !item.stats['Attack Accuracy']) return; // needs stats
if (!item.name || !item.stats['Attack Accuracy']) return;
// Apply weapon class filter — but always include the equipped item
if (source !== 'equipped' && !matchesWeaponClass(item)) return;
} else {
const det = gearDetectSlot(item);
if (det !== slot) return;
if (!item.stats['Physical Mitigation'] && !item.stats['Evade']) return;
// Apply armor type filter — but always include the equipped item
if (source !== 'equipped' && !matchesArmorType(item)) return;
}
// Level filter — always include the equipped item (it's what you have on)
if (source !== 'equipped' && !gearUsable(item, playerLv)) return;
const score = isWeapon ? gearScoreWeapon(item) : gearScoreArmor(item);
cands.push({ item, source, score });
};
@ -3852,7 +3897,7 @@ function analyzeGear() {
results.push({ slot, current, cands });
});
return { playerLv, results };
return { playerLv, filter, results };
}
// ── UI ──
@ -3882,6 +3927,42 @@ function buildGearPanel() {
title.style.cssText = 'font-weight:bold;margin-bottom:6px;color:#ffd';
panel.appendChild(title);
// ── Filters row ──
const filterRow = document.createElement('div');
filterRow.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:8px;font-size:10px;color:#aaa';
const filter = getGearFilter();
filterRow.appendChild(Object.assign(document.createElement('span'), { textContent: 'Armor:' }));
const armorSel = document.createElement('select');
armorSel.style.cssText = 'background:#222;color:#fff;border:1px solid #444;border-radius:3px;font-size:10px;font-family:monospace';
['Light', 'Heavy', 'Cloth', 'All'].forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === filter.armor) opt.selected = true;
armorSel.appendChild(opt);
});
filterRow.appendChild(armorSel);
filterRow.appendChild(Object.assign(document.createElement('span'), { textContent: 'Weapon:' }));
const weaponSel = document.createElement('select');
weaponSel.style.cssText = 'background:#222;color:#fff;border:1px solid #444;border-radius:3px;font-size:10px;font-family:monospace';
['2H', 'Estoc', 'Longsword', 'Great Mace', 'Club', 'Axe', 'Scythe'].forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === filter.weapon) opt.selected = true;
weaponSel.appendChild(opt);
});
filterRow.appendChild(weaponSel);
panel.appendChild(filterRow);
// Rebuild panel when filter changes
function applyFilter() {
setGearFilter({ armor: armorSel.value, weapon: weaponSel.value });
buildGearPanel();
}
armorSel.onchange = applyFilter;
weaponSel.onchange = applyFilter;
const { results } = analyzeGear();
results.forEach(({ slot, current, cands }) => {
@ -3917,9 +3998,13 @@ function buildGearPanel() {
// Highlight best option
const best = cands[0];
if (best && best.source !== 'equipped' && current) {
const equippedCand = cands.find(c => c.source === 'equipped');
// If equipped somehow missing from candidates, score it directly
const eqScore = equippedCand ? equippedCand.score
: (current.stats ? (slot === 'Mainhand' ? gearScoreWeapon(current) : gearScoreArmor(current)) : 0);
const rec = document.createElement('div');
const icon = best.source === 'armory' ? '📦 Stored' : '🛒 For Sale';
rec.textContent = ` ✅ Upgrade: ${icon} ${best.item.name} (${best.score.toFixed(0)} vs ${cands.find(c => c.source === 'equipped')?.score.toFixed(0) || '?'})`;
rec.textContent = ` ✅ Upgrade: ${icon} ${best.item.name} (${best.score.toFixed(0)} vs ${eqScore.toFixed(0)})`;
rec.style.cssText = 'color:#0f0;margin-top:2px';
block.appendChild(rec);
} else if (best && best.source === 'equipped') {

View file

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

View file

@ -93,6 +93,15 @@ function gearUsable(item, playerLv) {
}
function gearPlayerLevel() {
// Prefer battle-parser cached level (STATE.level)
if (STATE && STATE.level) return STATE.level;
// Parse from the HV cfg button (e.g. "Lv195")
const btn = document.getElementById('hv-cfg-btn');
if (btn) {
const m = (btn.textContent || '').match(/Lv(\d+)/i);
if (m) return parseInt(m[1]);
}
// Fallback: level_readout (may be CSS-font with no text)
const el = document.getElementById('level_readout');
if (el) {
const m = (el.textContent || '').match(/(\d+)/);
@ -101,6 +110,21 @@ function gearPlayerLevel() {
return 155;
}
// ── Filter state (persisted) ──
const GEAR_FILTER_KEY = SP + 'gearFilter';
function getGearFilter() {
try {
const f = JSON.parse(localStorage[GEAR_FILTER_KEY] || 'null');
if (f && f.armor && f.weapon) return f;
} catch (e) {}
return { armor: 'Light', weapon: '2H' };
}
function setGearFilter(f) {
try { localStorage[GEAR_FILTER_KEY] = JSON.stringify(f); } catch (e) {}
}
// ── Main analysis ──
function analyzeGear() {
const db = getGearDB();
@ -108,11 +132,27 @@ function analyzeGear() {
const armory = db.filter(e => e.source === 'armory' && e.stats);
const buy = db.filter(e => e.source === 'buy' && e.stats);
const playerLv = gearPlayerLevel();
const filter = getGearFilter();
// Find equipped slot map (slotType from character page)
const slotMap = {};
equipped.forEach(e => { if (e.slotType) slotMap[e.slotType] = e; });
// Does this item match the armor type filter (Cloth/Light/Heavy/All)?
function matchesArmorType(item) {
if (filter.armor === 'All') return true;
const type = (item.stats && item.stats.type) || '';
const cat = (item.category || '') + ' ' + (item.name || '');
return type.includes(filter.armor) || cat.includes(filter.armor);
}
// Does this weapon match the weapon class filter (Estoc/Longsword/.../2H)?
function matchesWeaponClass(item) {
if (filter.weapon === '2H') return true;
const name = (item.name || '').toLowerCase();
return name.includes(filter.weapon.toLowerCase());
}
const results = [];
const slots = ['Mainhand', 'Head', 'Body', 'Hands', 'Legs', 'Feet'];
@ -122,16 +162,21 @@ function analyzeGear() {
const consider = (item, source) => {
if (!item || !item.stats) return;
if (!gearUsable(item, playerLv)) return;
const isWeapon = slot === 'Mainhand';
if (isWeapon) {
if (!gearIsTwoHandWeapon(item)) return;
if (!item.name || !item.stats['Attack Accuracy']) return; // needs stats
if (!item.name || !item.stats['Attack Accuracy']) return;
// Apply weapon class filter — but always include the equipped item
if (source !== 'equipped' && !matchesWeaponClass(item)) return;
} else {
const det = gearDetectSlot(item);
if (det !== slot) return;
if (!item.stats['Physical Mitigation'] && !item.stats['Evade']) return;
// Apply armor type filter — but always include the equipped item
if (source !== 'equipped' && !matchesArmorType(item)) return;
}
// Level filter — always include the equipped item (it's what you have on)
if (source !== 'equipped' && !gearUsable(item, playerLv)) return;
const score = isWeapon ? gearScoreWeapon(item) : gearScoreArmor(item);
cands.push({ item, source, score });
};
@ -144,7 +189,7 @@ function analyzeGear() {
results.push({ slot, current, cands });
});
return { playerLv, results };
return { playerLv, filter, results };
}
// ── UI ──
@ -174,6 +219,42 @@ function buildGearPanel() {
title.style.cssText = 'font-weight:bold;margin-bottom:6px;color:#ffd';
panel.appendChild(title);
// ── Filters row ──
const filterRow = document.createElement('div');
filterRow.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:8px;font-size:10px;color:#aaa';
const filter = getGearFilter();
filterRow.appendChild(Object.assign(document.createElement('span'), { textContent: 'Armor:' }));
const armorSel = document.createElement('select');
armorSel.style.cssText = 'background:#222;color:#fff;border:1px solid #444;border-radius:3px;font-size:10px;font-family:monospace';
['Light', 'Heavy', 'Cloth', 'All'].forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === filter.armor) opt.selected = true;
armorSel.appendChild(opt);
});
filterRow.appendChild(armorSel);
filterRow.appendChild(Object.assign(document.createElement('span'), { textContent: 'Weapon:' }));
const weaponSel = document.createElement('select');
weaponSel.style.cssText = 'background:#222;color:#fff;border:1px solid #444;border-radius:3px;font-size:10px;font-family:monospace';
['2H', 'Estoc', 'Longsword', 'Great Mace', 'Club', 'Axe', 'Scythe'].forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
if (t === filter.weapon) opt.selected = true;
weaponSel.appendChild(opt);
});
filterRow.appendChild(weaponSel);
panel.appendChild(filterRow);
// Rebuild panel when filter changes
function applyFilter() {
setGearFilter({ armor: armorSel.value, weapon: weaponSel.value });
buildGearPanel();
}
armorSel.onchange = applyFilter;
weaponSel.onchange = applyFilter;
const { results } = analyzeGear();
results.forEach(({ slot, current, cands }) => {
@ -209,9 +290,13 @@ function buildGearPanel() {
// Highlight best option
const best = cands[0];
if (best && best.source !== 'equipped' && current) {
const equippedCand = cands.find(c => c.source === 'equipped');
// If equipped somehow missing from candidates, score it directly
const eqScore = equippedCand ? equippedCand.score
: (current.stats ? (slot === 'Mainhand' ? gearScoreWeapon(current) : gearScoreArmor(current)) : 0);
const rec = document.createElement('div');
const icon = best.source === 'armory' ? '📦 Stored' : '🛒 For Sale';
rec.textContent = ` ✅ Upgrade: ${icon} ${best.item.name} (${best.score.toFixed(0)} vs ${cands.find(c => c.source === 'equipped')?.score.toFixed(0) || '?'})`;
rec.textContent = ` ✅ Upgrade: ${icon} ${best.item.name} (${best.score.toFixed(0)} vs ${eqScore.toFixed(0)})`;
rec.style.cssText = 'color:#0f0;margin-top:2px';
block.appendChild(rec);
} else if (best && best.source === 'equipped') {

View file

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