/**
* 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 `
`
}).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 = `
💥
${times > 1 ? `连续击杀 ${times} 次完成!` : `${bossText} 倒下了!`}
获得 ${dropCount} 件掉落战利品 (${questText}, 用时 ${durationMs}ms)
`
}
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 `
${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()
}
}