v0.14.33 - In-game gear analysis module

New src/gear-analysis.js — ports analyze_gear.py scoring to JS:
- gearScoreArmor() / gearScoreWeapon() — same weights as Python
- gearDetectSlot() / gearIsTwoHandWeapon() / gearUsable()
- analyzeGear() — scores equipped vs armory vs buy per slot

UI on Armory pages (above equiplist):
- 🔍 Analyze Gear — rescrapes current page, shows per-slot panel
  with top 4 candidates, scores, prices, and upgrade highlights
- 🔄 Clear Buy + Rescan — wipes stale buy data, rescrapes, analyzes

scrapeBuyPage() now also scavenges the full dynjs_eqstore for items
not visible in the current filter tab — one Purchase visit captures
the entire store (429+ items), no need to click through tabs.

Console API: HV.analyzeGear(), HV.gearPanel(), HV.clearBuy()
This commit is contained in:
GaboGG 2026-07-31 07:07:38 -04:00
parent 6ac31faa1f
commit 166539e845
9 changed files with 990 additions and 9 deletions

View file

@ -32,6 +32,7 @@ FILES=(
abilities.js
armory.js
gear-scraper.js
gear-analysis.js
battle-logger.js
re-timer.js
settings-page.js

View file

@ -1,7 +1,7 @@
// ==UserScript==
// @name HV Unified
// @namespace hvunified
// @version 0.14.32
// @version 0.14.33
// @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.32';
const VERSION = '0.14.33';
const CFG = {
// — Battle automation
@ -3615,6 +3615,33 @@ function scrapeBuyPage() {
saveGearDB([...existing, ...scraped]);
console.log(`%c[HV] 🛒 Buy: ${scraped.length} items (${scraped.filter(s => s.stats).length} with stats)`, 'color:#0f0');
}
// Second, scan the full store for items NOT in the visible list
// The Purchase page loads ALL items into dynjs_eqstore regardless of
// the filter tab — one visit captures the whole store.
const store = getHVEquipStore();
if (store) {
const visibleIds = new Set(scraped.map(s => s.id));
const missing = Object.entries(store).filter(([id, data]) => !visibleIds.has(id) && data && (data.t || data.d));
if (missing.length > 0) {
missing.forEach(([id, data]) => {
const stats = data.d ? parseEquipHTML(data.d) : null;
let category = 'All Items';
if (stats && stats.type) {
if (stats.type.includes('Cloth')) category = 'Cloth Armor';
else if (stats.type.includes('Light')) category = 'Light Armor';
else if (stats.type.includes('Heavy')) category = 'Heavy Armor';
else if (stats.type.includes('Shield')) category = 'Shield';
else if (stats.type.includes('Staff')) category = 'Staff';
else category = 'Weapon';
}
scraped.push({ id, name: data.t || 'Unknown', category, quality: data.q, stats, source: 'buy', scrapedAt: Date.now() });
});
const db2 = getGearDB();
saveGearDB([...db2.filter(e => e.source !== 'buy'), ...scraped]);
console.log(`%c[HV] 🛒 Buy: +${missing.length} from store (total ${scraped.length})`, 'color:#0f0');
}
}
return scraped;
}
@ -3679,6 +3706,302 @@ function scrapeModifyDetail() {
return item;
}
// ═══════════════════════════════════════════════════════════════════════
// GEAR ANALYSIS — in-game gear comparison (port of analyze_gear.py)
// ═══════════════════════════════════════════════════════════════════════
// Adds a toolbar button on Armory pages. Clicking it:
// 1. Clears old buy data (optional)
// 2. Rescrapes the current page (store or inventory)
// 3. Scores all items and shows per-slot recommendations
// Sources: 🟢 equipped (character), 📦 stored (armory), 🛒 purchasable (buy)
function _num(v) {
if (v == null) return 0;
const s = String(v).replace('+', '').replace('%', '').trim();
const n = parseFloat(s);
return isNaN(n) ? 0 : n;
}
// ── Scoring (mirrors analyze_gear.py) ──
function gearScoreArmor(item) {
const st = item.stats || {};
let s = 0;
s += _num(st['Physical Mitigation']) * 3;
s += _num(st['Evade']) * 2;
s += _num(st['Strength']) * 2;
s += _num(st['Dexterity']) * 2;
s += _num(st['Agility']) * 1.5;
s += _num(st['Endurance']);
s += _num(st['Crushing']);
s += _num(st['Slashing']);
s += _num(st['Piercing']);
s -= _num(st['burden']) * 0.5;
s += (item.quality || 0) * 5;
return s;
}
function gearScoreWeapon(item) {
const st = item.stats || {};
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
const quality = item.quality || 0;
let s = 0;
s += _num(st['Attack Accuracy']) * 2;
s += _num(st['Attack Crit Damage']) * 60;
s += _num(st['Strength']) * 2;
s += _num(st['Dexterity']) * 1.5;
s += _num(st['Parry']) * 0.5;
s += _num(st['Block']) * 0.5;
s += _num(st['Agility']) * 0.5;
s -= _num(st['burden']) * 0.5;
// Quality multiplier (q6=Magnificent, q5=Exquisite, q4=Superior)
s *= 0.8 + (quality * 0.12);
// Weapon type bonus
if (name.includes('estoc')) s *= 1.25;
else if (name.includes('longsword')) s *= 1.10;
else if (name.includes('great mace')) s *= 0.90;
else if (name.includes('club')) s *= 0.85;
else if (name.includes('axe')) s *= 0.90;
return s;
}
// ── Slot detection ──
const GEAR_SLOT_KEYWORDS = {
'Head': ['helmet','cap','goggles','mask','hood','hat','circlet','crown','visor'],
'Body': ['breastplate','cuirass','robe','tunic','chest','gi','jacket','vest','hauberk'],
'Hands': ['gauntlets','gloves','mitts','bracers','handguards','fists'],
'Legs': ['leggings','greaves','pants','trousers','loincloth','kilt','breeches','chaps'],
'Feet': ['boots','sabatons','shoes','slippers','sandals'],
};
function gearDetectSlot(item) {
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
for (const [slot, kws] of Object.entries(GEAR_SLOT_KEYWORDS)) {
for (const kw of kws) {
if (name.includes(kw)) return slot;
}
}
return null;
}
function gearIsTwoHandWeapon(item) {
const st = item.stats || {};
const itype = st.type || '';
if (itype.includes('Armor') || itype.includes('Shield') || itype.includes('Staff')) return false;
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
if (['estoc','longsword','great mace','scythe','axe','club'].some(k => name.includes(k))) return true;
return false;
}
function gearUsable(item, playerLv) {
const lv = (item.stats || {}).level;
if (!lv || lv === 'Unassigned') return true;
const n = parseInt(lv);
if (isNaN(n)) return true;
return n <= (playerLv || 155) + 15;
}
function gearPlayerLevel() {
const el = document.getElementById('level_readout');
if (el) {
const m = (el.textContent || '').match(/(\d+)/);
if (m) return parseInt(m[1]);
}
return 155;
}
// ── Main analysis ──
function analyzeGear() {
const db = getGearDB();
const equipped = db.filter(e => e.source === 'character' && e.id);
const armory = db.filter(e => e.source === 'armory' && e.stats);
const buy = db.filter(e => e.source === 'buy' && e.stats);
const playerLv = gearPlayerLevel();
// Find equipped slot map (slotType from character page)
const slotMap = {};
equipped.forEach(e => { if (e.slotType) slotMap[e.slotType] = e; });
const results = [];
const slots = ['Mainhand', 'Head', 'Body', 'Hands', 'Legs', 'Feet'];
slots.forEach(slot => {
const current = slotMap[slot];
const cands = [];
const consider = (item, source) => {
if (!item || !item.stats) return;
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
} else {
const det = gearDetectSlot(item);
if (det !== slot) return;
if (!item.stats['Physical Mitigation'] && !item.stats['Evade']) return;
}
const score = isWeapon ? gearScoreWeapon(item) : gearScoreArmor(item);
cands.push({ item, source, score });
};
if (current) consider(current, 'equipped');
armory.forEach(i => consider(i, 'armory'));
buy.forEach(i => consider(i, 'buy'));
cands.sort((a, b) => b.score - a.score);
results.push({ slot, current, cands });
});
return { playerLv, results };
}
// ── UI ──
function buildGearPanel() {
// Remove old panel
const old = document.getElementById('hv-gear-panel');
if (old) old.remove();
const panel = document.createElement('div');
panel.id = 'hv-gear-panel';
panel.style.cssText = css({
position: 'fixed', right: '8px', top: '48px', zIndex: 99999,
width: '420px', maxHeight: '80vh', overflowY: 'auto',
background: '#12121e', border: '1px solid #333', borderRadius: '6px',
padding: '8px', fontSize: '11px', fontFamily: 'monospace',
boxShadow: '0 4px 16px rgba(0,0,0,.6)',
});
const close = document.createElement('button');
close.textContent = '✕';
close.style.cssText = 'float:right;cursor:pointer;background:#333;border:none;color:#fff;border-radius:3px;padding:2px 6px';
close.onclick = () => panel.remove();
panel.appendChild(close);
const title = document.createElement('div');
title.textContent = '🛡️ Gear Analysis';
title.style.cssText = 'font-weight:bold;margin-bottom:6px;color:#ffd';
panel.appendChild(title);
const { results } = analyzeGear();
results.forEach(({ slot, current, cands }) => {
const block = document.createElement('div');
block.style.cssText = 'margin-bottom:10px;border-top:1px solid #222;padding-top:6px';
const head = document.createElement('div');
head.textContent = `${slot}: ${current ? current.name : '—'}`;
head.style.cssText = 'color:#9cf;font-weight:bold;margin-bottom:2px';
block.appendChild(head);
if (!cands.length) {
const none = document.createElement('div');
none.textContent = ' (no data)';
none.style.cssText = 'color:#666';
block.appendChild(none);
panel.appendChild(block);
return;
}
// Show top 4 candidates
cands.slice(0, 4).forEach((c, i) => {
const icon = c.source === 'equipped' ? '🟢' : c.source === 'armory' ? '📦' : '🛒';
const row = document.createElement('div');
const price = c.item.price ? ` 💰${c.item.price}` : '';
const isCurrent = c.source === 'equipped';
row.textContent = ` ${i + 1}. [${c.score.toFixed(0)}] ${icon} ${c.item.name}${isCurrent ? ' ⬅' : ''}${price}`;
row.style.cssText = isCurrent ? 'color:#8f8' : 'color:#ccc';
if (i === 0 && !isCurrent) row.style.color = '#ff0';
block.appendChild(row);
});
// Highlight best option
const best = cands[0];
if (best && best.source !== 'equipped' && current) {
const 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.style.cssText = 'color:#0f0;margin-top:2px';
block.appendChild(rec);
} else if (best && best.source === 'equipped') {
const ok = document.createElement('div');
ok.textContent = ' ✅ Already best';
ok.style.cssText = 'color:#0f0';
block.appendChild(ok);
}
panel.appendChild(block);
});
document.body.appendChild(panel);
}
function enhanceGearAnalysis() {
// Only on Bazaar pages with equiplist
if (!document.getElementById('equiplist')) return;
if (document.getElementById('hv-gear-btn')) return;
const url = window.location.href || '';
const isBuy = url.includes('screen=purchase') || url.includes('ss=bi');
const bar = document.createElement('div');
bar.id = 'hv-gear-btn';
bar.style.cssText = css({
display: 'inline-flex', gap: '4px', margin: '2px 0',
});
// Analyze button
const analyzeBtn = document.createElement('button');
analyzeBtn.textContent = '🔍 Analyze Gear';
analyzeBtn.style.cssText = css({
padding: '3px 8px', background: '#2a2a4a', color: '#fff',
border: '1px solid #555', borderRadius: '3px', cursor: 'pointer',
fontSize: '11px', fontFamily: 'monospace',
});
analyzeBtn.onclick = () => {
autoScrapeGear();
setTimeout(buildGearPanel, 300);
};
bar.appendChild(analyzeBtn);
// Rescan button (clears buy data then rescrapes)
const rescanBtn = document.createElement('button');
rescanBtn.textContent = '🔄 Clear Buy + Rescan';
rescanBtn.style.cssText = css({
padding: '3px 8px', background: '#3a2a2a', color: '#fff',
border: '1px solid #555', borderRadius: '3px', cursor: 'pointer',
fontSize: '11px', fontFamily: 'monospace',
});
rescanBtn.onclick = () => {
// Clear old buy data
const db = getGearDB();
saveGearDB(db.filter(e => e.source !== 'buy'));
console.log('%c[HV] 🧹 Cleared old buy data', 'color:#f80');
// Rescan current page
const scraped = autoScrapeGear();
const count = scraped.length || 0;
console.log(`%c[HV] 🔄 Rescanned: ${count} items`, 'color:#0f0');
// If on buy page, also scan store after short delay
if (isBuy) {
setTimeout(() => {
autoScrapeGear();
buildGearPanel();
}, 500);
} else {
setTimeout(buildGearPanel, 300);
}
};
bar.appendChild(rescanBtn);
// Insert into the page (next to the armory toolbar if present)
const equiplist = document.getElementById('equiplist');
const container = document.createElement('div');
container.style.cssText = 'margin-bottom:4px';
container.appendChild(bar);
equiplist.parentNode.insertBefore(container, equiplist);
console.log('%c[HV] 🛡️ Gear analysis enabled — click 🔍 or 🔄', 'color:#0f0');
}
// ═══════════════════════════════════════════════════════════════════════
// BATTLE LOGGER — saves raw battle log lines to localStorage in real time
// ═══════════════════════════════════════════════════════════════════════
@ -3977,6 +4300,10 @@ window.HV = {
scrapeGear: () => autoScrapeGear(),
gearDebug: () => debugScrapeGear(),
gearStore: () => { const s = getHVEquipStore(); return s ? `Loaded: ${Object.keys(s).length} items` : 'Not loaded yet'; },
// Gear analysis
analyzeGear: () => analyzeGear(),
gearPanel: () => buildGearPanel(),
clearBuy: () => { const db = getGearDB(); saveGearDB(db.filter(e => e.source !== 'buy')); return 'Buy data cleared'; },
};
// ═══════════════════════════════════════════════════════════════════════
@ -4006,7 +4333,7 @@ function init() {
if (STATE.page === 'arena') { autoCheckTask('arenas'); autoCheckTask('first-blood'); }
if (STATE.page === 'character') showGuidancePanel();
if (STATE.page === 'settings') enhanceSettings();
if (STATE.page === 'armory') enhanceArmory();
if (STATE.page === 'armory') { enhanceArmory(); enhanceGearAnalysis(); }
if (STATE.page === 'abilities') enhanceAbilities();
if (STATE.page === 'monster') enhanceMonsterLab();
}

