// ═══════════════════════════════════════════════════════════════════════ // 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) { // 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'); }