= {
- ama: { zh: '亚马逊', en: 'Amazon' },
- sor: { zh: '法师', en: 'Sorceress' },
- nec: { zh: '死灵法师', en: 'Necromancer' },
- pal: { zh: '圣骑士', en: 'Paladin' },
- bar: { zh: '野蛮人', en: 'Barbarian' },
- dru: { zh: '德鲁伊', en: 'Druid' },
- ass: { zh: '刺客', en: 'Assassin' },
-}
-
-// UI State
-let currentClass: CharacterClassCode = 'ama'
-let currentLang: Lang = 'zh'
-let activeTooltipSkillId: number | null = null
-
-let state: SkillCalcState = {
- classCode: 'ama',
- hardPoints: {},
- tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
- allSkillsSoftPoints: 0,
- specificSoftPoints: {},
-}
-
-// DOM Elements
-const classTabsContainer = document.getElementById('class-tabs-container') as HTMLElement
-const treesContainer = document.getElementById('trees-container') as HTMLElement
-const tooltipEl = document.getElementById('skill-tooltip') as HTMLElement
-const toastEl = document.getElementById('toast') as HTMLElement
-
-// Summary Bar Elements
-const valReqLevel = document.getElementById('val-req-level') as HTMLElement
-const valPointsSpent = document.getElementById('val-points-spent') as HTMLElement
-const valPointsRemain = document.getElementById('val-points-remain') as HTMLElement
-const valAllSkills = document.getElementById('val-all-skills') as HTMLElement
-const btnAllSkillsDec = document.getElementById('btn-all-skills-dec') as HTMLButtonElement
-const btnAllSkillsInc = document.getElementById('btn-all-skills-inc') as HTMLButtonElement
-const btnRespec = document.getElementById('btn-respec') as HTMLButtonElement
-const btnCopyLink = document.getElementById('btn-copy-link') as HTMLButtonElement
-const btnLang = document.getElementById('btn-lang') as HTMLButtonElement
-
-// Static Label Elements for I18n
-const txtAppTitle = document.getElementById('txt-app-title') as HTMLElement
-const lblReqLevel = document.getElementById('lbl-req-level') as HTMLElement
-const lblPointsSpent = document.getElementById('lbl-points-spent') as HTMLElement
-const lblPointsRemain = document.getElementById('lbl-points-remain') as HTMLElement
-const lblAllSkills = document.getElementById('lbl-all-skills') as HTMLElement
-const lnkLoot = document.getElementById('lnk-loot') as HTMLAnchorElement
-const lnkBoss = document.getElementById('lnk-boss') as HTMLAnchorElement
-const lnkActs = document.getElementById('lnk-acts') as HTMLAnchorElement
-
-/**
- * Shows a brief floating toast notification.
- */
-function showToast(msg: string) {
- if (!toastEl) return
- toastEl.textContent = msg
- toastEl.classList.add('show')
- setTimeout(() => {
- toastEl.classList.remove('show')
- }, 2200)
-}
-
-/**
- * Initializes state from URL Hash or defaults.
- */
-function initFromHash() {
- const hash = window.location.hash
- if (hash) {
- const restored = deserializeBuildFromHash(hash)
- if (restored) {
- currentClass = restored.classCode
- state = restored
- return
- }
- }
- // Default fresh build
- state = {
- classCode: currentClass,
- hardPoints: {},
- tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
- allSkillsSoftPoints: 0,
- specificSoftPoints: {},
- }
-}
-
-/**
- * Syncs the state to URL hash without reloading.
- */
-function syncHash() {
- const hash = serializeBuildToHash(state)
- if (window.location.hash !== hash) {
- history.replaceState(null, '', hash)
- }
-}
-
-/**
- * Updates static labels according to the current language.
- */
-function updateStaticI18n() {
- if (currentLang === 'zh') {
- txtAppTitle.textContent = '暗黑破坏神 2 · 技能加点模拟器'
- lblReqLevel.textContent = '需求角色等级:'
- lblPointsSpent.textContent = '已用技能点:'
- lblPointsRemain.textContent = '剩余技能点:'
- lblAllSkills.textContent = '+ 全部技能:'
- btnRespec.textContent = '重置技能 (Respec)'
- btnCopyLink.textContent = '复制配置 (Share)'
- btnLang.textContent = '中 / EN'
- lnkLoot.textContent = '战利品模拟'
- lnkBoss.textContent = '首领挑战'
- lnkActs.textContent = '大地图'
- } else {
- txtAppTitle.textContent = 'Diablo II · Skill Calculator (v1.13c)'
- lblReqLevel.textContent = 'Required Level:'
- lblPointsSpent.textContent = 'Points Spent:'
- lblPointsRemain.textContent = 'Remaining Points:'
- lblAllSkills.textContent = '+ to All Skills:'
- btnRespec.textContent = 'Respec'
- btnCopyLink.textContent = 'Copy Build Link'
- btnLang.textContent = 'EN / 中'
- lnkLoot.textContent = 'Loot Sim'
- lnkBoss.textContent = 'Boss Arena'
- lnkActs.textContent = 'Campaign Map'
- }
-}
-
-/**
- * Renders the top 7 class switcher tabs.
- */
-function renderClassTabs() {
- classTabsContainer.innerHTML = ''
- for (const code of CHARACTER_CLASS_CODES) {
- const btn = document.createElement('button')
- btn.className = `class-btn ${code === currentClass ? 'active' : ''}`
- btn.textContent = currentLang === 'zh' ? CLASS_NAMES[code].zh : CLASS_NAMES[code].en
- btn.dataset.classCode = code
- btn.addEventListener('click', () => {
- if (currentClass === code) return
- currentClass = code
- state = {
- classCode: code,
- hardPoints: {},
- tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
- allSkillsSoftPoints: 0,
- specificSoftPoints: {},
- }
- syncHash()
- render()
- })
- classTabsContainer.appendChild(btn)
- }
-}
-
-/**
- * Renders the 3 skill tree panels for the active character class.
- */
-function renderTrees() {
- treesContainer.innerHTML = ''
- const tabIndices = CLASS_TAB_ORDER[currentClass]
-
- for (const tabIndex of tabIndices) {
- // Collect 10 skills for this tab
- const skillsInTab = SKILLS_CATALOG.filter(
- s => s.classCode === currentClass && s.tabIndex === tabIndex
- )
- if (skillsInTab.length === 0) continue
-
- const tabName = currentLang === 'zh' ? skillsInTab[0]!.tabNameZh : skillsInTab[0]!.tabName
- const tabSpent = getTabPointsSpent(state, tabIndex)
-
- // Tree Column container
- const colDiv = document.createElement('div')
- colDiv.className = 'tree-column'
-
- // Header
- const headerDiv = document.createElement('div')
- headerDiv.className = 'tree-header'
- headerDiv.innerHTML = `
- ${tabName}
-
- ${currentLang === 'zh' ? `该系已投入 ${tabSpent} 点` : `${tabSpent} POINTS IN THIS TREE`}
-
- `
- colDiv.appendChild(headerDiv)
-
- // Stone Tree Canvas
- const canvasDiv = document.createElement('div')
- canvasDiv.className = 'tree-canvas'
- canvasDiv.style.backgroundImage = `url('/skills/bg_${currentClass}_${tabIndex}.png')`
-
- // Render 10 skill sockets
- for (const skill of skillsInTab) {
- const socket = createSkillSocket(skill, tabIndex)
- canvasDiv.appendChild(socket)
- }
-
- colDiv.appendChild(canvasDiv)
-
- // Footer with Tree Soft Points stepper
- const footerDiv = document.createElement('div')
- footerDiv.className = 'tree-footer'
- const currentTabSoft = state.tabSoftPoints[tabIndex] ?? 0
- footerDiv.innerHTML = `
- ${currentLang === 'zh' ? `+ 该系技能:` : `+ to ${tabName}:`}
-
-
- ${currentTabSoft}
-
-
- `
-
- // Wire stepper buttons
- const btnDec = footerDiv.querySelector('.tab-soft-dec') as HTMLButtonElement
- const btnInc = footerDiv.querySelector('.tab-soft-inc') as HTMLButtonElement
- btnDec.addEventListener('click', () => {
- if ((state.tabSoftPoints[tabIndex] ?? 0) > 0) {
- state.tabSoftPoints[tabIndex]!--
- syncHash()
- render()
- if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
- }
- })
- btnInc.addEventListener('click', () => {
- if ((state.tabSoftPoints[tabIndex] ?? 0) < 99) {
- state.tabSoftPoints[tabIndex] = (state.tabSoftPoints[tabIndex] ?? 0) + 1
- syncHash()
- render()
- if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
- }
- })
-
- colDiv.appendChild(footerDiv)
- treesContainer.appendChild(colDiv)
- }
-}
-
-/**
- * Creates an individual skill socket DOM element on the 256x432 stone canvas.
- */
-function createSkillSocket(skill: SkillCatalogEntry, tabIndex: number): HTMLElement {
- const socket = document.createElement('div')
- socket.className = 'skill-socket'
-
- // Diablo II Grid layout coordinates:
- // x = 10 + (col - 1) * 72
- // y = 14 + (row - 1) * 64
- const posX = 10 + (skill.col - 1) * 72
- const posY = 14 + (skill.row - 1) * 64
- socket.style.left = `${posX}px`
- socket.style.top = `${posY}px`
- socket.dataset.skillId = String(skill.id)
-
- const hard = state.hardPoints[skill.id] ?? 0
- const eff = getEffectiveLevel(state, skill.id)
- const canAlloc = canAllocate(state, skill.id)
- const isMaxed = hard >= 20
- const isLocked = hard === 0 && !canAlloc
-
- if (isLocked) socket.classList.add('locked')
- if (hard > 0) socket.classList.add('active')
- if (isMaxed) socket.classList.add('maxed')
-
- // Icon Image
- const img = document.createElement('img')
- img.className = 'skill-icon'
- img.src = hard > 0 ? skill.iconActive : skill.icon
- img.alt = currentLang === 'zh' ? skill.nameZh : skill.name
- img.draggable = false
- socket.appendChild(img)
-
- // Point Badge Box (Positioned at right-bottom: posX + 46, posY + 26)
- const badge = document.createElement('div')
- badge.className = 'skill-pts-badge'
- badge.style.left = '46px'
- badge.style.top = '26px'
-
- if (hard > 0) {
- badge.classList.add('has-points')
- if (eff > hard) {
- badge.classList.add('has-soft-points')
- badge.textContent = String(eff)
- badge.title = `${hard} base + ${eff - hard} gear`
- } else {
- badge.textContent = String(hard)
- }
- } else {
- badge.textContent = '0'
- }
- if (isMaxed) badge.classList.add('is-maxed')
-
- socket.appendChild(badge)
-
- // Interaction: Left Click (+1 or +5)
- socket.addEventListener('click', (e: MouseEvent) => {
- e.preventDefault()
- const count = e.shiftKey ? 5 : 1
- const changed = allocatePoint(state, skill.id, count)
- if (changed) {
- syncHash()
- render()
- refreshTooltip(skill.id)
- }
- })
-
- // Interaction: Right Click (-1 or refund all)
- socket.addEventListener('contextmenu', (e: MouseEvent) => {
- e.preventDefault()
- const count = e.shiftKey ? 20 : 1
- const allowed = canDeallocate(state, skill.id)
- if (!allowed && (state.hardPoints[skill.id] ?? 0) > 0) {
- showToast(
- currentLang === 'zh'
- ? '无法撤除技能点:有后续依赖技能已加点!'
- : 'Cannot deallocate: Dependent skills require this prerequisite!'
- )
- return
- }
- const changed = deallocatePoint(state, skill.id, count)
- if (changed) {
- syncHash()
- render()
- refreshTooltip(skill.id)
- }
- })
-
- // Tooltip tracking
- socket.addEventListener('mouseenter', (e: MouseEvent) => {
- activeTooltipSkillId = skill.id
- renderTooltip(skill.id)
- positionTooltip(e.clientX, e.clientY)
- })
- socket.addEventListener('mousemove', (e: MouseEvent) => {
- positionTooltip(e.clientX, e.clientY)
- })
- socket.addEventListener('mouseleave', () => {
- activeTooltipSkillId = null
- hideTooltip()
- })
-
- return socket
-}
-
-/**
- * Positions the tooltip safely within the viewport boundaries.
- */
-function positionTooltip(mouseX: number, mouseY: number) {
- if (!tooltipEl || tooltipEl.style.display !== 'block') return
-
- const tooltipWidth = tooltipEl.offsetWidth || 320
- const tooltipHeight = tooltipEl.offsetHeight || 300
- const pad = 16
-
- let left = mouseX + pad
- let top = mouseY + pad
-
- // Right edge boundary clamp
- if (left + tooltipWidth > window.innerWidth - 10) {
- left = mouseX - tooltipWidth - pad
- }
- // Bottom edge boundary clamp
- if (top + tooltipHeight > window.innerHeight - 10) {
- top = window.innerHeight - tooltipHeight - 10
- }
- // Top boundary clamp (keep below header and stats bar at 94px)
- if (top < 96) top = 96
- if (left < 10) left = 10
-
- tooltipEl.style.left = `${left}px`
- tooltipEl.style.top = `${top}px`
-}
-
-/**
- * Hides the tooltip.
- */
-function hideTooltip() {
- if (tooltipEl) tooltipEl.style.display = 'none'
-}
-
-/**
- * Re-renders the tooltip content if active.
- */
-function refreshTooltip(skillId: number) {
- if (activeTooltipSkillId === skillId) {
- renderTooltip(skillId)
- }
-}
-
-/**
- * Builds and displays the authentic Diablo II hover tooltip.
- */
-function renderTooltip(skillId: number) {
- const reqCharLvl = getRequiredCharacterLevel(state)
- const vm = buildTooltipViewModel(state, skillId, reqCharLvl)
- if (!vm) {
- hideTooltip()
- return
- }
-
- const skillName = currentLang === 'zh' ? vm.skill.nameZh : vm.skill.name
- const className = currentLang === 'zh' ? CLASS_NAMES[currentClass].zh : CLASS_NAMES[currentClass].en
- const tabName = currentLang === 'zh' ? vm.skill.tabNameZh : vm.skill.tabName
- const soft = vm.effectiveLevel > vm.hardPoints ? vm.effectiveLevel - vm.hardPoints : 0
-
- let levelText = ''
- if (vm.isLearned) {
- if (soft > 0) {
- levelText = currentLang === 'zh'
- ? `当前技能等级: ${vm.effectiveLevel} (基础: ${vm.hardPoints}, 装备: +${soft})`
- : `Current Skill Level: ${vm.effectiveLevel} (Base: ${vm.hardPoints}, Gear: +${soft})`
- } else {
- levelText = currentLang === 'zh'
- ? `当前技能等级: ${vm.effectiveLevel}`
- : `Current Skill Level: ${vm.effectiveLevel}`
- }
- } else {
- levelText = currentLang === 'zh' ? `未习得 (预览 1 级效果)` : `Not Learned (Preview Level 1)`
- }
-
- let html = `
-
-
-
-
- ${currentLang === 'zh' ? '需求角色等级:' : 'Required Character Level:'}
- ${vm.reqlevel}
-
- `
-
- // Prerequisites
- if (vm.prereqs.length > 0) {
- html += `
-
- ${currentLang === 'zh' ? '前置技能需求:' : 'Prerequisites:'}
-
- `
- for (const prereq of vm.prereqs) {
- const pName = currentLang === 'zh' ? prereq.nameZh : prereq.name
- const statusIcon = prereq.met ? '✓' : '✗'
- const statusClass = prereq.met ? 'ok' : 'err'
- const statusDesc = prereq.met
- ? (currentLang === 'zh' ? '已达成' : 'Satisfied')
- : (currentLang === 'zh' ? '未投入点数' : 'Missing')
- html += `
-
- ${statusIcon} ${pName}
- ${statusDesc}
-
- `
- }
- }
-
- // Mana Cost & Delay
- if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
- html += `
-
- ${currentLang === 'zh' ? '法力消耗:' : 'Mana Cost:'}
- ${vm.current.manaCost}
-
- `
- }
- if (vm.current.delaySeconds > 0) {
- html += `
-
- ${currentLang === 'zh' ? '施法延迟:' : 'Casting Delay:'}
- ${vm.current.delaySeconds} ${currentLang === 'zh' ? '秒' : 'sec'}
-
- `
- }
-
- html += `
` // close tt-section
-
- // Damage & Mechanics Box
- const dmg = vm.current.damage
- if (dmg.hasDamage || dmg.weaponDamagePercent) {
- html += ``
-
- // Final Damage
- if (dmg.hasDamage) {
- const typeLabel = currentLang === 'zh' ? dmg.typeZh : `${dmg.type.toUpperCase()} Damage`
- html += `
-
- ${dmg.finalMin} - ${dmg.finalMax} ${typeLabel}
-
- `
- if (dmg.durationSeconds) {
- html += `
-
- ${currentLang === 'zh' ? `持续时间: ${dmg.durationSeconds} 秒` : `Duration: ${dmg.durationSeconds} seconds`}
-
- `
- }
- }
-
- // Weapon Damage %
- if (dmg.weaponDamagePercent) {
- html += `
-
- ${currentLang === 'zh' ? `武器伤害: ${dmg.weaponDamagePercent}%` : `Weapon Damage: ${dmg.weaponDamagePercent}%`}
-
- `
- }
-
- // Base damage breakdown if multiplier applied
- if (dmg.hasDamage && (dmg.synergyMultiplier > 1.0 || dmg.masteryMultiplier > 1.0)) {
- html += `
-
- ${currentLang === 'zh' ? `基础伤害: ${dmg.baseMin} - ${dmg.baseMax}` : `Base: ${dmg.baseMin} - ${dmg.baseMax}`}
- ${dmg.synergyBonusPct > 0 ? ` · 协同 +${dmg.synergyBonusPct}%` : ''}
- ${dmg.masteryBonusPct > 0 ? ` · 精通 +${dmg.masteryBonusPct}%` : ''}
-
- `
- }
-
- html += `
`
- }
-
- // Skill Effects & Mechanics Box (Stats, Auras, Minions, Buffs, Curses)
- if (vm.current.effects && vm.current.effects.length > 0) {
- html += ``
- for (const eff of vm.current.effects) {
- const lineText = currentLang === 'zh' ? eff.textZh : eff.textEn
- html += `
${lineText}
`
- }
- html += `
`
- }
-
- // Synergies Received List
- if (vm.synergiesReceived.length > 0) {
- html += `
-
-
- ${currentLang === 'zh' ? '接收协同加成 (Synergies Received):' : 'Synergies Received:'}
-
- `
- for (const syn of vm.synergiesReceived) {
- const pName = currentLang === 'zh' ? syn.providerNameZh : syn.providerName
- const activeClass = syn.hardPoints > 0 ? 'active' : ''
- html += `
-
- • ${pName}: +${syn.percentPerLevel}% / ${currentLang === 'zh' ? '级' : 'lvl'}
- (${currentLang === 'zh' ? `当前 ${syn.hardPoints} 点: +${syn.currentBonusPct}%` : `Current ${syn.hardPoints} pts: +${syn.currentBonusPct}%`})
-
- `
- }
- html += `
`
- }
-
- // Mastery info if applicable
- if (vm.masteryInfo && vm.masteryInfo.active) {
- html += `
-
-
- ${currentLang === 'zh' ? '精通加成 (Mastery Applied):' : 'Mastery Applied:'}
-
-
- • ${currentLang === 'zh' ? vm.masteryInfo.descriptionZh : vm.masteryInfo.descriptionEn}
-
-
- `
- }
-
- // Synergies Given List (为此技能提供协同)
- if (vm.synergiesGiven.length > 0) {
- html += `
-
-
- ${currentLang === 'zh' ? '输出协同加成 (Synergies Given):' : 'Synergies Given:'}
-
- `
- for (const given of vm.synergiesGiven) {
- const tName = currentLang === 'zh' ? given.targetNameZh : given.targetName
- html += `
-
- • ${tName}: +${given.percentPerLevel}% / ${currentLang === 'zh' ? '级' : 'lvl'}
-
- `
- }
- html += `
`
- }
-
- // Next Level Preview
- if (vm.nextLevel) {
- const nextDmg = vm.nextLevel.damage
- const hasNextDmg = nextDmg.hasDamage
- const hasNextEffects = vm.nextLevel.effects && vm.nextLevel.effects.length > 0
- const hasManaChange = vm.nextLevel.manaCost !== vm.current.manaCost
-
- if (hasNextDmg || hasNextEffects || hasManaChange) {
- html += `
-
-
${currentLang === 'zh' ? '[下一级效果]' : '[Next Level]'} (Lv ${vm.nextLevel.level})
- `
- if (hasNextDmg) {
- const typeLabel = currentLang === 'zh' ? nextDmg.typeZh : `${nextDmg.type.toUpperCase()} Damage`
- html += `
${nextDmg.finalMin} - ${nextDmg.finalMax} ${typeLabel}
`
- }
- if (hasNextEffects) {
- for (const eff of vm.nextLevel.effects) {
- const lineText = currentLang === 'zh' ? eff.textZh : eff.textEn
- html += `
${lineText}
`
- }
- }
- if (hasManaChange) {
- html += `
${currentLang === 'zh' ? '法力消耗:' : 'Mana Cost:'} ${vm.nextLevel.manaCost}
`
- }
- html += `
`
- }
- }
-
- // Description / Lore
- const desc = currentLang === 'zh' ? (vm.shortDescZh || vm.shortDesc) : vm.shortDesc
- if (desc) {
- html += `${desc}
`
- }
-
- // Helper Hint
- html += `
-
- ${currentLang === 'zh'
- ? '左键: +1点 (Shift+点击: +5) | 右键: -1点 (Shift+右键: 快速撤点)'
- : 'Left-click: +1 (Shift: +5) | Right-click: -1 (Shift: Refund max)'}
-
- `
-
- tooltipEl.innerHTML = html
- tooltipEl.style.display = 'block'
-}
-
-/**
- * Updates the summary statistics bar.
- */
-function updateSummaryBar() {
- const reqLevel = getRequiredCharacterLevel(state)
- const spent = getTotalPointsSpent(state)
- const remain = Math.max(0, 110 - spent)
-
- valReqLevel.textContent = String(reqLevel)
- if (reqLevel > 99) {
- valReqLevel.classList.add('warning')
- } else {
- valReqLevel.classList.remove('warning')
- }
-
- valPointsSpent.textContent = `${spent} / 110`
- valPointsRemain.textContent = String(remain)
- valAllSkills.textContent = String(state.allSkillsSoftPoints || 0)
-}
-
-/**
- * Full page render pass.
- */
-function render() {
- updateStaticI18n()
- renderClassTabs()
- renderTrees()
- updateSummaryBar()
-}
-
-/**
- * Setup event handlers.
- */
-function setupEvents() {
- // All Skills Stepper
- btnAllSkillsDec.addEventListener('click', () => {
- if (state.allSkillsSoftPoints > 0) {
- state.allSkillsSoftPoints--
- syncHash()
- render()
- if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
- }
- })
- btnAllSkillsInc.addEventListener('click', () => {
- if (state.allSkillsSoftPoints < 99) {
- state.allSkillsSoftPoints++
- syncHash()
- render()
- if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
- }
- })
-
- // Respec
- btnRespec.addEventListener('click', () => {
- const refunded = respec(state)
- state.allSkillsSoftPoints = 0
- state.tabSoftPoints = { 0: 0, 1: 0, 2: 0 }
- syncHash()
- render()
- hideTooltip()
- showToast(
- currentLang === 'zh'
- ? `重置完成!已退还 ${refunded} 点技能点。`
- : `Respec complete! Refunded ${refunded} skill points.`
- )
- })
-
- // Copy Build Link
- btnCopyLink.addEventListener('click', async () => {
- syncHash()
- const url = window.location.href
- try {
- await navigator.clipboard.writeText(url)
- showToast(
- currentLang === 'zh'
- ? '已复制配点链接到剪贴板!'
- : 'Build link copied to clipboard!'
- )
- } catch {
- prompt(
- currentLang === 'zh' ? '请复制以下配点链接:' : 'Copy build link below:',
- url
- )
- }
- })
-
- // Language Toggle
- btnLang.addEventListener('click', () => {
- currentLang = currentLang === 'zh' ? 'en' : 'zh'
- render()
- if (activeTooltipSkillId) refreshTooltip(activeTooltipSkillId)
- })
-
- // Window Hash Change (Back/Forward navigation)
- window.addEventListener('hashchange', () => {
- initFromHash()
- render()
- })
-}
-
-// Initialization
-initFromHash()
-setupEvents()
-render()
diff --git a/tests/boss.test.ts b/tests/boss.test.ts
deleted file mode 100644
index b4f9e6a..0000000
--- a/tests/boss.test.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { describe, it, expect, beforeAll } from 'vitest'
-import { ACT_BOSSES, type BossPreset } from '../src/boss.ts'
-import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
-import { executeDropPipeline, createDroppedItem, type DropTables } from '../src/game/drop-pipeline.ts'
-import { formatItemTooltip } from '../src/game/item-tooltip.ts'
-import type { Item } from '../src/game/items.ts'
-
-describe('Act Boss Loot Simulator (boss.html)', () => {
- let dropTables: DropTables
-
- beforeAll(() => {
- dropTables = getEmbeddedDropTables()
- })
-
- it('1. ACT_BOSSES catalog includes all 5 canonical Act Bosses', () => {
- expect(ACT_BOSSES).toHaveLength(5)
- const bossIds = ACT_BOSSES.map(b => b.id)
- expect(bossIds).toEqual(['andariel', 'duriel', 'mephisto', 'diablo', 'baal'])
- })
-
- it('2. All standard TCs and Quest TCs resolve to valid nodes in TreasureClassEx', () => {
- const difficulties: Array<'normal' | 'nightmare' | 'hell'> = ['normal', 'nightmare', 'hell']
-
- for (const boss of ACT_BOSSES) {
- for (const diff of difficulties) {
- const stdTc = boss.tc[diff]
- const questTc = boss.questTc[diff]
-
- const stdNode = dropTables.tcTable.get(stdTc)
- const questNode = dropTables.tcTable.get(questTc)
-
- expect(stdNode, `Boss ${boss.id} [${diff}] std TC ${stdTc} should exist`).toBeDefined()
- expect(questNode, `Boss ${boss.id} [${diff}] quest TC ${questTc} should exist`).toBeDefined()
-
- expect(stdNode?.picks).toBeGreaterThan(0)
- expect(questNode?.picks).toBeGreaterThan(0)
- }
- }
- })
-
- it('3. Simulates single Hell Mephisto kill and generates realistic dropped items with attributes', () => {
- const drops: Item[] = executeDropPipeline({
- tcName: 'Mephistoq (H)',
- nLevel: 87,
- dropTables,
- difficulty: 'hell',
- playerMf: 300,
- gamePlayers: 1,
- partyPlayers: 1,
- monsterRng: 42,
- })
-
- expect(drops.length).toBeGreaterThan(0)
-
- for (const item of drops) {
- expect(item.name.length).toBeGreaterThan(0)
- expect(item.code).toBeDefined()
- expect(item.code!.length).toBeGreaterThan(0)
- expect(item.base).toBeDefined()
-
- const tooltip = formatItemTooltip(item)
- expect(tooltip.title.length).toBeGreaterThan(0)
- expect(tooltip.quality.length).toBeGreaterThan(0)
- expect(tooltip.ilvl).toBe(87)
-
- // If equipment, verify stats formatting
- if (item.base.kind === 'weapon' || item.base.kind === 'armor') {
- expect(tooltip.baseType).toMatch(/Weapon|Armor/)
- }
- }
- })
-
- it('4. Simulates Hell Baal kill with 7 Players (low NoDrop) and rolls multi-tier affixes', () => {
- const drops: Item[] = executeDropPipeline({
- tcName: 'Baal (H)',
- nLevel: 99,
- dropTables,
- difficulty: 'hell',
- playerMf: 250,
- gamePlayers: 7,
- partyPlayers: 1,
- monsterRng: 99999,
- })
-
- expect(drops.length).toBeGreaterThan(0)
- const qualities = drops.map(d => d.rarity)
- expect(qualities.length).toBe(drops.length)
- })
-
- it('5. Verifies formatItemTooltip generates correct properties for Unique, Rare, and Magic items', () => {
- // 5.1 Unique Item Tooltip Test
- const dummyUniqueBase = dropTables.getBase('uap')! // Shako
- const dummyUniqueDef = dropTables.uniques.getEnabledByCode('uap')[0]!
- const uniqueItem: Item = {
- base: dummyUniqueBase,
- prefix: null,
- suffix: null,
- level: 87,
- ilvl: 87,
- name: dummyUniqueDef.index,
- stats: { defense: 141, allskills: 2, mag: 50 },
- invWidth: dummyUniqueBase.invWidth,
- invHeight: dummyUniqueBase.invHeight,
- stack: 1,
- value: 10000,
- rarity: 'unique',
- uniqueItemDef: dummyUniqueDef,
- }
-
- const uniqueTooltip = formatItemTooltip(uniqueItem)
- expect(uniqueTooltip.title).toBe(dummyUniqueDef.index)
- expect(uniqueTooltip.quality.toLowerCase()).toBe('unique')
- expect(uniqueTooltip.lines.length).toBeGreaterThan(0)
-
- // 5.2 Rune Tooltip Test
- const dummyRuneBase = dropTables.getBase('r30')! // Ber Rune
- const runeItem: Item = {
- base: dummyRuneBase,
- prefix: null,
- suffix: null,
- level: 63,
- ilvl: 63,
- name: 'Ber Rune',
- code: 'r30 ',
- stats: {},
- invWidth: 1,
- invHeight: 1,
- stack: 1,
- value: 100,
- rarity: 'normal',
- }
-
- const runeTooltip = formatItemTooltip(runeItem)
- expect(runeTooltip.title).toBe('Ber Rune')
- expect(runeTooltip.quality).toBe('Rune')
- expect(runeTooltip.lines.some(l => l.text.includes('Crushing Blow'))).toBe(true)
- })
-
- it('6. Verifies concrete rolled values for variable modifiers (e.g. +X to Mana, not range)', () => {
- const ringBase = dropTables.getBase('rin')!
- // Roll an item with mana stat
- const rolledItem = createDroppedItem(ringBase, 'magic', {
- magicAffixes: {
- name: "Bahamut's Ring",
- prefix: {
- name: "Bahamut's",
- affix: dropTables.magicAffixes.prefixes.getByName("Bahamut's")[0]!,
- level: 37,
- mods: [{ code: 'mana', min: 80, max: 120, value: 105 }],
- },
- } as any,
- ilvl: 50,
- dwInitSeed: 12345,
- })
-
- const tooltip = formatItemTooltip(rolledItem)
- expect(tooltip.lines.some(l => l.text === '+105 to Mana')).toBe(true)
- expect(tooltip.lines.some(l => l.text.includes('80-120'))).toBe(false)
- })
-})
diff --git a/tests/loot.test.ts b/tests/loot.test.ts
deleted file mode 100644
index 1024947..0000000
--- a/tests/loot.test.ts
+++ /dev/null
@@ -1,412 +0,0 @@
-import { describe, it, expect, beforeAll } from 'vitest'
-import {
- calculateDirectProbabilities,
- calculateCompoundProbability,
- calculateAnalyticProbabilities,
- buildReverseLookupIndex,
- reverseLookup,
- runMonteCarloSimulation,
- loadCanonicalTreasureClasses,
- CANONICAL_ITEM_NAMES,
- MONSTER_PRESETS,
- getMonsterPresetTc,
- type ReverseLookupEntry,
-} from '../src/loot.ts'
-
-describe('Loot Drop Simulator & Exact Probability Table (Issue #110)', () => {
- const { tcTable, autoTcs } = loadCanonicalTreasureClasses()
-
- describe('1. Analytic Expansion Canonical Probabilities (Act 1 H2H A)', () => {
- it('matches canonical probabilities for Act 1 H2H A direct drop rates', () => {
- // Act 1 H2H A base parameters:
- // picks: 1, NoDrop: 100, TotalProb: 60, TotalWeight: 160
- // Expected direct probabilities:
- // NoDrop: 100 / 160 = 0.625 (62.5%)
- // gld: 21 / 160 = 0.13125 (13.125%)
- // Act 1 Equip A: 16 / 160 = 0.10 (10%)
- // Act 1 Junk: 21 / 160 = 0.13125 (13.125%)
- // Act 1 Good: 2 / 160 = 0.0125 (1.25%)
- const direct = calculateDirectProbabilities('Act 1 H2H A', {
- tcTable,
- autoTcs,
- gamePlayers: 1,
- partyPlayers: 1,
- })
-
- expect(direct.picks).toBe(1)
- expect(direct.noDrop).toBe(100)
- expect(direct.totalWeight).toBe(160)
-
- const pNoDrop = direct.probabilities.get('NoDrop')!
- const pGld = direct.probabilities.get('gld')!
- const pEquip = direct.probabilities.get('Act 1 Equip A')!
- const pJunk = direct.probabilities.get('Act 1 Junk')!
- const pGood = direct.probabilities.get('Act 1 Good')!
-
- expect(pNoDrop).toBeCloseTo(0.625, 6) // 62.5%
- expect(pGld).toBeCloseTo(0.13125, 6) // 13.125%
- expect(pEquip).toBeCloseTo(0.10, 6) // 10.0%
- expect(pJunk).toBeCloseTo(0.13125, 6) // 13.125%
- expect(pGood).toBeCloseTo(0.0125, 6) // 1.25%
-
- // Sum of all direct branch probabilities must equal exactly 1.0 (100%)
- const sum = pNoDrop + pGld + pEquip + pJunk + pGood
- expect(sum).toBeCloseTo(1.0, 6)
- })
-
- it('propagates direct probabilities to analytic result object', () => {
- const analytic = calculateAnalyticProbabilities('Act 1 H2H A', {
- tcTable,
- autoTcs,
- gamePlayers: 1,
- partyPlayers: 1,
- })
-
- expect(analytic.direct.get('NoDrop')).toBeCloseTo(0.625, 6)
- expect(analytic.direct.get('gld')).toBeCloseTo(0.13125, 6)
- expect(analytic.direct.get('Act 1 Equip A')).toBeCloseTo(0.10, 6)
- expect(analytic.direct.get('Act 1 Junk')).toBeCloseTo(0.13125, 6)
- expect(analytic.direct.get('Act 1 Good')).toBeCloseTo(0.0125, 6)
-
- expect(analytic.noDropSingleProb).toBeCloseTo(0.625, 6)
- expect(analytic.noDropCompoundProb).toBeCloseTo(0.625, 6)
-
- // Leaf items include gld with single probability 0.13125
- const gldLeaf = analytic.leafMap.get('gld')
- expect(gldLeaf).toBeDefined()
- expect(gldLeaf?.singleProbability).toBeCloseTo(0.13125, 6)
- expect(gldLeaf?.compoundProbability).toBeCloseTo(0.13125, 6)
- })
- })
-
- describe('2. Compound Probability Calculation for picks > 1', () => {
- it('calculates compound probability 1 - (1 - p)^picks accurately', () => {
- // 7 picks with p = 0.1: 1 - 0.9^7 = 0.5217031
- expect(calculateCompoundProbability(0.1, 7)).toBeCloseTo(1 - Math.pow(0.9, 7), 8)
-
- // 7 picks with p = 0.5: 1 - 0.5^7 = 0.9921875
- expect(calculateCompoundProbability(0.5, 7)).toBeCloseTo(1 - Math.pow(0.5, 7), 8)
-
- // 1 pick equals single probability
- expect(calculateCompoundProbability(0.25, 1)).toBe(0.25)
-
- // Boundary values
- expect(calculateCompoundProbability(0, 7)).toBe(0)
- expect(calculateCompoundProbability(1, 7)).toBe(1)
- expect(calculateCompoundProbability(0.5, -2)).toBe(1) // deterministic picks
- })
-
- it('never exceeds 1.0 across extensive parameter sweeps', () => {
- const pickCounts = [1, 2, 3, 4, 5, 6, 7, 8, 10, 20, 50, 100, 1000]
- const probabilities = [0.0001, 0.001, 0.01, 0.05, 0.1, 0.2, 0.33, 0.5, 0.75, 0.9, 0.99, 0.9999, 1.0, 1.5]
-
- for (const picks of pickCounts) {
- for (const p of probabilities) {
- const cp = calculateCompoundProbability(p, picks)
- expect(cp).toBeGreaterThanOrEqual(0)
- expect(cp).toBeLessThanOrEqual(1.0)
- }
- }
- })
-
- it('strictly exhibits monotonicity with respect to pick count for p in (0, 1)', () => {
- const p = 0.15
- let prev = calculateCompoundProbability(p, 1)
- for (let picks = 2; picks <= 20; picks++) {
- const curr = calculateCompoundProbability(p, picks)
- expect(curr).toBeGreaterThan(prev)
- expect(curr).toBeLessThanOrEqual(1.0)
- prev = curr
- }
- })
- })
-
- describe('3. Monte Carlo Convergence within 99% Confidence Interval', () => {
- it('converges to analytic drop probabilities within 99% CI for Act 1 H2H A', async () => {
- const runs = 30000
- const seed = 424242
-
- const result = await runMonteCarloSimulation({
- tcName: 'Act 1 H2H A',
- runs,
- gamePlayers: 1,
- partyPlayers: 1,
- seed,
- tcTable,
- autoTcs,
- })
-
- expect(result.totalRuns).toBe(runs)
-
- // True analytic probabilities
- const pExpectedNoDrop = 0.625 // 100 / 160
- const pExpectedGld = 0.13125 // 21 / 160
-
- // Observed sample proportions
- const pObservedNoDrop = result.emptyRuns / runs
- const gldSummary = result.itemSummaries.find(it => it.code === 'gld')
- const pObservedGld = (gldSummary?.count ?? 0) / runs
-
- // 99% Confidence Interval Critical Value z = 2.575829
- const z99 = 2.575829
- const seNoDrop = Math.sqrt((pExpectedNoDrop * (1 - pExpectedNoDrop)) / runs)
- const seGld = Math.sqrt((pExpectedGld * (1 - pExpectedGld)) / runs)
-
- const marginNoDrop = z99 * seNoDrop
- const marginGld = z99 * seGld
-
- const diffNoDrop = Math.abs(pObservedNoDrop - pExpectedNoDrop)
- const diffGld = Math.abs(pObservedGld - pExpectedGld)
-
- expect(diffNoDrop).toBeLessThanOrEqual(marginNoDrop)
- expect(diffGld).toBeLessThanOrEqual(marginGld)
- })
-
- it('verifies player scaling reduces empty runs under /players 8', async () => {
- const runs = 10000
- const seed = 999111
-
- const resultSolo8 = await runMonteCarloSimulation({
- tcName: 'Act 1 H2H A',
- runs,
- gamePlayers: 8,
- partyPlayers: 1,
- seed,
- tcTable,
- autoTcs,
- })
-
- // Under gamePlayers = 8, partyPlayers = 1:
- // effectivePlayers = 1 + trunc(7/2) = 4
- // Scaled NoDrop = trunc(60 * (100/160)^4 / (1 - (100/160)^4)) = 10
- // Total = 70. Expected empty rate = 10 / 70 ~ 14.28%
- const observedRate8 = resultSolo8.emptyRuns / runs
- const expectedRate8 = 10 / 70
- const se8 = Math.sqrt((expectedRate8 * (1 - expectedRate8)) / runs)
-
- expect(Math.abs(observedRate8 - expectedRate8)).toBeLessThanOrEqual(2.576 * se8)
- expect(observedRate8).toBeLessThan(0.20)
- })
-
- it('simulates drops without seed (undefined seed) without PRNG zero-lock', async () => {
- // Regression test for Issue #110 bug: When seed is omitted (default UI state),
- // PRNG must not lock into (0, 0) state which produced 100% NoDrop across 1000 runs.
- const runs = 1000
- const result = await runMonteCarloSimulation({
- tcName: 'Baal (H)',
- runs,
- gamePlayers: 1,
- partyPlayers: 1,
- tcTable,
- autoTcs,
- })
-
- expect(result.totalRuns).toBe(runs)
- expect(result.itemsDropped).toBeGreaterThan(2000) // Baal rolls up to 7 picks, usually ~5-6 items per run
- expect(result.emptyRuns).toBeLessThan(10) // Baal has extremely low NoDrop
- expect(result.itemSummaries.length).toBeGreaterThan(10)
- expect(result.qualityCounts.magic + result.qualityCounts.rare + result.qualityCounts.set + result.qualityCounts.unique).toBeGreaterThan(0)
- })
-
- it('simulates drops with seed=0 safely mapping to non-zero seed', async () => {
- const runs = 1000
- const result = await runMonteCarloSimulation({
- tcName: 'Baal (H)',
- runs,
- seed: 0,
- gamePlayers: 1,
- partyPlayers: 1,
- tcTable,
- autoTcs,
- })
-
- expect(result.totalRuns).toBe(runs)
- expect(result.itemsDropped).toBeGreaterThan(2000)
- expect(result.emptyRuns).toBeLessThan(10)
- })
-
- it('supports isQuestDrop option routing to Baalq (H)', async () => {
- const runs = 500
- const result = await runMonteCarloSimulation({
- tcName: 'Baal (H)',
- runs,
- isQuestDrop: true,
- gamePlayers: 1,
- partyPlayers: 1,
- tcTable,
- autoTcs,
- })
-
- expect(result.totalRuns).toBe(runs)
- expect(result.itemsDropped).toBeGreaterThan(1000)
- // Quest drop has 0 NoDrop
- expect(result.emptyRuns).toBe(0)
- })
-
- it('handles negative picks analytic probabilities without producing 100% compound for rare items', () => {
- // Find a TC with negative picks in tcTable
- let negativeTcName: string | undefined
- for (const node of tcTable.all) {
- if (node.picks < 0 && node.items.length > 1) {
- negativeTcName = node.name
- break
- }
- }
-
- if (negativeTcName) {
- const analytic = calculateAnalyticProbabilities(negativeTcName, {
- tcTable,
- autoTcs,
- gamePlayers: 1,
- partyPlayers: 1,
- })
- for (const leaf of analytic.leaves) {
- if (leaf.singleProbability < 0.5) {
- expect(leaf.compoundProbability).toBeLessThan(1.0)
- }
- }
- }
- })
- })
-
- describe('4. Reverse Lookup Indexing All Eligible Items', () => {
- let reverseIndex: Map
-
- beforeAll(() => {
- reverseIndex = buildReverseLookupIndex(tcTable, autoTcs)
- })
-
- it('indexes all eligible unique leaf items', () => {
- // 605 distinct item codes (604 items + unified gold)
- expect(reverseIndex.size).toBe(605)
- expect(reverseIndex.has('gld')).toBe(true)
- expect(reverseIndex.has('uap')).toBe(true)
- expect(reverseIndex.has('r33')).toBe(true)
- expect(reverseIndex.has('cap')).toBe(true)
- })
-
- it('finds dropping TCs when searching for Shako / uap', () => {
- const byCode = reverseLookup(reverseIndex, 'uap', CANONICAL_ITEM_NAMES)
- expect(byCode.length).toBeGreaterThan(0)
- const uapEntry = byCode.find(r => r.matchedCode === 'uap')!
- expect(uapEntry).toBeDefined()
- expect(uapEntry.entries.length).toBeGreaterThan(50)
-
- // Bosses like Mephisto (H) and Baal (H) must be in the drop list for Shako
- const tcNames = uapEntry.entries.map(e => e.tcName)
- expect(tcNames).toContain('Mephisto (H)')
- expect(tcNames).toContain('Baal (H)')
-
- // Name search for "Shako"
- const byName = reverseLookup(reverseIndex, 'Shako', CANONICAL_ITEM_NAMES)
- expect(byName.some(r => r.matchedCode === 'uap')).toBe(true)
- })
-
- it('finds dropping TCs when searching for high rune Zod / r33', () => {
- const byCode = reverseLookup(reverseIndex, 'r33', CANONICAL_ITEM_NAMES)
- expect(byCode.length).toBeGreaterThan(0)
- const r33Entry = byCode.find(r => r.matchedCode === 'r33')!
- expect(r33Entry).toBeDefined()
-
- const tcNames = r33Entry.entries.map(e => e.tcName)
- expect(tcNames).toContain('Runes 17')
- expect(tcNames).toContain('Baal (H)')
- expect(tcNames).toContain('Cow (H)')
-
- // Name search for "Zod"
- const byName = reverseLookup(reverseIndex, 'zod', CANONICAL_ITEM_NAMES)
- expect(byName.some(r => r.matchedCode === 'r33')).toBe(true)
- })
-
- it('finds dropping TCs when searching for cap (armo3)', () => {
- const byCode = reverseLookup(reverseIndex, 'cap', CANONICAL_ITEM_NAMES)
- expect(byCode.length).toBeGreaterThan(0)
- const capEntry = byCode.find(r => r.matchedCode === 'cap')!
- expect(capEntry).toBeDefined()
-
- const tcNames = capEntry.entries.map(e => e.tcName)
- expect(tcNames).toContain('armo3')
- expect(tcNames).toContain('Act 1 Equip A')
- expect(tcNames).toContain('Act 1 H2H A')
- })
- })
-
- describe('5. Monster Preset TC Resolution & Accuracy (Issue #121)', () => {
- it('verifies that all MONSTER_PRESETS define valid TCs that exist in tcTable', () => {
- expect(MONSTER_PRESETS.length).toBeGreaterThanOrEqual(15)
-
- for (const m of MONSTER_PRESETS) {
- for (const diff of ['normal', 'nightmare', 'hell'] as const) {
- const tcName = m.tc[diff]
- expect(tcName).toBeDefined()
- expect(tcName.length).toBeGreaterThan(0)
- const node = tcTable.get(tcName) ?? autoTcs.get(tcName)
- expect(node, `Monster ${m.id} difficulty ${diff} TC "${tcName}" must exist in tcTable`).toBeDefined()
-
- if (m.questTc) {
- const qtcName = m.questTc[diff]
- expect(qtcName).toBeDefined()
- const qnode = tcTable.get(qtcName) ?? autoTcs.get(qtcName)
- expect(qnode, `Monster ${m.id} quest TC "${qtcName}" must exist in tcTable`).toBeDefined()
- }
- }
- }
- })
-
- it('resolves Travincal Council (Council) to Council TCs, not Baal (H) (Issue #121 bug reproduction)', () => {
- const council = MONSTER_PRESETS.find(m => m.id === 'council')!
- expect(council).toBeDefined()
-
- // In Nightmare difficulty, Council must resolve to 'Council (N)', NOT 'Baal (H)'
- const tcNightmare = getMonsterPresetTc(council, 'nightmare', false)
- expect(tcNightmare).toBe('Council (N)')
- expect(tcNightmare).not.toBe('Baal (H)')
-
- // In Hell difficulty, Council must resolve to 'Council (H)', NOT 'Baal (H)'
- const tcHell = getMonsterPresetTc(council, 'hell', false)
- expect(tcHell).toBe('Council (H)')
- expect(tcHell).not.toBe('Baal (H)')
-
- // In Normal difficulty, Council must resolve to 'Council'
- const tcNormal = getMonsterPresetTc(council, 'normal', false)
- expect(tcNormal).toBe('Council')
- })
-
- it('resolves distinct TCs for every monster preset without leaking Baal (H)', () => {
- for (const m of MONSTER_PRESETS) {
- if (m.id === 'baal') continue
-
- for (const diff of ['normal', 'nightmare', 'hell'] as const) {
- const tc = getMonsterPresetTc(m, diff, false)
- expect(tc, `Monster ${m.id} [${diff}] should never resolve to Baal (H)`).not.toBe('Baal (H)')
- expect(tc, `Monster ${m.id} [${diff}] should never resolve to Baal`).not.toBe('Baal')
- expect(tc, `Monster ${m.id} [${diff}] should never resolve to Baal (N)`).not.toBe('Baal (N)')
- }
- }
- })
-
- it('resolves quest TCs correctly when quest drop is enabled', () => {
- const andariel = MONSTER_PRESETS.find(m => m.id === 'andariel')!
- expect(getMonsterPresetTc(andariel, 'hell', true)).toBe('Andarielq (H)')
- expect(getMonsterPresetTc(andariel, 'nightmare', true)).toBe('Andarielq (N)')
- expect(getMonsterPresetTc(andariel, 'normal', true)).toBe('Andarielq')
-
- const mephisto = MONSTER_PRESETS.find(m => m.id === 'mephisto')!
- expect(getMonsterPresetTc(mephisto, 'hell', true)).toBe('Mephistoq (H)')
-
- const baal = MONSTER_PRESETS.find(m => m.id === 'baal')!
- expect(getMonsterPresetTc(baal, 'hell', true)).toBe('Baalq (H)')
- })
-
- it('respects customOverride when explicitly provided, but monster selection clears it', () => {
- const council = MONSTER_PRESETS.find(m => m.id === 'council')!
-
- // When custom override is active
- expect(getMonsterPresetTc(council, 'nightmare', false, 'Runes 17')).toBe('Runes 17')
-
- // When custom override is empty or null, returns monster TC
- expect(getMonsterPresetTc(council, 'nightmare', false, null)).toBe('Council (N)')
- expect(getMonsterPresetTc(council, 'nightmare', false, '')).toBe('Council (N)')
- })
- })
-})
diff --git a/tests/parity-1.13c.test.ts b/tests/parity-1.13c.test.ts
index 91c3706..7a9ae0a 100644
--- a/tests/parity-1.13c.test.ts
+++ b/tests/parity-1.13c.test.ts
@@ -1,5 +1,4 @@
import { describe, it, expect, beforeAll } from 'vitest'
-import { ACT_BOSSES, BOSS_LIST, type BossDefinition } from '../src/boss.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import * as fs from 'fs'
import { MpqArchive } from '../src/mpq/archive.ts'
@@ -37,7 +36,7 @@ import { isAffixEligible, type MagicAffix, type AffixTable } from '../src/game/a
import type { RareNameTable } from '../src/game/rare-names.ts'
import type { UpgradableItemBase } from '../src/game/item-upgrade.ts'
import type { Item, ItemBase } from '../src/game/items.ts'
-import { runParityAudit } from '../scripts/audit-parity.ts'
+import { ACT_BOSSES, BOSS_LIST, runParityAudit, type BossDefinition } from '../scripts/audit-parity.ts'
export type BaseTier = 'normal' | 'exceptional' | 'elite'
diff --git a/vite.config.ts b/vite.config.ts
index 09234d7..9a6060e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,8 +6,8 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url))
/**
* The engine is plain TypeScript with no runtime dependencies: Vite only serves
- * and bundles it. Two entries: the real campaign scene (`acts.html`) and the
- * combat math arena (`arena.html`). `samples/` is served in dev so all of them can
+ * and bundles it. Main campaign entries (`index.html`, `acts.html`, `acts-zh.html`,
+ * `acts-en.html`, `bnet.html`). `samples/` is served in dev so all of them can
* be smoke-tested over HTTP (`?sample=samples/spawn.mpq`,
* `?data=samples/fixtures`).
*/
@@ -27,10 +27,6 @@ export default defineConfig({
'acts-zh': resolve(__dirname, 'acts-zh.html'),
'acts-en': resolve(__dirname, 'acts-en.html'),
bnet: resolve(__dirname, 'bnet.html'),
- arena: resolve(__dirname, 'arena.html'),
- loot: resolve(__dirname, 'loot.html'),
- boss: resolve(__dirname, 'boss.html'),
- skills: resolve(__dirname, 'skills.html'),
},
},
},