View file

@ -1,7 +1,7 @@
// ==UserScript==
// @name HV Unified
// @namespace hvunified
// @version 0.14.32
// @version 0.14.33
// @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.32';
const VERSION = '0.14.33';
const CFG = {
// — Battle automation
@ -3615,6 +3615,33 @@ function scrapeBuyPage() {
saveGearDB([...existing, ...scraped]);
console.log(`%c[HV] 🛒 Buy: ${scraped.length} items (${scraped.filter(s => s.stats).length} with stats)`, 'color:#0f0');
}
// Second, scan the full store for items NOT in the visible list
// The Purchase page loads ALL items into dynjs_eqstore regardless of
// the filter tab — one visit captures the whole store.
const store = getHVEquipStore();
if (store) {
const visibleIds = new Set(scraped.map(s => s.id));
const missing = Object.entries(store).filter(([id, data]) => !visibleIds.has(id) && data && (data.t || data.d));
if (missing.length > 0) {
missing.forEach(([id, data]) => {
const stats = data.d ? parseEquipHTML(data.d) : null;
let category = 'All Items';
if (stats && stats.type) {
if (stats.type.includes('Cloth')) category = 'Cloth Armor';
else if (stats.type.includes('Light')) category = 'Light Armor';
else if (stats.type.includes('Heavy')) category = 'Heavy Armor';
else if (stats.type.includes('Shield')) category = 'Shield';
else if (stats.type.includes('Staff')) category = 'Staff';
else category = 'Weapon';
}
scraped.push({ id, name: data.t || 'Unknown', category, quality: data.q, stats, source: 'buy', scrapedAt: Date.now() });
});
const db2 = getGearDB();
saveGearDB([...db2.filter(e => e.source !== 'buy'), ...scraped]);
console.log(`%c[HV] 🛒 Buy: +${missing.length} from store (total ${scraped.length})`, 'color:#0f0');
}
}
return scraped;
}
@ -3679,6 +3706,302 @@ function scrapeModifyDetail() {
return item;
}
// ═══════════════════════════════════════════════════════════════════════
// GEAR ANALYSIS — in-game gear comparison (port of analyze_gear.py)
// ═══════════════════════════════════════════════════════════════════════
// Adds a toolbar button on Armory pages. Clicking it:
// 1. Clears old buy data (optional)
// 2. Rescrapes the current page (store or inventory)
// 3. Scores all items and shows per-slot recommendations
// Sources: 🟢 equipped (character), 📦 stored (armory), 🛒 purchasable (buy)
function _num(v) {
if (v == null) return 0;
const s = String(v).replace('+', '').replace('%', '').trim();
const n = parseFloat(s);
return isNaN(n) ? 0 : n;
}
// ── Scoring (mirrors analyze_gear.py) ──
function gearScoreArmor(item) {
const st = item.stats || {};
let s = 0;
s += _num(st['Physical Mitigation']) * 3;
s += _num(st['Evade']) * 2;
s += _num(st['Strength']) * 2;
s += _num(st['Dexterity']) * 2;
s += _num(st['Agility']) * 1.5;
s += _num(st['Endurance']);
s += _num(st['Crushing']);
s += _num(st['Slashing']);
s += _num(st['Piercing']);
s -= _num(st['burden']) * 0.5;
s += (item.quality || 0) * 5;
return s;
}
function gearScoreWeapon(item) {
const st = item.stats || {};
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
const quality = item.quality || 0;
let s = 0;
s += _num(st['Attack Accuracy']) * 2;
s += _num(st['Attack Crit Damage']) * 60;
s += _num(st['Strength']) * 2;
s += _num(st['Dexterity']) * 1.5;
s += _num(st['Parry']) * 0.5;
s += _num(st['Block']) * 0.5;
s += _num(st['Agility']) * 0.5;
s -= _num(st['burden']) * 0.5;
// Quality multiplier (q6=Magnificent, q5=Exquisite, q4=Superior)
s *= 0.8 + (quality * 0.12);
// Weapon type bonus
if (name.includes('estoc')) s *= 1.25;
else if (name.includes('longsword')) s *= 1.10;
else if (name.includes('great mace')) s *= 0.90;
else if (name.includes('club')) s *= 0.85;
else if (name.includes('axe')) s *= 0.90;
return s;
}
// ── Slot detection ──
const GEAR_SLOT_KEYWORDS = {
'Head': ['helmet','cap','goggles','mask','hood','hat','circlet','crown','visor'],
'Body': ['breastplate','cuirass','robe','tunic','chest','gi','jacket','vest','hauberk'],
'Hands': ['gauntlets','gloves','mitts','bracers','handguards','fists'],
'Legs': ['leggings','greaves','pants','trousers','loincloth','kilt','breeches','chaps'],
'Feet': ['boots','sabatons','shoes','slippers','sandals'],
};
function gearDetectSlot(item) {
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
for (const [slot, kws] of Object.entries(GEAR_SLOT_KEYWORDS)) {
for (const kw of kws) {
if (name.includes(kw)) return slot;
}
}
return null;
}
function gearIsTwoHandWeapon(item) {
const st = item.stats || {};
const itype = st.type || '';
if (itype.includes('Armor') || itype.includes('Shield') || itype.includes('Staff')) return false;
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
if (['estoc','longsword','great mace','scythe','axe','club'].some(k => name.includes(k))) return true;
return false;
}
function gearUsable(item, playerLv) {
const lv = (item.stats || {}).level;
if (!lv || lv === 'Unassigned') return true;
const n = parseInt(lv);
if (isNaN(n)) return true;
return n <= (playerLv || 155) + 15;
}
function gearPlayerLevel() {
const el = document.getElementById('level_readout');
if (el) {
const m = (el.textContent || '').match(/(\d+)/);
if (m) return parseInt(m[1]);
}
return 155;
}
// ── Main analysis ──
function analyzeGear() {
const db = getGearDB();
const equipped = db.filter(e => e.source === 'character' && e.id);
const armory = db.filter(e => e.source === 'armory' && e.stats);
const buy = db.filter(e => e.source === 'buy' && e.stats);
const playerLv = gearPlayerLevel();
// Find equipped slot map (slotType from character page)
const slotMap = {};
equipped.forEach(e => { if (e.slotType) slotMap[e.slotType] = e; });
const results = [];
const slots = ['Mainhand', 'Head', 'Body', 'Hands', 'Legs', 'Feet'];
slots.forEach(slot => {
const current = slotMap[slot];
const cands = [];
const consider = (item, source) => {
if (!item || !item.stats) return;
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
} else {
const det = gearDetectSlot(item);
if (det !== slot) return;
if (!item.stats['Physical Mitigation'] && !item.stats['Evade']) return;
}
const score = isWeapon ? gearScoreWeapon(item) : gearScoreArmor(item);
cands.push({ item, source, score });
};
if (current) consider(current, 'equipped');
armory.forEach(i => consider(i, 'armory'));
buy.forEach(i => consider(i, 'buy'));
cands.sort((a, b) => b.score - a.score);
results.push({ slot, current, cands });
});
return { playerLv, results };
}
// ── UI ──
function buildGearPanel() {
// Remove old panel
const old = document.getElementById('hv-gear-panel');
if (old) old.remove();
const panel = document.createElement('div');
panel.id = 'hv-gear-panel';
panel.style.cssText = css({
position: 'fixed', right: '8px', top: '48px', zIndex: 99999,
width: '420px', maxHeight: '80vh', overflowY: 'auto',
background: '#12121e', border: '1px solid #333', borderRadius: '6px',
padding: '8px', fontSize: '11px', fontFamily: 'monospace',
boxShadow: '0 4px 16px rgba(0,0,0,.6)',
});
const close = document.createElement('button');
close.textContent = '✕';
close.style.cssText = 'float:right;cursor:pointer;background:#333;border:none;color:#fff;border-radius:3px;padding:2px 6px';
close.onclick = () => panel.remove();
panel.appendChild(close);
const title = document.createElement('div');
title.textContent = '🛡️ Gear Analysis';
title.style.cssText = 'font-weight:bold;margin-bottom:6px;color:#ffd';
panel.appendChild(title);
const { results } = analyzeGear();
results.forEach(({ slot, current, cands }) => {
const block = document.createElement('div');
block.style.cssText = 'margin-bottom:10px;border-top:1px solid #222;padding-top:6px';
const head = document.createElement('div');
head.textContent = `${slot}: ${current ? current.name : '—'}`;
head.style.cssText = 'color:#9cf;font-weight:bold;margin-bottom:2px';
block.appendChild(head);
if (!cands.length) {
const none = document.createElement('div');
none.textContent = ' (no data)';
none.style.cssText = 'color:#666';
block.appendChild(none);
panel.appendChild(block);
return;
}
// Show top 4 candidates
cands.slice(0, 4).forEach((c, i) => {
const icon = c.source === 'equipped' ? '🟢' : c.source === 'armory' ? '📦' : '🛒';
const row = document.createElement('div');
const price = c.item.price ? ` 💰${c.item.price}` : '';
const isCurrent = c.source === 'equipped';
row.textContent = ` ${i + 1}. [${c.score.toFixed(0)}] ${icon} ${c.item.name}${isCurrent ? ' ⬅' : ''}${price}`;
row.style.cssText = isCurrent ? 'color:#8f8' : 'color:#ccc';
if (i === 0 && !isCurrent) row.style.color = '#ff0';
block.appendChild(row);
});
// Highlight best option
const best = cands[0];
if (best && best.source !== 'equipped' && current) {
const 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.style.cssText = 'color:#0f0;margin-top:2px';
block.appendChild(rec);
} else if (best && best.source === 'equipped') {
const ok = document.createElement('div');
ok.textContent = ' ✅ Already best';
ok.style.cssText = 'color:#0f0';
block.appendChild(ok);
}
panel.appendChild(block);
});
document.body.appendChild(panel);
}
function enhanceGearAnalysis() {
// Only on Bazaar pages with equiplist
if (!document.getElementById('equiplist')) return;
if (document.getElementById('hv-gear-btn')) return;
const url = window.location.href || '';
const isBuy = url.includes('screen=purchase') || url.includes('ss=bi');
const bar = document.createElement('div');
bar.id = 'hv-gear-btn';
bar.style.cssText = css({
display: 'inline-flex', gap: '4px', margin: '2px 0',
});
// Analyze button
const analyzeBtn = document.createElement('button');
analyzeBtn.textContent = '🔍 Analyze Gear';
analyzeBtn.style.cssText = css({
padding: '3px 8px', background: '#2a2a4a', color: '#fff',
border: '1px solid #555', borderRadius: '3px', cursor: 'pointer',
fontSize: '11px', fontFamily: 'monospace',
});
analyzeBtn.onclick = () => {
autoScrapeGear();
setTimeout(buildGearPanel, 300);
};
bar.appendChild(analyzeBtn);
// Rescan button (clears buy data then rescrapes)
const rescanBtn = document.createElement('button');
rescanBtn.textContent = '🔄 Clear Buy + Rescan';
rescanBtn.style.cssText = css({
padding: '3px 8px', background: '#3a2a2a', color: '#fff',
border: '1px solid #555', borderRadius: '3px', cursor: 'pointer',
fontSize: '11px', fontFamily: 'monospace',
});
rescanBtn.onclick = () => {
// Clear old buy data
const db = getGearDB();
saveGearDB(db.filter(e => e.source !== 'buy'));
console.log('%c[HV] 🧹 Cleared old buy data', 'color:#f80');
// Rescan current page
const scraped = autoScrapeGear();
const count = scraped.length || 0;
console.log(`%c[HV] 🔄 Rescanned: ${count} items`, 'color:#0f0');
// If on buy page, also scan store after short delay
if (isBuy) {
setTimeout(() => {
autoScrapeGear();
buildGearPanel();
}, 500);
} else {
setTimeout(buildGearPanel, 300);
}
};
bar.appendChild(rescanBtn);
// Insert into the page (next to the armory toolbar if present)
const equiplist = document.getElementById('equiplist');
const container = document.createElement('div');
container.style.cssText = 'margin-bottom:4px';
container.appendChild(bar);
equiplist.parentNode.insertBefore(container, equiplist);
console.log('%c[HV] 🛡️ Gear analysis enabled — click 🔍 or 🔄', 'color:#0f0');
}
// ═══════════════════════════════════════════════════════════════════════
// BATTLE LOGGER — saves raw battle log lines to localStorage in real time
// ═══════════════════════════════════════════════════════════════════════
@ -3977,6 +4300,10 @@ window.HV = {
scrapeGear: () => autoScrapeGear(),
gearDebug: () => debugScrapeGear(),
gearStore: () => { const s = getHVEquipStore(); return s ? `Loaded: ${Object.keys(s).length} items` : 'Not loaded yet'; },
// Gear analysis
analyzeGear: () => analyzeGear(),
gearPanel: () => buildGearPanel(),
clearBuy: () => { const db = getGearDB(); saveGearDB(db.filter(e => e.source !== 'buy')); return 'Buy data cleared'; },
};
// ═══════════════════════════════════════════════════════════════════════
@ -4006,7 +4333,7 @@ function init() {
if (STATE.page === 'arena') { autoCheckTask('arenas'); autoCheckTask('first-blood'); }
if (STATE.page === 'character') showGuidancePanel();
if (STATE.page === 'settings') enhanceSettings();
if (STATE.page === 'armory') enhanceArmory();
if (STATE.page === 'armory') { enhanceArmory(); enhanceGearAnalysis(); }
if (STATE.page === 'abilities') enhanceAbilities();
if (STATE.page === 'monster') enhanceMonsterLab();
}

