/** * 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 `
${b.icon}
${b.nameCn}
${b.titleCn}
` }).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('.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('.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('.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('.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('.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 = ` ` } 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 = `
⚔️
${isZh ? '尚未击杀首领' : 'No Boss Slain Yet'}
${isZh ? '点击左侧「模拟击杀 Boss」或按下 [空格键] 体验 1.13c 真实掉落与词缀属性生成' : 'Click "Kill Boss" on the left sidebar or press [Space] to simulate authentic 1.13c drops & affixes.'}
` 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 `
${escapeHtml(l.text)}
` }).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 ? `${t.defense} (${t.baseDefense})` : `${t.defense}` baseStatsHtml.push(`
${lblDefense}${defText}
`) } if (t.oneHandDamage) { const d = t.oneHandDamage const dmgText = d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined ? `${d.min}${wordTo}${d.max} (${d.baseMin}${wordTo}${d.baseMax})` : `${d.min}${wordTo}${d.max}` baseStatsHtml.push(`
${lblOneHand}${dmgText}
`) } if (t.twoHandDamage) { const d = t.twoHandDamage const dmgText = d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined ? `${d.min}${wordTo}${d.max} (${d.baseMin}${wordTo}${d.baseMax})` : `${d.min}${wordTo}${d.max}` baseStatsHtml.push(`
${lblTwoHand}${dmgText}
`) } if (t.durability) { const dur = t.durability const durText = dur.isEnhanced && dur.baseMax !== undefined ? `${dur.current}${wordOf}${dur.max} (${dur.baseMax})` : `${dur.current}${wordOf}${dur.max}` baseStatsHtml.push(`
${lblDurability}${durText}
`) } if (t.reqLevel !== undefined && !t.isSocketable) { const lvlText = t.baseReqLevel !== undefined && t.baseReqLevel !== t.reqLevel ? `${t.reqLevel} (${t.baseReqLevel})` : `${t.reqLevel}` baseStatsHtml.push(`
${lblReqLevel}${lvlText}
`) } if (t.reqStr !== undefined) { const strText = t.baseReqStr !== undefined && t.baseReqStr !== t.reqStr ? `${t.reqStr} (${t.baseReqStr})` : `${t.reqStr}` baseStatsHtml.push(`
${lblReqStr}${strText}
`) } if (t.reqDex !== undefined) { const dexText = t.baseReqDex !== undefined && t.baseReqDex !== t.reqDex ? `${t.reqDex} (${t.baseReqDex})` : `${t.reqDex}` baseStatsHtml.push(`
${lblReqDex}${dexText}
`) } const qLower = t.quality.toLowerCase() const qualityClass = t.isSocketable ? (t.qualityColor === '#ff8c00' ? 'quality-rune' : 'quality-gem') : `quality-${qLower}` return `
${escapeHtml(t.title)}
${t.subTitle ? `
${escapeHtml(t.subTitle)}
` : ''}
${escapeHtml(t.quality.toUpperCase())} ilvl ${t.ilvl}
${baseStatsHtml.length > 0 ? `
${baseStatsHtml.join('')}
` : ''} ${t.lines.length > 0 ? `
${linesHtml}
` : ''}
` } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } // Auto-run on DOM ready if (typeof document !== 'undefined') { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => initBossApp()) } else { initBossApp() } }