diablo2-web/src/boss.ts

659 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II Act Boss Loot Simulator (Issue #110).
*
* Implements:
* - Full Act 1-5 Boss selection (Andariel, Duriel, Mephisto, Diablo, Baal) across 3 difficulties.
* - Player count (1-8 Players / PP) scaling on NoDrop.
* - Magic Find (0-1000% MF) quality scaling with diminishing returns.
* - Quest Kill (First kill / Bug kill) TreasureClass toggle.
* - Complete 1.13c drop execution with fully rolled affixes and item properties.
* - Canonical D2 item tooltip presentation with equipment stats, blue affix mods, runes, and sets.
*/
import { getEmbeddedDropTables } from './game/embedded-drop-tables.ts'
import { executeDropPipeline, type DropTables } from './game/drop-pipeline.ts'
import { formatItemTooltip, type FormattedItemTooltip } from './game/item-tooltip.ts'
import { getTooltipLocale, setTooltipLocale, type TooltipLocale } from './game/tooltip-i18n.ts'
import type { Item } from './game/items.ts'
import { computeScaledNoDrop, computeEffectivePlayers } from './game/treasure-engine.ts'
export type Difficulty = 'normal' | 'nightmare' | 'hell'
export interface BossPreset {
readonly id: string
readonly act: 1 | 2 | 3 | 4 | 5
readonly nameCn: string
readonly nameEn: string
readonly titleCn: string
readonly areaCn: string
readonly icon: string
readonly mlvl: { normal: number; nightmare: number; hell: number }
readonly tc: { normal: string; nightmare: string; hell: string }
readonly questTc: { normal: string; nightmare: string; hell: string }
readonly baseNoDrop: number
readonly picks: number
readonly monsterType?: number
readonly descCn: string
readonly highlightDrops: string
}
export type BossDefinition = BossPreset
export const ACT_BOSSES: readonly BossPreset[] = [
{
id: 'andariel',
act: 1,
nameCn: '安达利尔 (Andariel)',
nameEn: 'Maiden of Anguish',
titleCn: '第一幕 · 痛苦之貌',
areaCn: '地下墓穴第四层 (Catacombs Level 4)',
icon: '🕷️',
mlvl: { normal: 12, nightmare: 49, hell: 75 },
tc: { normal: 'Andariel', nightmare: 'Andariel (N)', hell: 'Andariel (H)' },
questTc: { normal: 'Andarielq', nightmare: 'Andarielq (N)', hell: 'Andarielq (H)' },
baseNoDrop: 19,
picks: 7,
monsterType: 4,
descCn: '第一幕关底首领,弱火(-50%火抗)。暗黑2历史上著名的Bug首杀核心首领,乔丹之石 (SOJ) 掉率全游戏最高!',
highlightDrops: '乔丹之石 (SOJ)、暗金戒指/项链、海蛇皮甲',
},
{
id: 'duriel',
act: 2,
nameCn: '督瑞尔 (Duriel)',
nameEn: 'Lord of Pain',
titleCn: '第二幕 · 苦痛之王',
areaCn: '塔·拉夏的古墓 (Tal Rasha\'s Chamber)',
icon: '🐛',
mlvl: { normal: 22, nightmare: 55, hell: 88 },
tc: { normal: 'Duriel', nightmare: 'Duriel (N)', hell: 'Duriel (H)' },
questTc: { normal: 'Durielq', nightmare: 'Durielq (N)', hell: 'Durielq (H)' },
baseNoDrop: 19,
picks: 7,
monsterType: 4,
descCn: '第二幕关底首领,神圣冰冻光环。回城卷轴掉落特化,首杀可获得丰富的中级暗金装备与首饰。',
highlightDrops: '督瑞尔的壳、暗金首饰、高级符文',
},
{
id: 'mephisto',
act: 3,
nameCn: '墨菲斯托 (Mephisto)',
nameEn: 'Lord of Hatred',
titleCn: '第三幕 · 憎恨之王',
areaCn: '憎恨囚牢第三层 (Durance of Hate Level 3)',
icon: '💀',
mlvl: { normal: 26, nightmare: 59, hell: 87 },
tc: { normal: 'Mephisto', nightmare: 'Mephisto (N)', hell: 'Mephisto (H)' },
questTc: { normal: 'Mephistoq', nightmare: 'Mephistoq (N)', hell: 'Mephistoq (H)' },
baseNoDrop: 15,
picks: 7,
monsterType: 4,
descCn: '第三幕关底首领,全暗黑2玩家公认最受爱戴的「劳模」。地形隔河击杀简单,全游戏刷军帽、眼球、暴风盾等核心日用品效率之王!',
highlightDrops: '军帽 (Shako)、暴风之盾、眼球、海蛇皮、亚瑞特的面容',
},
{
id: 'diablo',
act: 4,
nameCn: '暗黑破坏神 (Diablo)',
nameEn: 'Lord of Terror',
titleCn: '第四幕 · 恐惧之王',
areaCn: '混沌避难所 (Chaos Sanctuary)',
icon: '👹',
mlvl: { normal: 40, nightmare: 62, hell: 94 },
tc: { normal: 'Diablo', nightmare: 'Diablo (N)', hell: 'Diablo (H)' },
questTc: { normal: 'Diabloq', nightmare: 'Diabloq (N)', hell: 'Diabloq (H)' },
baseNoDrop: 15,
picks: 7,
monsterType: 4,
descCn: '第四幕关底首领,红色闪电地狱火。mlvl高达94,能掉落大君之怒、马拉的万花筒等全系列高级暗金护身符与首饰。',
highlightDrops: '马拉的万花筒、大君之怒、丧钟、风之锤',
},
{
id: 'baal',
act: 5,
nameCn: '巴尔 (Baal)',
nameEn: 'Lord of Destruction',
titleCn: '第五幕 · 毁灭之王',
areaCn: '世界之石大厅 (Worldstone Chamber)',
icon: '👑',
mlvl: { normal: 60, nightmare: 75, hell: 99 },
tc: { normal: 'Baal', nightmare: 'Baal (N)', hell: 'Baal (H)' },
questTc: { normal: 'Baalq', nightmare: 'Baalq (N)', hell: 'Baalq (H)' },
baseNoDrop: 15,
picks: 7,
monsterType: 4,
descCn: '终局最高首领,地狱难度怪物等级 mlvl 99!能掉落全暗黑2所有最高等级的宝藏(tc87/qlvl85全部封顶物品:风之力、泰瑞尔的力量、祖父等)。',
highlightDrops: '风之力 (Windforce)、泰瑞尔的力量、祖父、死亡之网、全符文',
},
]
export const BOSS_LIST: readonly BossDefinition[] = ACT_BOSSES
// State
let dropTables: DropTables | null = null
let currentBoss: BossPreset = ACT_BOSSES[2]! // Default: Mephisto
let currentDifficulty: Difficulty = 'hell'
let isQuestKill: boolean = true
let currentPlayers: number = 1
let currentMf: number = 250
interface DropSessionRecord {
readonly killIndex: number
readonly timestamp: number
readonly boss: BossPreset
readonly difficulty: Difficulty
readonly isQuest: boolean
readonly players: number
readonly mf: number
readonly items: Item[]
}
const sessionHistory: DropSessionRecord[] = []
let latestDroppedItems: Item[] = []
let totalKills = 0
let totalItemsDropped = 0
let totalUniques = 0
let totalSets = 0
let totalRares = 0
let totalMagics = 0
let totalRunes = 0
// Initialize
export function initBossApp(): void {
// 1. Initialize Drop Tables
try {
dropTables = getEmbeddedDropTables()
console.log('Diablo II Drop Tables loaded successfully from canonical data.')
} catch (err) {
console.error('Failed to load drop tables:', err)
}
// 2. Bind DOM Events
bindBossSelector()
bindDifficultySelector()
bindSettings()
bindActionButtons()
bindLanguageSelector()
bindKeyboardShortcuts()
// 3. Initial UI update
updateBossCard()
updateParamTips()
renderLootShowcase([])
}
function bindBossSelector(): void {
const container = document.getElementById('boss-list-container')
if (!container) return
container.innerHTML = ACT_BOSSES.map(b => {
const isSelected = b.id === currentBoss.id
return `
<div class="boss-select-card ${isSelected ? 'selected' : ''}" data-boss-id="${b.id}">
<div class="boss-card-icon">${b.icon}</div>
<div class="boss-card-info">
<div class="boss-card-title">${b.nameCn}</div>
<div class="boss-card-sub">${b.titleCn}</div>
</div>
</div>
`
}).join('')
container.querySelectorAll('.boss-select-card').forEach(el => {
el.addEventListener('click', () => {
const id = el.getAttribute('data-boss-id')
const found = ACT_BOSSES.find(b => b.id === id)
if (found) {
currentBoss = found
container.querySelectorAll('.boss-select-card').forEach(c => c.classList.remove('selected'))
el.classList.add('selected')
updateBossCard()
updateParamTips()
}
})
})
}
function bindDifficultySelector(): void {
const pills = document.querySelectorAll<HTMLButtonElement>('.diff-pill')
pills.forEach(pill => {
pill.addEventListener('click', () => {
pills.forEach(p => p.classList.remove('active'))
pill.classList.add('active')
currentDifficulty = (pill.getAttribute('data-diff') as Difficulty) || 'hell'
updateBossCard()
updateParamTips()
})
})
}
function bindSettings(): void {
// Quest Kill checkbox
const questCheck = document.getElementById('quest-kill-toggle') as HTMLInputElement | null
if (questCheck) {
questCheck.checked = isQuestKill
questCheck.addEventListener('change', () => {
isQuestKill = questCheck.checked
updateBossCard()
updateParamTips()
})
}
// Players slider & pills
const playersRange = document.getElementById('players-range') as HTMLInputElement | null
const playersVal = document.getElementById('players-val')
if (playersRange && playersVal) {
playersRange.value = String(currentPlayers)
playersVal.textContent = `${currentPlayers}P`
playersRange.addEventListener('input', () => {
currentPlayers = Number(playersRange.value)
playersVal.textContent = `${currentPlayers}P`
updatePlayersPillActive(currentPlayers)
updateParamTips()
})
}
document.querySelectorAll<HTMLButtonElement>('.pp-pill').forEach(btn => {
btn.addEventListener('click', () => {
const p = Number(btn.getAttribute('data-pp'))
if (p >= 1 && p <= 8) {
currentPlayers = p
if (playersRange) playersRange.value = String(p)
if (playersVal) playersVal.textContent = `${p}P`
updatePlayersPillActive(p)
updateParamTips()
}
})
})
// MF slider & input
const mfRange = document.getElementById('mf-range') as HTMLInputElement | null
const mfInput = document.getElementById('mf-input') as HTMLInputElement | null
if (mfRange && mfInput) {
mfRange.value = String(currentMf)
mfInput.value = String(currentMf)
mfRange.addEventListener('input', () => {
currentMf = Math.max(0, Math.min(1000, Number(mfRange.value)))
mfInput.value = String(currentMf)
updateParamTips()
})
mfInput.addEventListener('change', () => {
currentMf = Math.max(0, Math.min(1000, Number(mfInput.value) || 0))
mfRange.value = String(currentMf)
mfInput.value = String(currentMf)
updateParamTips()
})
}
// MF quick presets
document.querySelectorAll<HTMLButtonElement>('.mf-preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const v = Number(btn.getAttribute('data-mf'))
currentMf = v
if (mfRange) mfRange.value = String(v)
if (mfInput) mfInput.value = String(v)
updateParamTips()
})
})
}
function updatePlayersPillActive(p: number): void {
document.querySelectorAll<HTMLButtonElement>('.pp-pill').forEach(btn => {
btn.classList.toggle('active', Number(btn.getAttribute('data-pp')) === p)
})
}
function updateBossCard(): void {
const iconEl = document.getElementById('boss-preview-icon')
const nameEl = document.getElementById('boss-preview-name')
const titleEl = document.getElementById('boss-preview-title')
const areaEl = document.getElementById('boss-preview-area')
const mlvlEl = document.getElementById('boss-preview-mlvl')
const tcEl = document.getElementById('boss-preview-tc')
const questEl = document.getElementById('boss-preview-quest')
const descEl = document.getElementById('boss-preview-desc')
const dropsEl = document.getElementById('boss-preview-highlights')
const mlvl = currentBoss.mlvl[currentDifficulty]
const effectiveTc = isQuestKill ? currentBoss.questTc[currentDifficulty] : currentBoss.tc[currentDifficulty]
if (iconEl) iconEl.textContent = currentBoss.icon
if (nameEl) nameEl.textContent = currentBoss.nameCn
if (titleEl) titleEl.textContent = `${currentBoss.titleCn} · [${currentDifficulty.toUpperCase()}]`
if (areaEl) areaEl.textContent = currentBoss.areaCn
if (mlvlEl) mlvlEl.textContent = `mlvl ${mlvl}`
if (tcEl) tcEl.textContent = `TC: ${effectiveTc}`
if (questEl) {
questEl.textContent = isQuestKill ? '🌟 首杀判定启用 (Quest Kill)' : '常规击杀 (Standard Kill)'
questEl.className = isQuestKill ? 'boss-quest-badge quest-on' : 'boss-quest-badge quest-off'
}
if (descEl) descEl.textContent = currentBoss.descCn
if (dropsEl) dropsEl.textContent = currentBoss.highlightDrops
}
function updateParamTips(): void {
const nodropTip = document.getElementById('tip-nodrop')
const mfTip = document.getElementById('tip-mf')
const effectivePlayers = computeEffectivePlayers(currentPlayers, 1)
const scaledNoDrop = computeScaledNoDrop(currentBoss.baseNoDrop, 65, effectivePlayers)
const nodropPercent = ((scaledNoDrop / (scaledNoDrop + 65)) * 100).toFixed(1)
if (nodropTip) {
nodropTip.textContent = `${currentPlayers}P 模式下有效玩家数 ${effectivePlayers.toFixed(1)},单次挑选无掉落率: ${nodropPercent}%`
}
if (mfTip) {
// 1.13c Diminishing returns: Unique = (MF * 250) / (MF + 250)
const effectiveUniqueMf = Math.trunc((currentMf * 250) / (currentMf + 250))
const effectiveRareMf = Math.trunc((currentMf * 600) / (currentMf + 600))
mfTip.textContent = `实际寻宝率 ${currentMf}% · 暗金等效: +${effectiveUniqueMf}% · 黄金等效: +${effectiveRareMf}%`
}
}
function bindActionButtons(): void {
const killBtn = document.getElementById('btn-kill-boss')
const kill10Btn = document.getElementById('btn-kill-10')
const clearBtn = document.getElementById('btn-clear-loot')
if (killBtn) {
killBtn.addEventListener('click', () => {
executeBossKill(1)
})
}
if (kill10Btn) {
kill10Btn.addEventListener('click', () => {
executeBossKill(10)
})
}
if (clearBtn) {
clearBtn.addEventListener('click', () => {
latestDroppedItems = []
renderLootShowcase([])
})
}
}
function bindLanguageSelector(): void {
const pills = document.querySelectorAll<HTMLButtonElement>('.lang-pill')
pills.forEach(pill => {
pill.addEventListener('click', () => {
const lang = pill.dataset.lang as TooltipLocale | undefined
if (!lang) return
setTooltipLocale(lang)
pills.forEach(p => p.classList.toggle('active', p === pill))
renderLootShowcase(latestDroppedItems)
})
})
}
function bindKeyboardShortcuts(): void {
window.addEventListener('keydown', (e: KeyboardEvent) => {
// Don't trigger if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault()
executeBossKill(1)
}
})
}
// Boss Kill Execution
export function executeBossKill(times: number = 1): void {
if (!dropTables) {
alert('掉落数据表仍在加载中,请稍候...')
return
}
const effectiveTc = isQuestKill ? currentBoss.questTc[currentDifficulty] : currentBoss.tc[currentDifficulty]
const nLevel = currentBoss.mlvl[currentDifficulty]
const batchItems: Item[] = []
const startTime = performance.now()
for (let i = 0; i < times; i++) {
const drops = executeDropPipeline({
tcName: effectiveTc,
nLevel,
monsterType: currentBoss.monsterType ?? 4,
dropTables,
difficulty: currentDifficulty,
playerMf: currentMf,
gamePlayers: currentPlayers,
partyPlayers: 1,
})
totalKills += 1
totalItemsDropped += drops.length
for (const item of drops) {
const q = (item.rarity || 'normal').toLowerCase()
if (q === 'unique') totalUniques++
else if (q === 'set') totalSets++
else if (q === 'rare') totalRares++
else if (q === 'magic') totalMagics++
else if (item.code?.trim().startsWith('r')) totalRunes++
batchItems.push(item)
}
sessionHistory.push({
killIndex: totalKills,
timestamp: Date.now(),
boss: currentBoss,
difficulty: currentDifficulty,
isQuest: isQuestKill,
players: currentPlayers,
mf: currentMf,
items: drops,
})
}
const durationMs = (performance.now() - startTime).toFixed(1)
// Trigger Animation
flashKillEffect()
// Update Stats & UI
updateStatsDisplay()
renderKillBanner(times, batchItems.length, durationMs)
renderLootShowcase(batchItems)
}
function flashKillEffect(): void {
const showcase = document.getElementById('loot-showcase-panel')
if (showcase) {
showcase.classList.remove('kill-flash')
void showcase.offsetWidth // force reflow
showcase.classList.add('kill-flash')
}
}
function updateStatsDisplay(): void {
const killsEl = document.getElementById('stat-total-kills')
const itemsEl = document.getElementById('stat-total-items')
const uniqEl = document.getElementById('stat-uniques')
const setEl = document.getElementById('stat-sets')
const rareEl = document.getElementById('stat-rares')
const magicEl = document.getElementById('stat-magics')
const runeEl = document.getElementById('stat-runes')
if (killsEl) killsEl.textContent = totalKills.toLocaleString()
if (itemsEl) itemsEl.textContent = totalItemsDropped.toLocaleString()
if (uniqEl) uniqEl.textContent = totalUniques.toLocaleString()
if (setEl) setEl.textContent = totalSets.toLocaleString()
if (rareEl) rareEl.textContent = totalRares.toLocaleString()
if (magicEl) magicEl.textContent = totalMagics.toLocaleString()
if (runeEl) runeEl.textContent = totalRunes.toLocaleString()
}
function renderKillBanner(times: number, dropCount: number, durationMs: string): void {
const banner = document.getElementById('kill-result-banner')
if (!banner) return
const diffNames = { normal: '普通', nightmare: '噩梦', hell: '地狱' }
const bossText = `${diffNames[currentDifficulty]}难度 · ${currentBoss.nameCn}`
const questText = isQuestKill ? '🌟 任务首杀' : '常规击杀'
banner.innerHTML = `
<div class="banner-main">
<span class="banner-icon">💥</span>
<span class="banner-title">${times > 1 ? `连续击杀 ${times} 次完成!` : `${bossText} 倒下了!`}</span>
<span class="banner-sub">获得 <strong>${dropCount}</strong> 件掉落战利品 (${questText}, 用时 ${durationMs}ms)</span>
</div>
`
}
function renderLootShowcase(items: Item[]): void {
latestDroppedItems = items
const container = document.getElementById('loot-items-grid')
if (!container) return
const locale = getTooltipLocale()
if (items.length === 0) {
const isZh = locale === 'zh' || locale === 'bilingual'
container.innerHTML = `
<div class="empty-loot-placeholder">
<div class="placeholder-icon">⚔️</div>
<div class="placeholder-title">${isZh ? '尚未击杀首领' : 'No Boss Slain Yet'}</div>
<div class="placeholder-desc">${isZh ? '点击左侧「模拟击杀 Boss」或按下 [空格键] 体验 1.13c 真实掉落与词缀属性生成' : 'Click "Kill Boss" on the left sidebar or press [Space] to simulate authentic 1.13c drops & affixes.'}</div>
</div>
`
return
}
// Render Item Cards
container.innerHTML = items.map((item, idx) => {
const tooltip = formatItemTooltip(item, locale)
return renderSingleItemCard(tooltip, idx + 1, locale)
}).join('')
}
function renderSingleItemCard(t: FormattedItemTooltip, index: number, locale: TooltipLocale = getTooltipLocale()): string {
const linesHtml = t.lines.map(l => {
const color = l.color || 'blue'
const colorClass = color === 'instruction' ? 'line-white line-instruction' : `line-${color}`
return `<div class="tooltip-stat-line ${colorClass}">${escapeHtml(l.text)}</div>`
}).join('')
const isZh = locale === 'zh' || locale === 'bilingual'
const lblDefense = isZh ? '防御: ' : 'Defense: '
const lblOneHand = isZh ? '单手伤害: ' : 'One-Hand Damage: '
const lblTwoHand = isZh ? '双手伤害: ' : 'Two-Hand Damage: '
const lblDurability = isZh ? '耐久度: ' : 'Durability: '
const lblReqLevel = isZh ? '需要等级: ' : 'Required Level: '
const lblReqStr = isZh ? '需要力量: ' : 'Required Strength: '
const lblReqDex = isZh ? '需要敏捷: ' : 'Required Dexterity: '
const wordOf = isZh ? ' / ' : ' of '
const wordTo = isZh ? ' 至 ' : ' to '
const baseStatsHtml: string[] = []
if (t.defense !== undefined) {
const defText =
t.baseDefense !== undefined && t.baseDefense !== t.defense
? `<span class="val-blue">${t.defense}</span> <span class="val-base">(${t.baseDefense})</span>`
: `<span class="val-white">${t.defense}</span>`
baseStatsHtml.push(`<div class="base-stat-item">${lblDefense}${defText}</div>`)
}
if (t.oneHandDamage) {
const d = t.oneHandDamage
const dmgText =
d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined
? `<span class="val-blue">${d.min}${wordTo}${d.max}</span> <span class="val-base">(${d.baseMin}${wordTo}${d.baseMax})</span>`
: `<span class="val-white">${d.min}${wordTo}${d.max}</span>`
baseStatsHtml.push(`<div class="base-stat-item">${lblOneHand}${dmgText}</div>`)
}
if (t.twoHandDamage) {
const d = t.twoHandDamage
const dmgText =
d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined
? `<span class="val-blue">${d.min}${wordTo}${d.max}</span> <span class="val-base">(${d.baseMin}${wordTo}${d.baseMax})</span>`
: `<span class="val-white">${d.min}${wordTo}${d.max}</span>`
baseStatsHtml.push(`<div class="base-stat-item">${lblTwoHand}${dmgText}</div>`)
}
if (t.durability) {
const dur = t.durability
const durText =
dur.isEnhanced && dur.baseMax !== undefined
? `<span class="val-blue">${dur.current}${wordOf}${dur.max}</span> <span class="val-base">(${dur.baseMax})</span>`
: `<span class="val-white">${dur.current}${wordOf}${dur.max}</span>`
baseStatsHtml.push(`<div class="base-stat-item">${lblDurability}${durText}</div>`)
}
if (t.reqLevel !== undefined && !t.isSocketable) {
const lvlText =
t.baseReqLevel !== undefined && t.baseReqLevel !== t.reqLevel
? `<span class="val-blue">${t.reqLevel}</span> <span class="val-base">(${t.baseReqLevel})</span>`
: `<span class="val-white">${t.reqLevel}</span>`
baseStatsHtml.push(`<div class="base-stat-item req">${lblReqLevel}${lvlText}</div>`)
}
if (t.reqStr !== undefined) {
const strText =
t.baseReqStr !== undefined && t.baseReqStr !== t.reqStr
? `<span class="val-blue">${t.reqStr}</span> <span class="val-base">(${t.baseReqStr})</span>`
: `<span class="val-white">${t.reqStr}</span>`
baseStatsHtml.push(`<div class="base-stat-item req">${lblReqStr}${strText}</div>`)
}
if (t.reqDex !== undefined) {
const dexText =
t.baseReqDex !== undefined && t.baseReqDex !== t.reqDex
? `<span class="val-blue">${t.reqDex}</span> <span class="val-base">(${t.baseReqDex})</span>`
: `<span class="val-white">${t.reqDex}</span>`
baseStatsHtml.push(`<div class="base-stat-item req">${lblReqDex}${dexText}</div>`)
}
const qLower = t.quality.toLowerCase()
const qualityClass = t.isSocketable
? (t.qualityColor === '#ff8c00' ? 'quality-rune' : 'quality-gem')
: `quality-${qLower}`
return `
<div class="d2-item-card ${qualityClass} quality-${escapeHtml(qLower)}">
<div class="item-card-header">
<div class="item-title" style="color: ${t.qualityColor};">${escapeHtml(t.title)}</div>
${t.subTitle ? `<div class="item-subtitle">${escapeHtml(t.subTitle)}</div>` : ''}
<div class="item-tags">
<span class="item-quality-pill" style="border-color: ${t.qualityColor}; color: ${t.qualityColor};">${escapeHtml(t.quality.toUpperCase())}</span>
<span class="item-ilvl-pill">ilvl ${t.ilvl}</span>
</div>
</div>
${baseStatsHtml.length > 0 ? `
<div class="item-base-stats">
${baseStatsHtml.join('')}
</div>
` : ''}
${t.lines.length > 0 ? `
<div class="item-affix-lines">
${linesHtml}
</div>
` : ''}
</div>
`
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// Auto-run on DOM ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initBossApp())
} else {
initBossApp()
}
}