hv-unified/src/gear-analysis.js
GaboGG f993014a46 v0.15.3 - Scorer now accounts for weapon damage, elemental damage, and procs
Parser fix: parseEquipHTML now captures the direct-child divs of the
tooltip that were previously skipped:
  - Proc lines: 'Penetrated Armor: 21.7% chance' -> stats['Proc Penetrated Armor']
  - Base damage: '+708 Piercing Damage' -> stats['Weapon Damage']
  - Elemental lines: '+25 Fire Damage' / '+30 Elec Strike' -> stats['Elemental Fire']

Scorer (gearScoreWeapon + analyze_gear.py):
  - Weapon Damage x 0.30 (base damage matters)
  - Elemental damage x 0.25 per element
  - Proc bonuses by type: CC procs (stun/freeze) x0.45, armor break
    x0.35, DoT x0.25, generic x0.15
  - All multiplied by the same quality multiplier afterward

So an elemental weapon with a strike proc now gets its due — the
short-margin stat winner may no longer win once damage+proc count.
Note: re-scrape the armory/store after updating so the new fields
get captured (old entries lack Weapon Damage/Proc keys).
2026-08-03 16:00:59 -04:00

416 lines
17 KiB
JavaScript

// ═══════════════════════════════════════════════════════════════════════
// 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;
// Base weapon damage (e.g. "+708 Piercing Damage") — scales with quality
s += _num(st['Weapon Damage']) * 0.30;
// Elemental damage lines (e.g. "+25 Fire Damage", "+30 Elec Strike")
['Fire', 'Cold', 'Elec', 'Wind', 'Holy', 'Dark', 'Ethereal', 'Elemental'].forEach(el => {
s += _num(st['Elemental ' + el]) * 0.25;
});
// Proc lines: elemental/status procs add value (e.g. "Proc Penetrated Armor: 21.7%")
let procBonus = 0;
Object.keys(st).forEach(k => {
if (!k.startsWith('Proc ')) return;
const pct = _num(st[k]);
const pname = k.replace('Proc ', '').toLowerCase();
if (pname.includes('penetrated armor') || pname.includes('armor')) procBonus += pct * 0.35;
else if (pname.includes('stun') || pname.includes('freeze') || pname.includes('slow') || pname.includes('paraly')) procBonus += pct * 0.45;
else if (pname.includes('bleeding') || pname.includes('wound') || pname.includes('poison') || pname.includes('burn') || pname.includes('shock')) procBonus += pct * 0.25;
else if (pname.includes('weaken') || pname.includes('impair')) procBonus += pct * 0.25;
else procBonus += pct * 0.15; // generic proc value
});
s += procBonus;
// 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) {
// Word-boundary match: prevents 'gi' matching inside 'leggings',
// 'cap' inside 'capacity', etc.
if (new RegExp('\\b' + kw + '\\b').test(name)) 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() {
// 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+)/);
if (m) return parseInt(m[1]);
}
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();
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();
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'];
slots.forEach(slot => {
const current = slotMap[slot];
const cands = [];
const consider = (item, source) => {
if (!item || !item.stats) return;
const isWeapon = slot === 'Mainhand';
if (isWeapon) {
if (!gearIsTwoHandWeapon(item)) return;
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 });
};
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, filter, 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);
// ── 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 }) => {
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 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 ${eqScore.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() {
// Works on Bazaar pages (equiplist) AND equipment pages (eqsb)
const equiplist = document.getElementById('equiplist');
const eqsb = document.getElementById('eqsb');
if (!equiplist && !eqsb) return;
if (document.getElementById('hv-gear-btn')) return;
const url = window.location.href || '';
const isBuy = url.includes('screen=purchase') || url.includes('ss=bi');
// On equipment pages, only show the Analyze button (no rescan — no store data there)
const isEquipPage = !!eqsb && !equiplist;
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.type = 'button'; // CRITICAL: prevent form submit (inside armory form)
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 = () => {
try {
autoScrapeGear();
} catch (e) { console.error('[HV] scrape failed:', e); }
setTimeout(() => {
try { buildGearPanel(); }
catch (e) { console.error('[HV] gear panel failed:', e); }
}, 300);
};
bar.appendChild(analyzeBtn);
// Rescan button (clears buy data then rescrapes)
const rescanBtn = document.createElement('button');
rescanBtn.type = 'button'; // CRITICAL: prevent form submit (inside armory form)
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);
if (isEquipPage) rescanBtn.style.display = 'none';
// Insert into the page — before equiplist (Bazaar) or after eqsb (equipment page)
const container = document.createElement('div');
container.style.cssText = 'margin-bottom:4px';
container.appendChild(bar);
if (equiplist) {
equiplist.parentNode.insertBefore(container, equiplist);
} else if (eqsb) {
eqsb.parentNode.insertBefore(container, eqsb.nextSibling);
}
console.log('%c[HV] 🛡️ Gear analysis enabled — click 🔍 or 🔄', 'color:#0f0');
}