View file

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

295
src/gear-analysis.js Normal file
View file

@ -0,0 +1,295 @@
// ═══════════════════════════════════════════════════════════════════════
// GEAR ANALYSIS — in-game gear comparison (port of analyze_gear.py)
// ═══════════════════════════════════════════════════════════════════════
// Adds a toolbar button on Armory pages. Clicking it:
// 1. Clears old buy data (optional)
// 2. Rescrapes the current page (store or inventory)
// 3. Scores all items and shows per-slot recommendations
// Sources: 🟢 equipped (character), 📦 stored (armory), 🛒 purchasable (buy)
function _num(v) {
if (v == null) return 0;
const s = String(v).replace('+', '').replace('%', '').trim();
const n = parseFloat(s);
return isNaN(n) ? 0 : n;
}
// ── Scoring (mirrors analyze_gear.py) ──
function gearScoreArmor(item) {
const st = item.stats || {};
let s = 0;
s += _num(st['Physical Mitigation']) * 3;
s += _num(st['Evade']) * 2;
s += _num(st['Strength']) * 2;
s += _num(st['Dexterity']) * 2;
s += _num(st['Agility']) * 1.5;
s += _num(st['Endurance']);
s += _num(st['Crushing']);
s += _num(st['Slashing']);
s += _num(st['Piercing']);
s -= _num(st['burden']) * 0.5;
s += (item.quality || 0) * 5;
return s;
}
function gearScoreWeapon(item) {
const st = item.stats || {};
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
const quality = item.quality || 0;
let s = 0;
s += _num(st['Attack Accuracy']) * 2;
s += _num(st['Attack Crit Damage']) * 60;
s += _num(st['Strength']) * 2;
s += _num(st['Dexterity']) * 1.5;
s += _num(st['Parry']) * 0.5;
s += _num(st['Block']) * 0.5;
s += _num(st['Agility']) * 0.5;
s -= _num(st['burden']) * 0.5;
// Quality multiplier (q6=Magnificent, q5=Exquisite, q4=Superior)
s *= 0.8 + (quality * 0.12);
// Weapon type bonus
if (name.includes('estoc')) s *= 1.25;
else if (name.includes('longsword')) s *= 1.10;
else if (name.includes('great mace')) s *= 0.90;
else if (name.includes('club')) s *= 0.85;
else if (name.includes('axe')) s *= 0.90;
return s;
}
// ── Slot detection ──
const GEAR_SLOT_KEYWORDS = {
'Head': ['helmet','cap','goggles','mask','hood','hat','circlet','crown','visor'],
'Body': ['breastplate','cuirass','robe','tunic','chest','gi','jacket','vest','hauberk'],
'Hands': ['gauntlets','gloves','mitts','bracers','handguards','fists'],
'Legs': ['leggings','greaves','pants','trousers','loincloth','kilt','breeches','chaps'],
'Feet': ['boots','sabatons','shoes','slippers','sandals'],
};
function gearDetectSlot(item) {
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
for (const [slot, kws] of Object.entries(GEAR_SLOT_KEYWORDS)) {
for (const kw of kws) {
if (name.includes(kw)) return slot;
}
}
return null;
}
function gearIsTwoHandWeapon(item) {
const st = item.stats || {};
const itype = st.type || '';
if (itype.includes('Armor') || itype.includes('Shield') || itype.includes('Staff')) return false;
const name = ((item.name || '') + ' ' + (item.category || '')).toLowerCase();
if (['estoc','longsword','great mace','scythe','axe','club'].some(k => name.includes(k))) return true;
return false;
}
function gearUsable(item, playerLv) {
const lv = (item.stats || {}).level;
if (!lv || lv === 'Unassigned') return true;
const n = parseInt(lv);
if (isNaN(n)) return true;
return n <= (playerLv || 155) + 15;
}
function gearPlayerLevel() {
const el = document.getElementById('level_readout');
if (el) {
const m = (el.textContent || '').match(/(\d+)/);
if (m) return parseInt(m[1]);
}
return 155;
}
// ── Main analysis ──
function analyzeGear() {
const db = getGearDB();
const equipped = db.filter(e => e.source === 'character' && e.id);
const armory = db.filter(e => e.source === 'armory' && e.stats);
const buy = db.filter(e => e.source === 'buy' && e.stats);
const playerLv = gearPlayerLevel();
// Find equipped slot map (slotType from character page)
const slotMap = {};
equipped.forEach(e => { if (e.slotType) slotMap[e.slotType] = e; });
const results = [];
const slots = ['Mainhand', 'Head', 'Body', 'Hands', 'Legs', 'Feet'];
slots.forEach(slot => {
const current = slotMap[slot];
const cands = [];
const consider = (item, source) => {
if (!item || !item.stats) return;
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
} else {
const det = gearDetectSlot(item);
if (det !== slot) return;
if (!item.stats['Physical Mitigation'] && !item.stats['Evade']) return;
}
const score = isWeapon ? gearScoreWeapon(item) : gearScoreArmor(item);
cands.push({ item, source, score });
};
if (current) consider(current, 'equipped');
armory.forEach(i => consider(i, 'armory'));
buy.forEach(i => consider(i, 'buy'));
cands.sort((a, b) => b.score - a.score);
results.push({ slot, current, cands });
});
return { playerLv, results };
}
// ── UI ──
function buildGearPanel() {
// Remove old panel
const old = document.getElementById('hv-gear-panel');
if (old) old.remove();
const panel = document.createElement('div');
panel.id = 'hv-gear-panel';
panel.style.cssText = css({
position: 'fixed', right: '8px', top: '48px', zIndex: 99999,
width: '420px', maxHeight: '80vh', overflowY: 'auto',
background: '#12121e', border: '1px solid #333', borderRadius: '6px',
padding: '8px', fontSize: '11px', fontFamily: 'monospace',
boxShadow: '0 4px 16px rgba(0,0,0,.6)',
});
const close = document.createElement('button');
close.textContent = '✕';
close.style.cssText = 'float:right;cursor:pointer;background:#333;border:none;color:#fff;border-radius:3px;padding:2px 6px';
close.onclick = () => panel.remove();
panel.appendChild(close);
const title = document.createElement('div');
title.textContent = '🛡️ Gear Analysis';
title.style.cssText = 'font-weight:bold;margin-bottom:6px;color:#ffd';
panel.appendChild(title);
const { results } = analyzeGear();
results.forEach(({ slot, current, cands }) => {
const block = document.createElement('div');
block.style.cssText = 'margin-bottom:10px;border-top:1px solid #222;padding-top:6px';
const head = document.createElement('div');
head.textContent = `${slot}: ${current ? current.name : '—'}`;
head.style.cssText = 'color:#9cf;font-weight:bold;margin-bottom:2px';
block.appendChild(head);
if (!cands.length) {
const none = document.createElement('div');
none.textContent = ' (no data)';
none.style.cssText = 'color:#666';
block.appendChild(none);
panel.appendChild(block);
return;
}
// Show top 4 candidates
cands.slice(0, 4).forEach((c, i) => {
const icon = c.source === 'equipped' ? '🟢' : c.source === 'armory' ? '📦' : '🛒';
const row = document.createElement('div');
const price = c.item.price ? ` 💰${c.item.price}` : '';
const isCurrent = c.source === 'equipped';
row.textContent = ` ${i + 1}. [${c.score.toFixed(0)}] ${icon} ${c.item.name}${isCurrent ? ' ⬅' : ''}${price}`;
row.style.cssText = isCurrent ? 'color:#8f8' : 'color:#ccc';
if (i === 0 && !isCurrent) row.style.color = '#ff0';
block.appendChild(row);
});
// Highlight best option
const best = cands[0];
if (best && best.source !== 'equipped' && current) {
const 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.style.cssText = 'color:#0f0;margin-top:2px';
block.appendChild(rec);
} else if (best && best.source === 'equipped') {
const ok = document.createElement('div');
ok.textContent = ' ✅ Already best';
ok.style.cssText = 'color:#0f0';
block.appendChild(ok);
}
panel.appendChild(block);
});
document.body.appendChild(panel);
}
function enhanceGearAnalysis() {
// Only on Bazaar pages with equiplist
if (!document.getElementById('equiplist')) return;
if (document.getElementById('hv-gear-btn')) return;
const url = window.location.href || '';
const isBuy = url.includes('screen=purchase') || url.includes('ss=bi');
const bar = document.createElement('div');
bar.id = 'hv-gear-btn';
bar.style.cssText = css({
display: 'inline-flex', gap: '4px', margin: '2px 0',
});
// Analyze button
const analyzeBtn = document.createElement('button');
analyzeBtn.textContent = '🔍 Analyze Gear';
analyzeBtn.style.cssText = css({
padding: '3px 8px', background: '#2a2a4a', color: '#fff',
border: '1px solid #555', borderRadius: '3px', cursor: 'pointer',
fontSize: '11px', fontFamily: 'monospace',
});
analyzeBtn.onclick = () => {
autoScrapeGear();
setTimeout(buildGearPanel, 300);
};
bar.appendChild(analyzeBtn);
// Rescan button (clears buy data then rescrapes)
const rescanBtn = document.createElement('button');
rescanBtn.textContent = '🔄 Clear Buy + Rescan';
rescanBtn.style.cssText = css({
padding: '3px 8px', background: '#3a2a2a', color: '#fff',
border: '1px solid #555', borderRadius: '3px', cursor: 'pointer',
fontSize: '11px', fontFamily: 'monospace',
});
rescanBtn.onclick = () => {
// Clear old buy data
const db = getGearDB();
saveGearDB(db.filter(e => e.source !== 'buy'));
console.log('%c[HV] 🧹 Cleared old buy data', 'color:#f80');
// Rescan current page
const scraped = autoScrapeGear();
const count = scraped.length || 0;
console.log(`%c[HV] 🔄 Rescanned: ${count} items`, 'color:#0f0');
// If on buy page, also scan store after short delay
if (isBuy) {
setTimeout(() => {
autoScrapeGear();
buildGearPanel();
}, 500);
} else {
setTimeout(buildGearPanel, 300);
}
};
bar.appendChild(rescanBtn);
// Insert into the page (next to the armory toolbar if present)
const equiplist = document.getElementById('equiplist');
const container = document.createElement('div');
container.style.cssText = 'margin-bottom:4px';
container.appendChild(bar);
equiplist.parentNode.insertBefore(container, equiplist);
console.log('%c[HV] 🛡️ Gear analysis enabled — click 🔍 or 🔄', 'color:#0f0');
}

