feat(boss): 新建幕Boss击杀模拟与真实属性掉落页面 (boss.html)
- 新增 boss.html 及配套前端交互引擎 src/boss.ts,支持 Act 1~5 关底 Boss(安达利尔、督瑞尔、墨菲斯托、迪亚波罗、巴尔) - 支持 普通/噩梦/地狱 三大难度与首杀 (Quest Kill) 切换 - 支持 1~8 人房间人数 (PP) 与 0~1000% 寻宝率 (MF) 动态调整 - 提取并内置 1.13c 完整掉落表数据 (src/data/canonical-drop-data.ts),支持零延迟离线秒开解析 - 修复 D2Rng 默认种子全零导致 NoDrop 的历史缺陷,以及杂项物品误入品质判定的问题 - 新增 src/game/item-tooltip.ts,完整还原暗金/套装/亮金/魔法/符文/基础装备的暗黑2经典属性面板 - 新增 tests/boss.test.ts 单元测试并全量通过 1603 项测试
This commit is contained in:
parent
79bf8f4cda
commit
9b4347a72f
|
|
@ -25,6 +25,7 @@ Diablo II*/
|
|||
|
||||
# 临时调试产物
|
||||
.tmp/
|
||||
scratch/
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,827 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>暗黑破坏神 2 · 幕首领击杀与战利品属性模拟器 (Act Boss Loot Simulator)</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-void: #080604;
|
||||
--bg-main: #0e0a07;
|
||||
--bg-panel: rgba(20, 15, 11, 0.95);
|
||||
--bg-card: #15110c;
|
||||
--bg-card-hover: #211a13;
|
||||
--border-dim: #2b2219;
|
||||
--border-gold: #73572d;
|
||||
--border-gold-glow: #c8a15a;
|
||||
--gold-primary: #c8a15a;
|
||||
--gold-bright: #eecf87;
|
||||
--gold-light: #f5eedc;
|
||||
--text-dim: #998772;
|
||||
--text-muted: #6e5e4d;
|
||||
--btn-bg: #1c1611;
|
||||
--btn-hover: #33271c;
|
||||
--pill-bg: #1b150f;
|
||||
|
||||
/* Diablo Item Quality Colors */
|
||||
--q-unique: #c8a15a;
|
||||
--q-unique-bg: rgba(200, 161, 90, 0.08);
|
||||
--q-unique-border: rgba(200, 161, 90, 0.35);
|
||||
--q-set: #00e600;
|
||||
--q-set-bg: rgba(0, 230, 0, 0.08);
|
||||
--q-set-border: rgba(0, 230, 0, 0.35);
|
||||
--q-rune: #ff8c00;
|
||||
--q-rune-bg: rgba(255, 140, 0, 0.08);
|
||||
--q-rune-border: rgba(255, 140, 0, 0.35);
|
||||
--q-rare: #ffff60;
|
||||
--q-rare-bg: rgba(255, 255, 96, 0.08);
|
||||
--q-magic: #4880ff;
|
||||
--q-magic-bg: rgba(72, 128, 255, 0.08);
|
||||
--q-base: #e0d5c1;
|
||||
--q-gem: #d880ff;
|
||||
--q-gem-bg: rgba(216, 128, 255, 0.08);
|
||||
--q-misc: #ffd700;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg-void);
|
||||
color: var(--gold-light);
|
||||
font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0c0907; }
|
||||
::-webkit-scrollbar-thumb { background: #2f251c; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #523f2f; }
|
||||
|
||||
/* Top Header */
|
||||
header {
|
||||
height: 44px;
|
||||
background: linear-gradient(180deg, #18120d 0%, #100c09 100%);
|
||||
border-bottom: 1px solid var(--border-gold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--gold-bright);
|
||||
letter-spacing: 0.06em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.brand-badge {
|
||||
background: linear-gradient(135deg, #7d0000 0%, #b81c00 100%);
|
||||
color: #ffcfcf;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #ff4d4d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
header nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
header nav a {
|
||||
color: var(--gold-primary);
|
||||
text-decoration: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
header nav a:hover {
|
||||
background: var(--btn-hover);
|
||||
border-color: var(--border-gold);
|
||||
color: var(--gold-bright);
|
||||
}
|
||||
header nav a.active {
|
||||
background: #33140a;
|
||||
border-color: #ff5722;
|
||||
color: #ffccbc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Main Layout */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 44px);
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left Control Sidebar */
|
||||
aside.sidebar-controls {
|
||||
width: 420px;
|
||||
min-width: 420px;
|
||||
max-width: 420px;
|
||||
background: var(--bg-main);
|
||||
border-right: 1px solid var(--border-dim);
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Section Box */
|
||||
.control-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--gold-bright);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Boss Selector Grid */
|
||||
.boss-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.boss-select-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #0f0b08;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.boss-select-card:hover {
|
||||
background: #1c150e;
|
||||
border-color: var(--border-gold);
|
||||
}
|
||||
.boss-select-card.selected {
|
||||
background: linear-gradient(90deg, rgba(200, 161, 90, 0.15) 0%, rgba(33, 26, 19, 0.4) 100%);
|
||||
border-color: var(--gold-primary);
|
||||
box-shadow: 0 0 8px rgba(200, 161, 90, 0.2);
|
||||
}
|
||||
.boss-card-icon {
|
||||
font-size: 20px;
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.boss-card-info {
|
||||
flex: 1;
|
||||
}
|
||||
.boss-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--gold-bright);
|
||||
}
|
||||
.boss-card-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Difficulty Pills */
|
||||
.diff-pills-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.diff-pill {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
background: #100c08;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.diff-pill:hover {
|
||||
background: var(--btn-hover);
|
||||
color: var(--gold-light);
|
||||
}
|
||||
.diff-pill.active {
|
||||
background: #2a1b0c;
|
||||
border-color: var(--gold-primary);
|
||||
color: var(--gold-bright);
|
||||
}
|
||||
|
||||
/* Quest Checkbox Row */
|
||||
.quest-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
background: #0d0906;
|
||||
border: 1px dashed var(--border-dim);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.quest-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--gold-light);
|
||||
}
|
||||
.quest-label input {
|
||||
accent-color: var(--gold-primary);
|
||||
}
|
||||
|
||||
/* Boss Spec Preview Box */
|
||||
.boss-spec-box {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #0d0a07;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--gold-primary);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.boss-spec-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.boss-spec-name {
|
||||
font-weight: 700;
|
||||
color: var(--gold-bright);
|
||||
}
|
||||
.boss-quest-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.boss-quest-badge.quest-on {
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
color: #ffd700;
|
||||
border: 1px solid #ffd700;
|
||||
}
|
||||
.boss-quest-badge.quest-off {
|
||||
background: rgba(150, 150, 150, 0.1);
|
||||
color: #888;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
|
||||
/* Sliders & Parameter Controls */
|
||||
.param-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.param-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.param-title {
|
||||
font-size: 11px;
|
||||
color: var(--gold-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.param-val-badge {
|
||||
background: #211910;
|
||||
border: 1px solid var(--border-gold);
|
||||
color: var(--gold-bright);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
}
|
||||
.param-slider {
|
||||
width: 100%;
|
||||
accent-color: var(--gold-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.param-pills-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.pp-pill, .mf-preset-btn {
|
||||
flex: 1;
|
||||
padding: 3px 0;
|
||||
text-align: center;
|
||||
background: #110d09;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 3px;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.pp-pill:hover, .mf-preset-btn:hover {
|
||||
background: var(--btn-hover);
|
||||
color: var(--gold-light);
|
||||
}
|
||||
.pp-pill.active {
|
||||
background: #2b1c0e;
|
||||
border-color: var(--gold-primary);
|
||||
color: var(--gold-bright);
|
||||
font-weight: 700;
|
||||
}
|
||||
.param-tip {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Big Kill Action Buttons */
|
||||
.actions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-big-kill {
|
||||
background: linear-gradient(180deg, #7c1200 0%, #440a00 100%);
|
||||
border: 1px solid #ff4400;
|
||||
color: #fff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
box-shadow: 0 4px 14px rgba(180, 20, 0, 0.4);
|
||||
transition: all 0.15s ease;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.btn-big-kill:hover {
|
||||
background: linear-gradient(180deg, #991600 0%, #590d00 100%);
|
||||
border-color: #ff6622;
|
||||
box-shadow: 0 4px 20px rgba(220, 30, 0, 0.6);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-big-kill:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 2px 6px rgba(180, 20, 0, 0.4);
|
||||
}
|
||||
.btn-sub-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-sub-action {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
background: #18120d;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 4px;
|
||||
color: var(--gold-light);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.btn-sub-action:hover {
|
||||
background: #2b1f15;
|
||||
border-color: var(--border-gold);
|
||||
}
|
||||
|
||||
/* Stats Board */
|
||||
.stats-board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.stat-chip {
|
||||
padding: 6px 8px;
|
||||
background: #0e0a07;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-chip-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.stat-chip-val {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--gold-bright);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.stat-chip.unique .stat-chip-val { color: var(--q-unique); }
|
||||
.stat-chip.set .stat-chip-val { color: var(--q-set); }
|
||||
.stat-chip.rare .stat-chip-val { color: var(--q-rare); }
|
||||
.stat-chip.rune .stat-chip-val { color: var(--q-rune); }
|
||||
|
||||
/* Right Showcase Area */
|
||||
main.loot-showcase {
|
||||
flex: 1;
|
||||
background: var(--bg-void);
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Screen Flash Effect */
|
||||
@keyframes killPulse {
|
||||
0% { box-shadow: inset 0 0 0 0 rgba(255, 68, 0, 0); }
|
||||
30% { box-shadow: inset 0 0 60px 10px rgba(255, 68, 0, 0.35); }
|
||||
100% { box-shadow: inset 0 0 0 0 rgba(255, 68, 0, 0); }
|
||||
}
|
||||
.kill-flash {
|
||||
animation: killPulse 0.45s ease-out;
|
||||
}
|
||||
|
||||
/* Kill Banner */
|
||||
.kill-banner-box {
|
||||
background: linear-gradient(90deg, #241108 0%, #150b05 100%);
|
||||
border: 1px solid #7c2409;
|
||||
border-radius: 6px;
|
||||
padding: 12px 18px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.banner-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.banner-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
.banner-title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #ff9d6c;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.banner-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Empty Placeholder */
|
||||
.empty-loot-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120px 0;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.placeholder-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.placeholder-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.placeholder-desc {
|
||||
font-size: 12px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Items Grid */
|
||||
.loot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Diablo II Authentic Tooltip / Item Card */
|
||||
.d2-item-card {
|
||||
background: #080604;
|
||||
border: 2px solid #332517;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
|
||||
position: relative;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.d2-item-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
/* Card Quality Border Themes */
|
||||
.d2-item-card.quality-unique { border-color: rgba(200, 161, 90, 0.5); background: linear-gradient(180deg, #161009 0%, #0a0704 100%); }
|
||||
.d2-item-card.quality-set { border-color: rgba(0, 230, 0, 0.4); background: linear-gradient(180deg, #091609 0%, #040a04 100%); }
|
||||
.d2-item-card.quality-rare { border-color: rgba(255, 255, 96, 0.4); background: linear-gradient(180deg, #161609 0%, #0a0a04 100%); }
|
||||
.d2-item-card.quality-magic { border-color: rgba(72, 128, 255, 0.4); background: linear-gradient(180deg, #09101c 0%, #04070c 100%); }
|
||||
.d2-item-card.quality-rune { border-color: rgba(255, 140, 0, 0.5); background: linear-gradient(180deg, #180f07 0%, #0a0603 100%); }
|
||||
|
||||
/* Item Card Header */
|
||||
.item-card-header {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #201811;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.item-title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.item-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.item-tags {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.item-quality-pill {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.item-ilvl-pill {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
background: #120e0a;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Base Stats Block */
|
||||
.item-base-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 1px dashed #1c150e;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.base-stat-item .val-white {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.base-stat-item.req {
|
||||
color: #b08d74;
|
||||
}
|
||||
|
||||
/* Affix Lines Block (Canonical Blue Lines) */
|
||||
.item-affix-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.tooltip-stat-line.line-blue {
|
||||
color: #4880ff;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 6px rgba(72, 128, 255, 0.4);
|
||||
}
|
||||
.tooltip-stat-line.line-white {
|
||||
color: #e0d5c1;
|
||||
}
|
||||
.tooltip-stat-line.line-gold {
|
||||
color: #c8a15a;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tooltip-stat-line.line-green {
|
||||
color: #00e600;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tooltip-stat-line.line-orange {
|
||||
color: #ff8c00;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Navigation -->
|
||||
<header>
|
||||
<h1>
|
||||
<span>DIABLO II</span>
|
||||
<span class="brand-badge">BOSS KILL SIMULATOR</span>
|
||||
<span style="font-size: 11px; color: var(--text-dim); font-weight: normal; margin-left: 8px;">1.13c 幕首领战利品与词缀生成器</span>
|
||||
</h1>
|
||||
<nav>
|
||||
<a href="./acts.html">🏰 旷野与战役 (Acts)</a>
|
||||
<a href="./arena.html">⚔️ 竞技场 (Arena)</a>
|
||||
<a href="./loot.html">📊 掉落几率计算器 (Loot)</a>
|
||||
<a href="./boss.html" class="active">💀 首领击杀模拟 (Boss Sim)</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Workspace -->
|
||||
<div class="app-layout">
|
||||
<!-- Left Controls Sidebar -->
|
||||
<aside class="sidebar-controls">
|
||||
<!-- 1. Act Boss Selector -->
|
||||
<div class="control-section">
|
||||
<div class="section-header">
|
||||
<span>选择幕首领 (Act Boss)</span>
|
||||
<span style="color: var(--gold-primary); font-size: 10px;">Acts 1-5</span>
|
||||
</div>
|
||||
|
||||
<!-- Boss List -->
|
||||
<div class="boss-grid" id="boss-list-container">
|
||||
<!-- Rendered by src/boss.ts -->
|
||||
</div>
|
||||
|
||||
<!-- Difficulty Select -->
|
||||
<div class="diff-pills-row">
|
||||
<button type="button" class="diff-pill" data-diff="normal">普通 (Normal)</button>
|
||||
<button type="button" class="diff-pill" data-diff="nightmare">噩梦 (Nightmare)</button>
|
||||
<button type="button" class="diff-pill active" data-diff="hell">地狱 (Hell)</button>
|
||||
</div>
|
||||
|
||||
<!-- Quest Kill Toggle -->
|
||||
<div class="quest-row">
|
||||
<label class="quest-label">
|
||||
<input type="checkbox" id="quest-kill-toggle" checked />
|
||||
<span>任务首杀判定 (Quest Kill / Bug Kill)</span>
|
||||
</label>
|
||||
<span style="color: #ffd700; font-weight: bold; font-size: 10px;">移除垃圾掉落</span>
|
||||
</div>
|
||||
|
||||
<!-- Selected Boss Info Card -->
|
||||
<div class="boss-spec-box">
|
||||
<div class="boss-spec-header">
|
||||
<span class="boss-spec-name" id="boss-preview-name">墨菲斯托 (Mephisto)</span>
|
||||
<span class="boss-quest-badge quest-on" id="boss-preview-quest">🌟 首杀判定启用</span>
|
||||
</div>
|
||||
<div id="boss-preview-title" style="color: var(--gold-primary); font-weight: 600; margin-bottom: 2px;">第三幕 · 憎恨之王 [HELL]</div>
|
||||
<div id="boss-preview-area" style="color: var(--text-dim); margin-bottom: 4px;">憎恨囚牢第三层</div>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 4px;">
|
||||
<span id="boss-preview-mlvl" style="color: #ff9d6c; font-weight: 700;">mlvl 87</span>
|
||||
<span id="boss-preview-tc" style="color: var(--text-dim);">TC: Mephistoq (H)</span>
|
||||
</div>
|
||||
<div id="boss-preview-desc" style="color: var(--text-muted); font-size: 10px; margin-top: 4px; line-height: 1.4;"></div>
|
||||
<div style="margin-top: 6px; padding-top: 4px; border-top: 1px dashed #201811; color: var(--gold-light); font-size: 10px;">
|
||||
<strong>核心产出:</strong><span id="boss-preview-highlights">军帽、暴风之盾、眼球、海蛇皮</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Player Parameters (PP / MF) -->
|
||||
<div class="control-section">
|
||||
<div class="section-header">
|
||||
<span>角色属性与房间 (Settings)</span>
|
||||
<span style="color: var(--text-muted); font-size: 10px;">PP数 & 寻宝率</span>
|
||||
</div>
|
||||
|
||||
<!-- Players Count (PP) -->
|
||||
<div class="param-group">
|
||||
<div class="param-header">
|
||||
<span class="param-title">房间人数 (Players Count)</span>
|
||||
<span class="param-val-badge" id="players-val">1P</span>
|
||||
</div>
|
||||
<input type="range" class="param-slider" id="players-range" min="1" max="8" step="1" value="1" />
|
||||
<div class="param-pills-row">
|
||||
<button type="button" class="pp-pill active" data-pp="1">1P (单人)</button>
|
||||
<button type="button" class="pp-pill" data-pp="3">3P</button>
|
||||
<button type="button" class="pp-pill" data-pp="5">5P</button>
|
||||
<button type="button" class="pp-pill" data-pp="7">7P (极低NoDrop)</button>
|
||||
<button type="button" class="pp-pill" data-pp="8">8P (满人)</button>
|
||||
</div>
|
||||
<div class="param-tip" id="tip-nodrop">1P 模式下单次挑选无掉落率: 18.7%</div>
|
||||
</div>
|
||||
|
||||
<!-- Magic Find (MF) -->
|
||||
<div class="param-group">
|
||||
<div class="param-header">
|
||||
<span class="param-title">更加寻获魔法装备 (Magic Find %)</span>
|
||||
<input type="number" id="mf-input" min="0" max="1000" step="10" value="250" style="width: 68px; background: #0c0805; border: 1px solid var(--border-gold); color: var(--gold-bright); text-align: center; border-radius: 3px; font-weight: 700;" />
|
||||
</div>
|
||||
<input type="range" class="param-slider" id="mf-range" min="0" max="1000" step="10" value="250" />
|
||||
<div class="param-pills-row">
|
||||
<button type="button" class="mf-preset-btn" data-mf="0">0%</button>
|
||||
<button type="button" class="mf-preset-btn" data-mf="150">150%</button>
|
||||
<button type="button" class="mf-preset-btn" data-mf="250">250% (推荐)</button>
|
||||
<button type="button" class="mf-preset-btn" data-mf="400">400%</button>
|
||||
<button type="button" class="mf-preset-btn" data-mf="600">600%</button>
|
||||
</div>
|
||||
<div class="param-tip" id="tip-mf">实际寻宝率 250% · 暗金等效修正: +125%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Actions -->
|
||||
<div class="control-section" style="background: transparent; border: none; padding: 0;">
|
||||
<div class="actions-container">
|
||||
<button type="button" class="btn-big-kill" id="btn-kill-boss">
|
||||
<span>💥</span>
|
||||
<span>模拟击杀 Boss (Kill Boss)</span>
|
||||
<span style="font-size: 11px; opacity: 0.75; font-weight: normal;">[空格键]</span>
|
||||
</button>
|
||||
<div class="btn-sub-actions">
|
||||
<button type="button" class="btn-sub-action" id="btn-kill-10">⚔️ 连杀 10 次</button>
|
||||
<button type="button" class="btn-sub-action" id="btn-clear-loot">🔄 清空战利品</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Session Stats Board -->
|
||||
<div class="control-section">
|
||||
<div class="section-header">
|
||||
<span>战利品统计 (Session Stats)</span>
|
||||
<span style="color: var(--text-muted); font-size: 10px;">累计战报</span>
|
||||
</div>
|
||||
<div class="stats-board-grid">
|
||||
<div class="stat-chip">
|
||||
<span class="stat-chip-label">击杀首领次数</span>
|
||||
<span class="stat-chip-val" id="stat-total-kills">0</span>
|
||||
</div>
|
||||
<div class="stat-chip">
|
||||
<span class="stat-chip-label">掉落装备总数</span>
|
||||
<span class="stat-chip-val" id="stat-total-items">0</span>
|
||||
</div>
|
||||
<div class="stat-chip unique">
|
||||
<span class="stat-chip-label">暗金装备 (Unique)</span>
|
||||
<span class="stat-chip-val" id="stat-uniques">0</span>
|
||||
</div>
|
||||
<div class="stat-chip set">
|
||||
<span class="stat-chip-label">绿色套装 (Set)</span>
|
||||
<span class="stat-chip-val" id="stat-sets">0</span>
|
||||
</div>
|
||||
<div class="stat-chip rare">
|
||||
<span class="stat-chip-label">黄金装备 (Rare)</span>
|
||||
<span class="stat-chip-val" id="stat-rares">0</span>
|
||||
</div>
|
||||
<div class="stat-chip">
|
||||
<span class="stat-chip-label">魔法装备 (Magic)</span>
|
||||
<span class="stat-chip-val" id="stat-magics" style="color: var(--q-magic);">0</span>
|
||||
</div>
|
||||
<div class="stat-chip rune">
|
||||
<span class="stat-chip-label">高级符文 (Rune)</span>
|
||||
<span class="stat-chip-val" id="stat-runes">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right Loot Showcase -->
|
||||
<main class="loot-showcase" id="loot-showcase-panel">
|
||||
<!-- Kill Result Banner -->
|
||||
<div class="kill-banner-box" id="kill-result-banner">
|
||||
<div class="banner-main">
|
||||
<span class="banner-icon">⚔️</span>
|
||||
<span class="banner-title">击杀模拟器已就绪</span>
|
||||
<span class="banner-sub">请在左侧选择幕首领并点击「模拟击杀 Boss」生成对应物品与属性</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loot Grid -->
|
||||
<div class="loot-grid" id="loot-items-grid">
|
||||
<!-- Rendered by src/boss.ts -->
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Script Entry -->
|
||||
<script type="module" src="/src/boss.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -974,6 +974,7 @@
|
|||
<nav>
|
||||
<a href="/acts.html">🎮 战役漫步 (acts.html)</a>
|
||||
<a href="/arena.html">⚔️ 战斗竞技场 (arena.html)</a>
|
||||
<a href="/boss.html">💀 幕首领击杀 (boss.html)</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,564 @@
|
|||
/**
|
||||
* 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 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 descCn: string
|
||||
readonly highlightDrops: string
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
descCn: '终局最高首领,地狱难度怪物等级 mlvl 99!能掉落全暗黑2所有最高等级的宝藏(tc87/qlvl85全部封顶物品:风之力、泰瑞尔的力量、祖父等)。',
|
||||
highlightDrops: '风之力 (Windforce)、泰瑞尔的力量、祖父、死亡之网、全符文',
|
||||
},
|
||||
]
|
||||
|
||||
// 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 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()
|
||||
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', () => {
|
||||
renderLootShowcase([])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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 {
|
||||
const container = document.getElementById('loot-items-grid')
|
||||
if (!container) return
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-loot-placeholder">
|
||||
<div class="placeholder-icon">⚔️</div>
|
||||
<div class="placeholder-title">尚未击杀首领</div>
|
||||
<div class="placeholder-desc">点击左侧「模拟击杀 Boss」或按下 [空格键] 体验 1.13c 真实掉落与词缀属性生成</div>
|
||||
</div>
|
||||
`
|
||||
return
|
||||
}
|
||||
|
||||
// Render Item Cards
|
||||
container.innerHTML = items.map((item, idx) => {
|
||||
const tooltip = formatItemTooltip(item)
|
||||
return renderSingleItemCard(tooltip, idx + 1)
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function renderSingleItemCard(t: FormattedItemTooltip, index: number): string {
|
||||
const linesHtml = t.lines.map(l => {
|
||||
const colorClass = `line-${l.color || 'blue'}`
|
||||
return `<div class="tooltip-stat-line ${colorClass}">${escapeHtml(l.text)}</div>`
|
||||
}).join('')
|
||||
|
||||
const baseStatsHtml: string[] = []
|
||||
if (t.defense) baseStatsHtml.push(`<div class="base-stat-item">防御: <span class="val-white">${t.defense}</span></div>`)
|
||||
if (t.oneHandDamage) baseStatsHtml.push(`<div class="base-stat-item">单手伤害: <span class="val-white">${t.oneHandDamage.min} 到 ${t.oneHandDamage.max}</span></div>`)
|
||||
if (t.twoHandDamage) baseStatsHtml.push(`<div class="base-stat-item">双手伤害: <span class="val-white">${t.twoHandDamage.min} 到 ${t.twoHandDamage.max}</span></div>`)
|
||||
if (t.durability) baseStatsHtml.push(`<div class="base-stat-item">耐久度: <span class="val-white">${t.durability.current} / ${t.durability.max}</span></div>`)
|
||||
if (t.reqLevel) baseStatsHtml.push(`<div class="base-stat-item req">需要等级: <span class="val-white">${t.reqLevel}</span></div>`)
|
||||
if (t.reqStr) baseStatsHtml.push(`<div class="base-stat-item req">需要力量: <span class="val-white">${t.reqStr}</span></div>`)
|
||||
if (t.reqDex) baseStatsHtml.push(`<div class="base-stat-item req">需要敏捷: <span class="val-white">${t.reqDex}</span></div>`)
|
||||
|
||||
return `
|
||||
<div class="d2-item-card quality-${t.quality.toLowerCase()}">
|
||||
<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};">${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, '&')
|
||||
.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()
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -355,9 +355,11 @@ export function createDroppedItem(
|
|||
uniqueId: options.dwInitSeed,
|
||||
durability: maxDurability,
|
||||
maxDurability,
|
||||
...(options.uniqueItem ? { uniqueId: options.dwInitSeed } : {}),
|
||||
...(options.setItem ? { setId: options.setItem.id } : {}),
|
||||
}
|
||||
...(options.uniqueItem ? { uniqueId: options.dwInitSeed, uniqueItemDef: options.uniqueItem } : {}),
|
||||
...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}),
|
||||
...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}),
|
||||
...(options.magicAffixes ? { rolledMagicAffixes: options.magicAffixes } : {}),
|
||||
} as Item
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -403,7 +405,7 @@ export function executeDropPipeline(
|
|||
} else if (typeof options.monsterRng === 'number') {
|
||||
rng = new D2Rng(options.monsterRng >>> 0)
|
||||
} else {
|
||||
rng = new D2Rng()
|
||||
rng = new D2Rng((Math.random() * 0xFFFFFFFF) >>> 0, (Math.random() * 0xFFFFFFFF) >>> 0)
|
||||
}
|
||||
|
||||
// 3. Expand TC tree
|
||||
|
|
@ -449,9 +451,22 @@ export function executeDropPipeline(
|
|||
const dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0)
|
||||
const itemRng = new D2Rng(dwInitSeed)
|
||||
|
||||
// Check if the item can roll magical qualities (Weapons, Armor, Rings, Amulets, Jewels, Charms)
|
||||
const canHaveQuality =
|
||||
effectiveBase.kind === 'weapon' ||
|
||||
effectiveBase.kind === 'armor' ||
|
||||
effectiveBase.id === 'rin' ||
|
||||
effectiveBase.id === 'amu' ||
|
||||
effectiveBase.id === 'jew' ||
|
||||
effectiveBase.id === 'cm1' ||
|
||||
effectiveBase.id === 'cm2' ||
|
||||
effectiveBase.id === 'cm3'
|
||||
|
||||
// Roll quality
|
||||
let quality: ItemQualityTier
|
||||
if (leaf.forcedUnique) {
|
||||
if (!canHaveQuality) {
|
||||
quality = 'normal'
|
||||
} else if (leaf.forcedUnique) {
|
||||
quality = 'unique'
|
||||
} else if (leaf.forcedSet) {
|
||||
quality = 'set'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Embedded Diablo II 1.13c Drop Tables Provider.
|
||||
*
|
||||
* Provides a self-contained, zero-network, zero-MPQ implementation of DropTables
|
||||
* using the canonical embedded 1.13c Blizzard data tables.
|
||||
* This guarantees instantaneous load times across any static HTTP server (e.g. port 8080,
|
||||
* file protocol, or headless production environments) without partial Range request failures.
|
||||
*/
|
||||
|
||||
import {
|
||||
RAW_TC_EX,
|
||||
RAW_WEAPONS,
|
||||
RAW_ARMOR,
|
||||
RAW_MISC,
|
||||
RAW_ITEM_RATIO,
|
||||
RAW_ITEM_TYPES,
|
||||
RAW_UNIQUE_ITEMS,
|
||||
RAW_SET_ITEMS,
|
||||
RAW_SETS,
|
||||
RAW_MAGIC_PREFIX,
|
||||
RAW_MAGIC_SUFFIX,
|
||||
RAW_RARE_PREFIX,
|
||||
RAW_RARE_SUFFIX,
|
||||
} from '../data/canonical-drop-data.ts'
|
||||
import { parseTable } from './acts.ts'
|
||||
import { parseTreasureClassTable, type TreasureClassNode } from './treasure-class.ts'
|
||||
import { parseWeaponsTable } from './weapons.ts'
|
||||
import { parseArmorTable } from './armor.ts'
|
||||
import { parseMiscTable } from './misc-items.ts'
|
||||
import { parseItemRatioTable } from './item-ratio.ts'
|
||||
import { parseUniqueItemsTable } from './unique-items.ts'
|
||||
import { parseSetItemsTable, parseSetsTable } from './set-items.ts'
|
||||
import { parseMagicAffixesTable } from './affixes.ts'
|
||||
import { parseRareNamesTable } from './rare-names.ts'
|
||||
import { parseItemTypesTable, isA } from './item-types.ts'
|
||||
import { generateAutoTreasureClasses, autoTcToTreasureClassNode } from './auto-tc.ts'
|
||||
import {
|
||||
DifficultyLevelsTable,
|
||||
DEFAULT_DIFFICULTY_LEVEL_ODDS,
|
||||
type DifficultyLevelOdds,
|
||||
CaseInsensitiveMap,
|
||||
} from './item-upgrade.ts'
|
||||
import type { DropTables } from './drop-pipeline.ts'
|
||||
import type { ItemBase } from './items.ts'
|
||||
|
||||
let _cachedDropTables: DropTables | null = null
|
||||
|
||||
/**
|
||||
* Returns the fully-initialized, canonical 1.13c DropTables.
|
||||
* Initialized synchronously on first call (< 120ms) and memoized for all subsequent calls.
|
||||
*/
|
||||
export function getEmbeddedDropTables(): DropTables {
|
||||
if (_cachedDropTables) {
|
||||
return _cachedDropTables
|
||||
}
|
||||
|
||||
const enc = new TextEncoder()
|
||||
const tcTable = parseTreasureClassTable(parseTable(enc.encode(RAW_TC_EX)))
|
||||
const weapons = parseWeaponsTable(parseTable(enc.encode(RAW_WEAPONS)))
|
||||
const armor = parseArmorTable(parseTable(enc.encode(RAW_ARMOR)))
|
||||
const misc = parseMiscTable(parseTable(enc.encode(RAW_MISC)))
|
||||
const itemRatio = parseItemRatioTable(parseTable(enc.encode(RAW_ITEM_RATIO)))
|
||||
const itemTypes = parseItemTypesTable(parseTable(enc.encode(RAW_ITEM_TYPES)))
|
||||
const uniques = parseUniqueItemsTable(parseTable(enc.encode(RAW_UNIQUE_ITEMS)))
|
||||
const sets = {
|
||||
items: parseSetItemsTable(parseTable(enc.encode(RAW_SET_ITEMS))),
|
||||
sets: parseSetsTable(parseTable(enc.encode(RAW_SETS))),
|
||||
}
|
||||
const magicAffixes = {
|
||||
prefixes: parseMagicAffixesTable(parseTable(enc.encode(RAW_MAGIC_PREFIX)), true),
|
||||
suffixes: parseMagicAffixesTable(parseTable(enc.encode(RAW_MAGIC_SUFFIX)), false),
|
||||
}
|
||||
const rareNames = {
|
||||
prefixes: parseRareNamesTable(parseTable(enc.encode(RAW_RARE_PREFIX))),
|
||||
suffixes: parseRareNamesTable(parseTable(enc.encode(RAW_RARE_SUFFIX))),
|
||||
}
|
||||
|
||||
const rawAuto = generateAutoTreasureClasses(itemTypes, armor, weapons)
|
||||
const autoTcTable = new Map<string, TreasureClassNode>()
|
||||
for (const [k, v] of rawAuto) {
|
||||
autoTcTable.set(k, autoTcToTreasureClassNode(v))
|
||||
}
|
||||
|
||||
const isAPredicate = (type: string, target: string): boolean => isA(type, target, itemTypes)
|
||||
const getBase = (code: string): ItemBase | undefined => weapons.get(code) ?? armor.get(code) ?? misc.get(code)
|
||||
|
||||
const oddsMap = new CaseInsensitiveMap<DifficultyLevelOdds>()
|
||||
for (const [k, v] of Object.entries(DEFAULT_DIFFICULTY_LEVEL_ODDS)) {
|
||||
oddsMap.set(k, v)
|
||||
}
|
||||
const difficultyLevels = new DifficultyLevelsTable(Object.values(DEFAULT_DIFFICULTY_LEVEL_ODDS), oddsMap)
|
||||
|
||||
_cachedDropTables = {
|
||||
tcTable,
|
||||
autoTcTable,
|
||||
weapons,
|
||||
armor,
|
||||
misc,
|
||||
itemRatio,
|
||||
difficultyLevels,
|
||||
uniques,
|
||||
sets,
|
||||
magicAffixes,
|
||||
rareNames,
|
||||
itemTypes,
|
||||
monsterKinds: new Map(),
|
||||
superUniques: new Map(),
|
||||
isA: isAPredicate,
|
||||
getBase,
|
||||
}
|
||||
|
||||
return _cachedDropTables!
|
||||
}
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
/**
|
||||
* Diablo II Item Tooltip and Formatted Attribute Generator.
|
||||
*
|
||||
* Formats dropped Item instances into rich, canonical Diablo II tooltip data:
|
||||
* - Quality colors and tiered title (Unique gold, Set green, Rare yellow, Magic blue, Rune orange, Gem purple)
|
||||
* - Base equipment stats (Defense, 1H/2H Damage, Speed, Durability, Required Str/Dex/Level, ilvl)
|
||||
* - Rolled magic modifiers, affix lines, and unique/set fixed modifiers formatted with authentic D2 strings
|
||||
* - Sockets, rune effects, and gem socketing bonuses
|
||||
*/
|
||||
|
||||
import type { Item, ItemBase } from './items.ts'
|
||||
import type { UniqueItem, UniqueItemProp } from './unique-items.ts'
|
||||
import type { SetItem, SetItemProp } from './set-items.ts'
|
||||
import type { GeneratedAffixes, GeneratedRareAffixes, RolledAffix, RolledMod } from './affix-generator.ts'
|
||||
|
||||
export interface FormattedStatLine {
|
||||
text: string
|
||||
color?: 'blue' | 'white' | 'gold' | 'green' | 'orange' | 'yellow' | 'red' | 'gray'
|
||||
}
|
||||
|
||||
export interface FormattedItemTooltip {
|
||||
title: string
|
||||
subTitle?: string | undefined
|
||||
quality: string
|
||||
qualityColor: string
|
||||
baseType: string
|
||||
ilvl: number
|
||||
reqLevel?: number | undefined
|
||||
reqStr?: number | undefined
|
||||
reqDex?: number | undefined
|
||||
defense?: number | undefined
|
||||
oneHandDamage?: { min: number; max: number } | undefined
|
||||
twoHandDamage?: { min: number; max: number } | undefined
|
||||
durability?: { current: number; max: number } | undefined
|
||||
sockets?: number | undefined
|
||||
lines: FormattedStatLine[]
|
||||
setPieces?: string[] | undefined
|
||||
setBonuses?: FormattedStatLine[] | undefined
|
||||
flavorText?: string | undefined
|
||||
}
|
||||
|
||||
// Canonical Chinese Translations for Common Base Names
|
||||
const BASE_NAMES_CN: Record<string, string> = {
|
||||
// Common Boss Drops & Favorites
|
||||
uap: '军帽 (Shako)',
|
||||
xap: '战帽 (War Hat)',
|
||||
cap: '便帽 (Cap)',
|
||||
uit: '统治者大盾 (Monarch)',
|
||||
xit: '冷酷之盾 (Grim Shield)',
|
||||
kit: '轻盾 (Kite Shield)',
|
||||
uui: '海蛇皮甲 (Serpentskin Armor)',
|
||||
xui: '鬼魂战甲 (Ghost Armor)',
|
||||
qui: '皮甲 (Leather Armor)',
|
||||
aar: '古代装甲 (Ancient Armor)',
|
||||
uar: '神圣盔甲 (Sacred Armor)',
|
||||
xar: '法师铠甲 (Mage Plate)',
|
||||
ful: '全铠甲 (Full Plate Mail)',
|
||||
gth: '哥特战甲 (Gothic Plate)',
|
||||
rin: '戒指 (Ring)',
|
||||
amu: '项链 (Amulet)',
|
||||
jew: '珠宝 (Jewel)',
|
||||
cm1: '小型护身符 (Small Charm)',
|
||||
cm2: '大型护身符 (Large Charm)',
|
||||
cm3: '超大型护身符 (Grand Charm)',
|
||||
ob4: '涡流水晶 (Swirling Crystal)',
|
||||
'7cr': '幻化之刃 (Phase Blade)',
|
||||
crs: '水晶剑 (Crystal Sword)',
|
||||
'9cr': '空间之刃 (Dimensional Blade)',
|
||||
'7wa': '狂战士斧 (Berserker Axe)',
|
||||
hax: '手斧 (Hand Axe)',
|
||||
'9ha': '长柄斧 (Hatchet)',
|
||||
'7gd': '巨神之刃 (Colossus Blade)',
|
||||
gsd: '巨剑 (Great Sword)',
|
||||
'7fl': '连枷 (Flail)',
|
||||
fla: '连枷 (Flail)',
|
||||
'9fl': '铁刺锤 (Knout)',
|
||||
'7ws': '天罚之锤 (Scourge)',
|
||||
gld: '金币 (Gold)',
|
||||
tsc: '城镇传送卷轴 (Town Portal Scroll)',
|
||||
isc: '辨识卷轴 (Identify Scroll)',
|
||||
hp1: '微型生命药剂',
|
||||
hp2: '轻型生命药剂',
|
||||
hp3: '中型生命药剂',
|
||||
hp4: '强效生命药剂',
|
||||
hp5: '超级生命药剂',
|
||||
mp1: '微型法力药剂',
|
||||
mp2: '轻型法力药剂',
|
||||
mp3: '中型法力药剂',
|
||||
mp4: '强效法力药剂',
|
||||
mp5: '超级法力药剂',
|
||||
rvs: '恢复药剂 (35%)',
|
||||
rvl: '全面恢复药剂 (100%)',
|
||||
aqv: '箭矢 (Arrows)',
|
||||
cqv: '十字弓弹 (Bolts)',
|
||||
key: '钥匙 (Key)',
|
||||
}
|
||||
|
||||
// Rune Names & Socket Effects
|
||||
const RUNE_INFO: Record<string, { num: number; nameCn: string; nameEn: string; lvl: number; weapon: string; armor: string }> = {
|
||||
r01: { num: 1, nameCn: '艾尔 (El)', nameEn: 'El Rune', lvl: 11, weapon: '+50 命中率, +1 点照亮范围', armor: '+15 防御, +1 点照亮范围' },
|
||||
r02: { num: 2, nameCn: '艾德 (Eld)', nameEn: 'Eld Rune', lvl: 11, weapon: '+75% 对不死生物伤害, +50 针对不死生物命中率', armor: '降低体力消耗 15% (盾牌: +7% 格挡率)' },
|
||||
r03: { num: 3, nameCn: '特尔 (Tir)', nameEn: 'Tir Rune', lvl: 13, weapon: '+2 点法力在每杀一个敌人后获得', armor: '+2 点法力在每杀一个敌人后获得' },
|
||||
r04: { num: 4, nameCn: '那夫 (Nef)', nameEn: 'Nef Rune', lvl: 13, weapon: '击退目标 (Knockback)', armor: '+30 对远距离攻击防御' },
|
||||
r05: { num: 5, nameCn: '爱斯 (Eth)', nameEn: 'Eth Rune', lvl: 15, weapon: '-25% 目标防御', armor: '法力回复速度 +15%' },
|
||||
r06: { num: 6, nameCn: '伊夫 (Ith)', nameEn: 'Ith Rune', lvl: 15, weapon: '+9 最大伤害', armor: '15% 受损生命转化为法力' },
|
||||
r07: { num: 7, nameCn: '塔尔 (Tal)', nameEn: 'Tal Rune', lvl: 17, weapon: '+75 毒素伤害持续 5 秒', armor: '抗毒 +30% (盾牌: 抗毒 +35%)' },
|
||||
r08: { num: 8, nameCn: '拉尔 (Ral)', nameEn: 'Ral Rune', lvl: 19, weapon: '增加 5-30 火焰伤害', armor: '抗火 +30% (盾牌: 抗火 +35%)' },
|
||||
r09: { num: 9, nameCn: '欧特 (Ort)', nameEn: 'Ort Rune', lvl: 21, weapon: '增加 1-50 闪电伤害', armor: '抗电 +30% (盾牌: 抗电 +35%)' },
|
||||
r10: { num: 10, nameCn: '书尔 (Thul)', nameEn: 'Thul Rune', lvl: 23, weapon: '增加 3-14 冰冷伤害 (持续 3 秒)', armor: '抗寒 +30% (盾牌: 抗寒 +35%)' },
|
||||
r11: { num: 11, nameCn: '安姆 (Amn)', nameEn: 'Amn Rune', lvl: 25, weapon: '每次命中偷取 7% 生命', armor: '攻击者受到 14 点反弹伤害' },
|
||||
r12: { num: 12, nameCn: '索尔 (Sol)', nameEn: 'Sol Rune', lvl: 27, weapon: '+9 最小伤害', armor: '物理伤害减少 7' },
|
||||
r13: { num: 13, nameCn: '夏 (Shael)', nameEn: 'Shael Rune', lvl: 29, weapon: '20% 提升攻击速度 (IAS)', armor: '20% 快速打击恢复 (盾牌: 20% 快速格挡率)' },
|
||||
r14: { num: 14, nameCn: '多尔 (Dol)', nameEn: 'Dol Rune', lvl: 31, weapon: '击中使怪物逃跑 (25%)', armor: '生命恢复 +7' },
|
||||
r15: { num: 15, nameCn: '海尔 (Hel)', nameEn: 'Hel Rune', lvl: 0, weapon: '装备需求 -20%', armor: '装备需求 -15%' },
|
||||
r16: { num: 16, nameCn: '埃欧 (Io)', nameEn: 'Io Rune', lvl: 35, weapon: '+10 体力', armor: '+10 体力' },
|
||||
r17: { num: 17, nameCn: '卢姆 (Lum)', nameEn: 'Lum Rune', lvl: 37, weapon: '+10 精力', armor: '+10 精力' },
|
||||
r18: { num: 18, nameCn: '科 (Ko)', nameEn: 'Ko Rune', lvl: 39, weapon: '+10 敏捷', armor: '+10 敏捷' },
|
||||
r19: { num: 19, nameCn: '法尔 (Fal)', nameEn: 'Fal Rune', lvl: 41, weapon: '+10 力量', armor: '+10 力量' },
|
||||
r20: { num: 20, nameCn: '蓝姆 (Lem)', nameEn: 'Lem Rune', lvl: 43, weapon: '75% 额外金币从怪物身上获得', armor: '50% 额外金币从怪物身上获得' },
|
||||
r21: { num: 21, nameCn: '普尔 (Pul)', nameEn: 'Pul Rune', lvl: 45, weapon: '+75% 对恶魔伤害, +100 对恶魔命中率', armor: '+30% 增强防御' },
|
||||
r22: { num: 22, nameCn: '乌姆 (Um)', nameEn: 'Um Rune', lvl: 47, weapon: '25% 几率造成伤口流血 (OW)', armor: '所有抗性 +15 (盾牌: 所有抗性 +22)' },
|
||||
r23: { num: 23, nameCn: '马尔 (Mal)', nameEn: 'Mal Rune', lvl: 49, weapon: '防止怪物自愈', armor: '魔法伤害减少 7' },
|
||||
r24: { num: 24, nameCn: '伊司特 (Ist)', nameEn: 'Ist Rune', lvl: 51, weapon: '30% 更加寻获魔法装备 (MF)', armor: '25% 更加寻获魔法装备 (MF)' },
|
||||
r25: { num: 25, nameCn: '古尔 (Gul)', nameEn: 'Gul Rune', lvl: 53, weapon: '20% 额外攻击命中率', armor: '+5% 最大毒素抗性' },
|
||||
r26: { num: 26, nameCn: '伐克斯 (Vex)', nameEn: 'Vex Rune', lvl: 55, weapon: '每次命中偷取 7% 法力', armor: '+5% 最大火焰抗性' },
|
||||
r27: { num: 27, nameCn: '欧姆 (Ohm)', nameEn: 'Ohm Rune', lvl: 57, weapon: '+50% 增强伤害 (ED)', armor: '+5% 最大冰冷抗性' },
|
||||
r28: { num: 28, nameCn: '罗 (Lo)', nameEn: 'Lo Rune', lvl: 59, weapon: '20% 几率造成双倍打击 (DS)', armor: '+5% 最大闪电抗性' },
|
||||
r29: { num: 29, nameCn: '瑟 (Sur)', nameEn: 'Sur Rune', lvl: 61, weapon: '击中使目标目盲', armor: '最大法力提升 5% (盾牌: +50 法力)' },
|
||||
r30: { num: 30, nameCn: '贝 (Ber)', nameEn: 'Ber Rune', lvl: 63, weapon: '20% 几率产生压碎打击 (CB)', armor: '物理伤害减少 8% (DR)' },
|
||||
r31: { num: 31, nameCn: '乔 (Jah)', nameEn: 'Jah Rune', lvl: 65, weapon: '无视目标防御力 (ITD)', armor: '最大生命提升 5% (盾牌: +50 生命)' },
|
||||
r32: { num: 32, nameCn: '查姆 (Cham)', nameEn: 'Cham Rune', lvl: 67, weapon: '击中使目标冻结 +3', armor: '无法被冻结 (Cannot be Frozen)' },
|
||||
r33: { num: 33, nameCn: '萨德 (Zod)', nameEn: 'Zod Rune', lvl: 69, weapon: '无法破坏 (Indestructible)', armor: '无法破坏 (Indestructible)' },
|
||||
}
|
||||
|
||||
// Property Code Formatter
|
||||
export function formatPropertyCode(code: string, min: number, max: number, par?: string | number): string {
|
||||
const isRange = max > min
|
||||
const valStr = isRange ? `${min}-${max}` : `${min}`
|
||||
const plusVal = min > 0 ? `+${valStr}` : `${valStr}`
|
||||
|
||||
switch (code.toLowerCase()) {
|
||||
case 'allskills':
|
||||
return `${plusVal} 所有技能`
|
||||
case 'skilltab':
|
||||
return `${plusVal} 技能分类 (${par ?? ''})`
|
||||
case 'classskills':
|
||||
return `${plusVal} 职业技能 (${par ?? ''})`
|
||||
case 'skill':
|
||||
return `${plusVal} 到技能 [${par ?? ''}]`
|
||||
case 'str':
|
||||
return `${plusVal} 力量`
|
||||
case 'dex':
|
||||
return `${plusVal} 敏捷`
|
||||
case 'vit':
|
||||
return `${plusVal} 体力`
|
||||
case 'enr':
|
||||
return `${plusVal} 精力`
|
||||
case 'all-stats':
|
||||
return `${plusVal} 所有属性`
|
||||
case 'hp':
|
||||
return `${plusVal} 生命`
|
||||
case 'mana':
|
||||
return `${plusVal} 法力`
|
||||
case 'hp/lvl':
|
||||
return `+${(min / 8).toFixed(1)}~${((min / 8) * 99).toFixed(0)} 生命 (依角色等级决定)`
|
||||
case 'mana/lvl':
|
||||
return `+${(min / 8).toFixed(1)}~${((min / 8) * 99).toFixed(0)} 法力 (依角色等级决定)`
|
||||
case 'ac':
|
||||
return `${plusVal} 防御力`
|
||||
case 'ac%':
|
||||
return `${plusVal}% 增强防御`
|
||||
case 'dmg%':
|
||||
return `${plusVal}% 增强伤害 (ED)`
|
||||
case 'dmg-min':
|
||||
return `+${valStr} 最小伤害值`
|
||||
case 'dmg-max':
|
||||
return `+${valStr} 最大伤害值`
|
||||
case 'res-all':
|
||||
return `所有抗性 ${plusVal}%`
|
||||
case 'res-fire':
|
||||
case 'fireresist':
|
||||
return `抗火 ${plusVal}%`
|
||||
case 'res-cold':
|
||||
case 'coldresist':
|
||||
return `抗寒 ${plusVal}%`
|
||||
case 'res-ltng':
|
||||
case 'lightresist':
|
||||
return `抗电 ${plusVal}%`
|
||||
case 'res-pois':
|
||||
case 'poisonresist':
|
||||
return `抗毒 ${plusVal}%`
|
||||
case 'fcr':
|
||||
return `${plusVal}% 快速施法速度 (FCR)`
|
||||
case 'fhr':
|
||||
return `${plusVal}% 快速打击恢复 (FHR)`
|
||||
case 'ias':
|
||||
return `${plusVal}% 提升攻击速度 (IAS)`
|
||||
case 'frw':
|
||||
return `${plusVal}% 快速移动/奔跑 (FRW)`
|
||||
case 'fbr':
|
||||
return `${plusVal}% 快速格挡率 (FBR)`
|
||||
case 'mag%':
|
||||
return `${plusVal}% 更加寻获魔法装备 (MF)`
|
||||
case 'gold%':
|
||||
return `${plusVal}% 额外金币从怪物身上获得`
|
||||
case 'lifesteal':
|
||||
case 'leech':
|
||||
return `每次击中偷取 ${valStr}% 生命`
|
||||
case 'manasteal':
|
||||
return `每次击中偷取 ${valStr}% 法力`
|
||||
case 'crush':
|
||||
return `${valStr}% 几率产生压碎打击 (CB)`
|
||||
case 'openwounds':
|
||||
return `${valStr}% 几率造成伤口流血 (OW)`
|
||||
case 'deadly':
|
||||
return `${valStr}% 几率造成双倍打击 (DS)`
|
||||
case 'dmag':
|
||||
return `伤害减少 ${valStr}% (物免)`
|
||||
case 'dmag-ac':
|
||||
return `物理伤害减少 ${valStr}`
|
||||
case 'mag-ac':
|
||||
return `魔法伤害减少 ${valStr}`
|
||||
case 'sock':
|
||||
return `凹槽 (${valStr})`
|
||||
case 'indestruct':
|
||||
return `无法破坏`
|
||||
case 'no-freeze':
|
||||
case 'nofreeze':
|
||||
return `无法被冻结`
|
||||
case 'half-freeze':
|
||||
return `冻结时间减半`
|
||||
case 'rep-life':
|
||||
case 'regen':
|
||||
return `生命恢复 +${valStr}`
|
||||
case 'regen-mana':
|
||||
case 'manaregen':
|
||||
return `法力恢复速度 +${valStr}%`
|
||||
case 'pierce':
|
||||
return `穿透攻击 (${valStr}%)`
|
||||
case 'ignore-ac':
|
||||
return `无视目标防御力`
|
||||
case 'freeze':
|
||||
return `击中使目标冰冻 +${valStr}`
|
||||
case 'slow':
|
||||
return `使目标减速 ${valStr}%`
|
||||
case 'att':
|
||||
return `${plusVal} 攻击命中率`
|
||||
case 'att%':
|
||||
return `${plusVal}% 额外攻击命中率`
|
||||
case 'dmg-fire':
|
||||
return `增加 ${min}-${max} 点火焰伤害`
|
||||
case 'dmg-cold':
|
||||
return `增加 ${min}-${max} 点冰冷伤害`
|
||||
case 'dmg-ltng':
|
||||
return `增加 ${min}-${max} 点闪电伤害`
|
||||
case 'dmg-pois':
|
||||
return `增加 ${min} 点毒素伤害,持续 ${max} 秒`
|
||||
case 'dmg-mag':
|
||||
return `增加 ${min}-${max} 点魔法伤害`
|
||||
default:
|
||||
return `${code}: ${plusVal}${par ? ` (${par})` : ''}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates formatted tooltip structure for any dropped Diablo II Item.
|
||||
*/
|
||||
export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
||||
const base = item.base
|
||||
const quality = (item.rarity || 'normal').toLowerCase()
|
||||
const ilvl = item.level ?? item.ilvl ?? 1
|
||||
|
||||
let title = item.name
|
||||
let subTitle: string | undefined = undefined
|
||||
let qualityColor: string = '#ffffff'
|
||||
const lines: FormattedStatLine[] = []
|
||||
|
||||
// Check if this is a Rune
|
||||
const isRune = item.code?.trim().startsWith('r') && Number(item.code.trim().slice(1)) >= 1 && Number(item.code.trim().slice(1)) <= 33
|
||||
const runeKey = item.code?.trim().toLowerCase()
|
||||
const runeData = isRune && runeKey ? RUNE_INFO[runeKey] : undefined
|
||||
|
||||
if (runeData) {
|
||||
title = `${runeData.num}# ${runeData.nameCn}`
|
||||
subTitle = runeData.nameEn
|
||||
qualityColor = '#ff8c00'
|
||||
lines.push({ text: `可以镶嵌在有凹槽的装备中`, color: 'white' })
|
||||
lines.push({ text: `武器: ${runeData.weapon}`, color: 'blue' })
|
||||
lines.push({ text: `防具/盾牌: ${runeData.armor}`, color: 'blue' })
|
||||
return {
|
||||
title,
|
||||
subTitle,
|
||||
quality: '符文 (Rune)',
|
||||
qualityColor,
|
||||
baseType: '符文',
|
||||
ilvl,
|
||||
reqLevel: runeData.lvl > 0 ? runeData.lvl : undefined,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is Gold
|
||||
if (base.id === 'gld' || item.name.toLowerCase().includes('gold')) {
|
||||
title = `${(item.stats.amount ?? item.value ?? 100).toLocaleString()} 金币 (Gold)`
|
||||
qualityColor = '#ffd700'
|
||||
return {
|
||||
title,
|
||||
quality: '金币',
|
||||
qualityColor,
|
||||
baseType: '金币',
|
||||
ilvl: 1,
|
||||
lines: [{ text: '可用于向城镇商贩交易、赌博或雇佣兵复活', color: 'white' }],
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve Base Chinese Name
|
||||
const baseNameCn = BASE_NAMES_CN[base.id] ?? base.name
|
||||
|
||||
// Determine Quality Presentation
|
||||
if (quality === 'unique') {
|
||||
qualityColor = '#c8a15a'
|
||||
const uniqDef = item.uniqueItemDef as UniqueItem | undefined
|
||||
if (uniqDef) {
|
||||
title = uniqDef.index
|
||||
subTitle = `${baseNameCn}`
|
||||
// Add Unique Properties
|
||||
for (const prop of uniqDef.props) {
|
||||
if (prop.code) {
|
||||
lines.push({
|
||||
text: formatPropertyCode(prop.code, prop.min, prop.max, prop.par),
|
||||
color: 'blue',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
subTitle = baseNameCn
|
||||
}
|
||||
} else if (quality === 'set') {
|
||||
qualityColor = '#00e600'
|
||||
const setDef = item.setItemDef as SetItem | undefined
|
||||
if (setDef) {
|
||||
title = setDef.index
|
||||
subTitle = `${baseNameCn} · [${setDef.set}]`
|
||||
for (const prop of setDef.props) {
|
||||
if (prop.code) {
|
||||
lines.push({
|
||||
text: formatPropertyCode(prop.code, prop.min, prop.max, prop.par),
|
||||
color: 'blue',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
subTitle = baseNameCn
|
||||
}
|
||||
} else if (quality === 'rare') {
|
||||
qualityColor = '#ffff60'
|
||||
subTitle = baseNameCn
|
||||
const rareDef = item.rolledRareAffixes as GeneratedRareAffixes | undefined
|
||||
if (rareDef && rareDef.affixes) {
|
||||
title = rareDef.name
|
||||
for (const aff of rareDef.affixes) {
|
||||
for (const mod of aff.mods) {
|
||||
lines.push({
|
||||
text: formatPropertyCode(mod.code, mod.min, mod.max),
|
||||
color: 'blue',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (quality === 'magic') {
|
||||
qualityColor = '#4880ff'
|
||||
const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined
|
||||
if (magicDef) {
|
||||
title = magicDef.name
|
||||
subTitle = baseNameCn
|
||||
if (magicDef.prefix) {
|
||||
for (const m of magicDef.prefix.mods) {
|
||||
lines.push({ text: formatPropertyCode(m.code, m.min, m.max), color: 'blue' })
|
||||
}
|
||||
}
|
||||
if (magicDef.suffix) {
|
||||
for (const m of magicDef.suffix.mods) {
|
||||
lines.push({ text: formatPropertyCode(m.code, m.min, m.max), color: 'blue' })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
subTitle = baseNameCn
|
||||
}
|
||||
} else if (quality === 'superior') {
|
||||
qualityColor = '#ffffff'
|
||||
title = `超强的 ${baseNameCn}`
|
||||
lines.push({ text: '+15% 增强防御 / +15% 增强伤害', color: 'blue' })
|
||||
} else {
|
||||
qualityColor = '#e0d5c1'
|
||||
title = baseNameCn
|
||||
}
|
||||
|
||||
// Equipment Base Stats
|
||||
const reqLevel = (base as any).levelreq > 0 ? (base as any).levelreq : undefined
|
||||
const reqStr = (base as any).reqstr > 0 ? (base as any).reqstr : undefined
|
||||
const reqDex = (base as any).reqdex > 0 ? (base as any).reqdex : undefined
|
||||
const defense = base.defense > 0 ? (item.stats.defense ?? base.defense) : undefined
|
||||
const oneHandDamage = (base as any).mindam && (base as any).maxdam
|
||||
? { min: (base as any).mindam, max: (base as any).maxdam }
|
||||
: undefined
|
||||
const twoHandDamage = (base as any).twoHandedMindam && (base as any).twoHandedMaxdam
|
||||
? { min: (base as any).twoHandedMindam, max: (base as any).twoHandedMaxdam }
|
||||
: undefined
|
||||
const durability = item.durability && item.maxDurability
|
||||
? { current: item.durability, max: item.maxDurability }
|
||||
: undefined
|
||||
const sockets = (base as any).gemsockets > 0 ? (base as any).gemsockets : undefined
|
||||
|
||||
return {
|
||||
title,
|
||||
subTitle,
|
||||
quality,
|
||||
qualityColor,
|
||||
baseType: base.kind === 'weapon' ? '武器' : base.kind === 'armor' ? '防具' : '杂项/首饰',
|
||||
ilvl,
|
||||
reqLevel,
|
||||
reqStr,
|
||||
reqDex,
|
||||
defense,
|
||||
oneHandDamage,
|
||||
twoHandDamage,
|
||||
durability,
|
||||
sockets,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
|
@ -371,6 +371,14 @@ export interface Item {
|
|||
readonly quantity?: number | undefined
|
||||
/** Total number of sockets (4 bits, 1..6) if socketed flag is set. */
|
||||
readonly totalSockets?: number | undefined
|
||||
/** Definition for Unique items from UniqueItems.txt. */
|
||||
readonly uniqueItemDef?: any | undefined
|
||||
/** Definition for Set items from SetItems.txt. */
|
||||
readonly setItemDef?: any | undefined
|
||||
/** Rolled affixes and modifiers for Rare items. */
|
||||
readonly rolledRareAffixes?: any | undefined
|
||||
/** Rolled affixes and modifiers for Magic items. */
|
||||
readonly rolledMagicAffixes?: any | undefined
|
||||
|
||||
// --- Stat Modifier Lists ---
|
||||
/** Base stat list entries (each 9-bit statId + params/values, terminated by 0x1FF). */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
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, 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(/武器|防具/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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).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).toContain('30#')
|
||||
expect(runeTooltip.quality).toContain('符文')
|
||||
expect(runeTooltip.lines.some(l => l.text.includes('压碎打击'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -30,6 +30,8 @@ describe('Scene DropTables Preload & Decoupling (Issue #117)', () => {
|
|||
|
||||
it('GameEngine allows starting with dropTables undefined on frame 0 and injecting later via setDropTables', () => {
|
||||
const dummyTerrain = {
|
||||
widthPx: 1000,
|
||||
heightPx: 1000,
|
||||
overlap: () => 0,
|
||||
raycast: () => null,
|
||||
findPath: () => [],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export default defineConfig({
|
|||
acts: new URL('./acts.html', import.meta.url).pathname,
|
||||
arena: new URL('./arena.html', import.meta.url).pathname,
|
||||
loot: resolve(__dirname, 'loot.html'),
|
||||
boss: resolve(__dirname, 'boss.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue