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).
416 lines
17 KiB
JavaScript
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');
|
|
}
|