View file

@ -296,6 +296,33 @@ function scrapeBuyPage() {
saveGearDB([...existing, ...scraped]);
console.log(`%c[HV] 🛒 Buy: ${scraped.length} items (${scraped.filter(s => s.stats).length} with stats)`, 'color:#0f0');
}
// Second, scan the full store for items NOT in the visible list
// The Purchase page loads ALL items into dynjs_eqstore regardless of
// the filter tab — one visit captures the whole store.
const store = getHVEquipStore();
if (store) {
const visibleIds = new Set(scraped.map(s => s.id));
const missing = Object.entries(store).filter(([id, data]) => !visibleIds.has(id) && data && (data.t || data.d));
if (missing.length > 0) {
missing.forEach(([id, data]) => {
const stats = data.d ? parseEquipHTML(data.d) : null;
let category = 'All Items';
if (stats && stats.type) {
if (stats.type.includes('Cloth')) category = 'Cloth Armor';
else if (stats.type.includes('Light')) category = 'Light Armor';
else if (stats.type.includes('Heavy')) category = 'Heavy Armor';
else if (stats.type.includes('Shield')) category = 'Shield';
else if (stats.type.includes('Staff')) category = 'Staff';
else category = 'Weapon';
}
scraped.push({ id, name: data.t || 'Unknown', category, quality: data.q, stats, source: 'buy', scrapedAt: Date.now() });
});
const db2 = getGearDB();
saveGearDB([...db2.filter(e => e.source !== 'buy'), ...scraped]);
console.log(`%c[HV] 🛒 Buy: +${missing.length} from store (total ${scraped.length})`, 'color:#0f0');
}
}
return scraped;
}

View file

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

View file

@ -25,7 +25,7 @@ function init() {
if (STATE.page === 'arena') { autoCheckTask('arenas'); autoCheckTask('first-blood'); }
if (STATE.page === 'character') showGuidancePanel();
if (STATE.page === 'settings') enhanceSettings();
if (STATE.page === 'armory') enhanceArmory();
if (STATE.page === 'armory') { enhanceArmory(); enhanceGearAnalysis(); }
if (STATE.page === 'abilities') enhanceAbilities();
if (STATE.page === 'monster') enhanceMonsterLab();
}

View file

@ -26,4 +26,8 @@ window.HV = {
scrapeGear: () => autoScrapeGear(),
gearDebug: () => debugScrapeGear(),
gearStore: () => { const s = getHVEquipStore(); return s ? `Loaded: ${Object.keys(s).length} items` : 'Not loaded yet'; },
// Gear analysis
analyzeGear: () => analyzeGear(),
gearPanel: () => buildGearPanel(),
clearBuy: () => { const db = getGearDB(); saveGearDB(db.filter(e => e.source !== 'buy')); return 'Buy data cleared'; },
};