chore: remove standalone simulator pages (arena, loot, boss, skills) and related entry code

This commit is contained in:
troytt 2026-09-28 08:09:37 +00:00
parent 4914b4f770
commit a100885552
17 changed files with 89 additions and 10494 deletions

View File

@ -39,7 +39,7 @@ src/render/ WebGL2 渲染器(多图集、动态光照着色、加法/常规
src/scene/ 页面级装配(act-scene 主游戏循环、A* 鼠标控制器、跨图过渡;资源包优先,失败回退到直读归档)
src/data/ 1.13c 权威内嵌数据表(canonical-drop-data / canonical-tbl-strings / canonical-vendor-data / canonical-quest-data / skills-catalog / gitea-skill-issues.json)
scripts/ 资源包多进程烘焙(地图/UI/动画/实体/投射物/叠加特效)、格式/数据/部署校验、无头浏览器全量走查、夹具生成
*.html 入口页面:acts.html(游戏主页面)、arena.html(战斗数值与技能竞技场)、skills.html(技能加点模拟器)、loot.html(掉落模拟器)、boss.html(幕首领战利品模拟器)
*.html 入口页面:index.html(双语选择门户)、acts.html / acts-zh.html / acts-en.html(中英战役主页面)、bnet.html(原版片头与战网完整流程)
```
## 美术资源与图集资产(Assets)
@ -210,11 +210,9 @@ npm run dev # 启动 Vite:http://127.
| 页面 | 用途 |
| --- | --- |
| `/acts.html`(或访问根路径 `/`) | **主游戏页面**:全屏 WebGL2 画布 + Glide 动态光影 + 等距精灵自动地图/小地图 + HUD 控制底栏 + 左右停靠全套功能面板 + 7 职业漫步战斗与打宝交易 |
| `/arena.html` | **战斗数值与技能测试竞技场**:攻防数值数学模型、技能加成、伤害计算器(1.13c 原版公式验证) |
| `/skills.html` | **全职业技能加点模拟器**:7 大职业 210 个技能树加点、协同加成与 1.13c 技能数值实时预览 |
| `/loot.html` | **掉落模拟器与精确概率计算器**:Maxroll 风格宝藏等级(TC)掉落模拟、MF 收益曲线与物品概率查询 |
| `/boss.html` | **幕首领击杀与战利品属性模拟器**:五幕首领击杀掉落模拟与暗金/套装/词缀属性检视 |
| `/index.html`(或访问根路径 `/`) | **双语版本选择门户页**:选择简体中文版(`CHI.tbl`)或英文原版(`ENG.tbl`)进入庇护之地 |
| `/acts.html` / `/acts-zh.html` / `/acts-en.html` | **战役主场景(中/英双语)**:全屏 WebGL2 画布 + Glide 动态光影 + 等距精灵自动地图/小地图 + HUD 控制底栏 + 左右停靠全套功能面板 + 7 职业漫步战斗与打宝交易 |
| `/bnet.html` | **原版前端与战网(Battle.net)7 阶段完整流程页**:800×600 原版片头、主菜单、战网登录、篝火 7 职业角色创建/选择、大厅建房并无缝进入第一幕罗格营地 |
#### 页面查询参数(`/acts.html`)

View File

@ -1,780 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>d2web · 战斗数学与攻防测试竞技场(Issue #24 / M9)</title>
<style>
:root {
color-scheme: dark;
--bg-main: #0b0906;
--bg-panel: rgba(22, 18, 14, 0.92);
--border-color: #382d22;
--gold-primary: #c8a15a;
--gold-light: #e8dcc8;
--text-dim: #9c8d78;
--btn-bg: #221c15;
--btn-hover: #3a2f24;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg-main);
color: var(--gold-light);
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
overflow: hidden;
}
/* 顶部导航 */
header {
height: 42px;
background: #14100c;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
header h1 {
margin: 0;
font-size: 14px;
color: var(--gold-primary);
letter-spacing: 0.06em;
display: flex;
align-items: center;
gap: 8px;
}
header a {
color: var(--gold-primary);
text-decoration: none;
margin-left: 12px;
}
header a:hover { text-decoration: underline; }
/* 三栏主容器 */
#app-container {
display: grid;
grid-template-columns: 310px 1fr 310px;
height: calc(100vh - 42px);
overflow: hidden;
}
/* 侧边设定栏 */
.sidebar {
background: var(--bg-panel);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.sidebar.right-sidebar {
border-right: none;
border-left: 1px solid var(--border-color);
}
.panel-header {
padding: 10px 14px;
background: #1a1510;
border-bottom: 1px solid var(--border-color);
color: var(--gold-primary);
font-weight: bold;
font-size: 13px;
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-content {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
}
/* 表单元素 */
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.form-row-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px;
}
label {
font-size: 11px;
color: var(--text-dim);
}
input, select {
background: #18140f;
border: 1px solid var(--border-color);
color: var(--gold-light);
padding: 4px 8px;
border-radius: 4px;
font: inherit;
font-size: 12px;
}
input[type="number"] {
font-variant-numeric: tabular-nums;
}
input:focus, select:focus {
outline: none;
border-color: var(--gold-primary);
}
.badge-section {
border-top: 1px dashed var(--border-color);
padding-top: 8px;
margin-top: 4px;
}
.section-title {
font-size: 11px;
color: var(--gold-primary);
margin-bottom: 6px;
font-weight: 600;
}
/* 中间主视口 */
#center-area {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* 画布容器 */
#viewport-wrapper {
position: relative;
flex: 1;
min-height: 280px;
background: #000;
overflow: hidden;
border-bottom: 1px solid var(--border-color);
}
#view {
width: 100%;
height: 100%;
display: block;
}
/* 操作控制栏 */
#action-bar {
background: #15110d;
padding: 8px 14px;
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.btn {
background: var(--btn-bg);
border: 1px solid var(--border-color);
color: var(--gold-light);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font: inherit;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.15s ease;
}
.btn:hover {
background: var(--btn-hover);
border-color: var(--gold-primary);
color: #fff;
}
.btn:active {
transform: translateY(1px);
}
.btn.primary {
background: #3d2b18;
border-color: #8c6a38;
color: #ffdfa9;
font-weight: bold;
}
.btn.primary:hover {
background: #543b20;
border-color: var(--gold-primary);
}
.btn.spell-fire { color: #ff8c5a; }
.btn.spell-cold { color: #72c2ff; }
.btn.spell-light { color: #ffe66d; }
.btn.spell-magic { color: #df9aff; }
.btn.danger { color: #ff6b6b; }
/* 公式日志分解面板 */
#log-panel {
height: 200px;
background: #0d0b08;
display: flex;
flex-direction: column;
overflow: hidden;
}
.log-header {
padding: 6px 12px;
background: #16120e;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: var(--text-dim);
}
#log-entries {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
font-size: 12px;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.log-item {
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #1f1913;
}
.log-item.hit { color: #c8e6c9; }
.log-item.miss { color: #78909c; }
.log-item.blocked { color: #90caf9; }
.log-item.crit { color: #ffe082; }
/* 状态与指示器 */
.tag {
display: inline-block;
padding: 1px 5px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
}
.tag.immune { background: #b71c1c; color: #fff; }
.tag.res { background: #37474f; color: #cfd8dc; }
/* 快捷键提示 */
.key-pill {
display: inline-block;
background: #2b2319;
border: 1px solid #4a3c2c;
border-radius: 3px;
padding: 0 4px;
font-size: 10px;
color: var(--gold-primary);
}
</style>
</head>
<body>
<header>
<h1>
<span>⚔️</span>
<span>暗黑破坏神 2 · 真实战斗数学与攻防测试竞技场</span>
<span style="font-size: 11px; color: var(--text-dim); font-weight: normal;">(Issue #24 / M9)</span>
</h1>
<div>
<a href="/acts.html">🎮 返回战役漫步 (acts.html)</a>
</div>
</header>
<div id="app-container">
<!-- 左栏:角色属性设定 -->
<aside class="sidebar" id="player-sidebar">
<div class="panel-header">
<span>🧙 玩家角色数值 (Hero)</span>
<select id="char-preset" style="padding: 2px 4px; font-size: 11px;">
<option value="custom">-- 快捷预设模版 --</option>
<option value="novice">🔰 Lv1 新手物理角色</option>
<option value="zealot">⚔️ Lv45 圣骑士狂热狂徒</option>
<option value="sorc_fire">🔥 Lv75 纯火系法师</option>
<option value="sorc_orb">❄️ Lv80 冰风暴法师</option>
<option value="hell_slayer">👑 Lv90 地狱狂战杀手</option>
</select>
</div>
<div class="panel-content">
<div class="form-row">
<div class="form-group">
<label>职业 (Class)</label>
<select id="char-class">
<option value="Sorceress">女巫 (Sorceress)</option>
<option value="Barbarian">野蛮人 (Barbarian)</option>
<option value="Paladin">圣骑士 (Paladin)</option>
<option value="Amazon">亚马逊 (Amazon)</option>
<option value="Necromancer">死灵法师 (Necromancer)</option>
</select>
</div>
<div class="form-group">
<label>角色等级 (1–99)</label>
<input type="number" id="char-level" min="1" max="99" value="30" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>生命值 (HP / Max)</label>
<input type="number" id="char-hp" min="1" max="9999" value="350" />
</div>
<div class="form-group">
<label>法力值 (Mana / Max)</label>
<input type="number" id="char-mana" min="0" max="9999" value="200" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>攻击准确率 (AR / ToHit)</label>
<input type="number" id="char-ar" min="0" max="99999" value="1200" />
</div>
<div class="form-group">
<label>角色防御力 (DR / AC)</label>
<input type="number" id="char-dr" min="0" max="99999" value="650" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>基础物理武器最小伤害</label>
<input type="number" id="char-dmg-min" min="1" max="9999" value="25" />
</div>
<div class="form-group">
<label>基础物理武器最大伤害</label>
<input type="number" id="char-dmg-max" min="1" max="9999" value="65" />
</div>
</div>
<div class="badge-section">
<div class="section-title">✨ 附加元素伤害 (Added Elemental Dmg)</div>
<div class="form-row">
<div class="form-group">
<label>🔥 火焰伤害 (Fire)</label>
<input type="number" id="char-fire" min="0" max="9999" value="15" />
</div>
<div class="form-group">
<label>❄️ 冰冷伤害 (Cold)</label>
<input type="number" id="char-cold" min="0" max="9999" value="10" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>⚡ 闪电伤害 (Light)</label>
<input type="number" id="char-light" min="0" max="9999" value="25" />
</div>
<div class="form-group">
<label>☠️ 毒素伤害 (Poison)</label>
<input type="number" id="char-poison" min="0" max="9999" value="0" />
</div>
</div>
<div class="form-group">
<label>🔮 纯魔法伤害 (Magic)</label>
<input type="number" id="char-magic" min="0" max="9999" value="0" />
</div>
</div>
<div class="badge-section">
<div class="section-title">⚡ 暴击、压碎与撕裂 (CS / DS / CB / OW)</div>
<div class="form-row">
<div class="form-group">
<label>技能致命 CS % (Skill)</label>
<input type="number" id="char-cs" min="0" max="100" value="20" />
</div>
<div class="form-group">
<label>装备双倍 DS % (Item)</label>
<input type="number" id="char-ds" min="0" max="100" value="15" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>压碎性打击 CB %</label>
<input type="number" id="char-cb" min="0" max="100" value="25" />
</div>
<div class="form-group">
<label>撕开伤口 OW % (8秒流血)</label>
<input type="number" id="char-ow" min="0" max="100" value="25" />
</div>
</div>
<div class="form-row" style="margin-top: 4px;">
<div class="form-group">
<label style="display:flex; align-items:center; gap:6px; cursor:pointer; color:#81c784; font-size:11px;">
<input type="checkbox" id="char-pmh" checked />
<span>阻止怪物自愈 (PMH: 80分锁血)</span>
</label>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:6px; cursor:pointer; color:var(--text-dim); font-size:11px;">
<input type="checkbox" id="char-is-merc" />
<span>雇佣兵出手 (PMH无效)</span>
</label>
</div>
</div>
</div>
<div class="badge-section">
<div class="section-title">⚡ 元素破免与光环诅咒 (1/5th Rule & -% Res)</div>
<div class="form-row">
<div class="form-group">
<label>审判光环 Conviction -%</label>
<input type="number" id="char-conviction" min="0" max="150" value="0" placeholder="如无限: 85" />
</div>
<div class="form-group">
<label>降低抗性 Lower Resist -%</label>
<input type="number" id="char-lower-res" min="0" max="100" value="0" placeholder="如诅咒: 60" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>装备直减电抗 -% (Griffon)</label>
<input type="number" id="char-minus-light-res" min="0" max="100" value="0" />
</div>
<div class="form-group">
<label>装备直减火抗 -% (Facet)</label>
<input type="number" id="char-minus-fire-res" min="0" max="100" value="0" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>装备直减冰抗 -%</label>
<input type="number" id="char-minus-cold-res" min="0" max="100" value="0" />
</div>
<div class="form-group">
<label>装备直减毒抗 -% (Death's Web)</label>
<input type="number" id="char-minus-poison-res" min="0" max="100" value="0" />
</div>
</div>
</div>
<div class="badge-section">
<div class="section-title">🛑 状态异常与控制 (Slow / HBT / HCMTF)</div>
<div class="form-row">
<div class="form-group">
<label>减速目标 Slows Target %</label>
<input type="number" id="char-slow" min="0" max="100" value="0" placeholder="如水魔: 75" />
</div>
<div class="form-group">
<label>致盲目标 HBT (微暗灵视 +X)</label>
<input type="number" id="char-hbt" min="0" max="20" value="0" placeholder="如+1致盲" />
</div>
</div>
<div class="form-group">
<label>击中使怪逃跑 HCMTF % (恐吓5秒)</label>
<input type="number" id="char-hcmtf" min="0" max="100" value="0" placeholder="如25%" />
</div>
</div>
<div class="badge-section">
<div class="section-title">🗡️ 悔恨纯伤与离体增伤 (Off-Weapon ED)</div>
<div class="form-row">
<div class="form-group">
<label>绝对基础纯伤害 (+Dmg 悔恨)</label>
<input type="number" id="char-flat-dmg" min="0" max="999" value="0" placeholder="如悔恨: 400" />
</div>
<div class="form-group">
<label>离体增伤 ED % (刚毅/狂热)</label>
<input type="number" id="char-off-weapon-ed" min="0" max="2000" value="0" placeholder="如刚毅: 300" />
</div>
</div>
<div class="form-group">
<label>属性点力敏增伤 % (Str/Dex Bonus)</label>
<input type="number" id="char-stat-bonus" min="0" max="1000" value="0" />
</div>
</div>
<div class="badge-section">
<div class="section-title">🎯 破防、吸取与控制 (ITD / Leech / Freeze)</div>
<div class="form-row">
<div class="form-group">
<label>偷取生命 LL %</label>
<input type="number" id="char-ll" min="0" max="100" value="10" />
</div>
<div class="form-group">
<label>偷取法力 ML %</label>
<input type="number" id="char-ml" min="0" max="100" value="5" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>降低目标防御 % (-% Def)</label>
<input type="number" id="char-def-red" min="0" max="100" value="25" />
</div>
<div class="form-group">
<label>冻结目标 +X (Freezes)</label>
<input type="number" id="char-freeze" min="0" max="20" value="1" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>击退几率 % (Knockback)</label>
<input type="number" id="char-knockback" min="0" max="100" value="25" />
</div>
<div class="form-group" style="justify-content: flex-end; padding-bottom: 2px;">
<label style="display:flex; align-items:center; gap:6px; cursor:pointer; color:var(--gold-primary); font-size:12px;">
<input type="checkbox" id="char-itd" />
<span>无视目标防御 (ITD)</span>
</label>
</div>
</div>
<div class="form-group">
<label>游戏难度 (Difficulty)</label>
<select id="game-difficulty">
<option value="normal">普通难度 (Normal: 100% 汲取)</option>
<option value="nightmare">噩梦难度 (Nightmare: 50% 汲取惩罚)</option>
<option value="hell">地狱难度 (Hell: 33% 汲取惩罚)</option>
</select>
</div>
</div>
<div class="badge-section">
<div class="section-title">🛡️ 防护与格挡 (Defense & Reductions)</div>
<div class="form-row-3">
<div class="form-group">
<label>格挡率 %</label>
<input type="number" id="char-block" min="0" max="75" value="45" />
</div>
<div class="form-group">
<label>物减 (Flat DR)</label>
<input type="number" id="char-flat-dr" min="0" max="999" value="2" />
</div>
<div class="form-group">
<label>魔减 (Flat MDR)</label>
<input type="number" id="char-flat-mdr" min="0" max="999" value="2" />
</div>
</div>
</div>
</div>
</aside>
<!-- 中间主视区 -->
<main id="center-area">
<!-- 画布视区 -->
<div id="viewport-wrapper">
<canvas id="view"></canvas>
</div>
<!-- 动作操作栏 -->
<div id="action-bar">
<button class="btn primary" id="btn-attack" title="快捷键: 1 或 空格">
<span>⚔️ 物理挥砍</span>
<span class="key-pill">1/Space</span>
</button>
<button class="btn spell-fire" id="btn-fire" title="快捷键: 2">
<span>🔥 火球法术</span>
<span class="key-pill">2</span>
</button>
<button class="btn spell-cold" id="btn-cold" title="快捷键: 3">
<span>❄️ 冰风暴</span>
<span class="key-pill">3</span>
</button>
<button class="btn spell-light" id="btn-light" title="快捷键: 4">
<span>⚡ 充能连电</span>
<span class="key-pill">4</span>
</button>
<button class="btn spell-magic" id="btn-magic" title="快捷键: 5">
<span>🔮 纯魔法矢</span>
<span class="key-pill">5</span>
</button>
<button class="btn" id="btn-monster-attack" title="测试怪物殴打玩家">
<span>👹 怪物攻击玩家</span>
</button>
<button class="btn" id="btn-auto-battle" title="快捷键: A">
<span id="auto-battle-label">🔄 开启自动对决</span>
<span class="key-pill">A</span>
</button>
<button class="btn danger" id="btn-reset" title="快捷键: R">
<span>🩺 满血重置</span>
<span class="key-pill">R</span>
</button>
</div>
<!-- 公式推导明细面板 -->
<div id="log-panel">
<div class="log-header">
<span>📐 实时战斗数学公式推导与日志 (Combat Math Provenance)</span>
<div>
<span id="stat-dps" style="margin-right: 12px; color: var(--gold-primary);">DPS: 0.0</span>
<button class="btn" id="btn-clear-log" style="padding: 2px 8px; font-size: 11px;">清空日志</button>
</div>
</div>
<div id="log-entries"></div>
</div>
</main>
<!-- 右栏:怪物属性设定 -->
<aside class="sidebar right-sidebar" id="monster-sidebar">
<div class="panel-header">
<span>👹 怪物对手设定 (Monster Target)</span>
<span id="monster-badge" class="tag" style="background:#423223; color:#f0c070;">NORMAL</span>
</div>
<div class="panel-content">
<div class="form-group">
<label>怪物原型种类 (Type)</label>
<select id="mon-type">
<option value="fallen1">沉沦魔 (Fallen) [恶魔]</option>
<option value="zombie1">僵尸 (Zombie) [不死,弱火]</option>
<option value="quillrat1">刺鼠 (Quill Rat) [动物]</option>
<option value="skeleton1">骷髅兵 (Skeleton) [不死,毒抗]</option>
<option value="corruptrogue1">黑暗猎手 (Dark Hunter) [恶魔]</option>
<option value="goatman1">羊头人 (Moon Clan) [动物]</option>
<option value="bloodraven">血鸟 (Blood Raven) [暗金Boss]</option>
<option value="andariel">安达利尔 (Andariel) [Act 1 Boss,弱火强毒]</option>
<option value="duriel">督瑞尔 (Duriel) [Act 2 Boss,强冷抗]</option>
<option value="mephisto">墨菲斯托 (Mephisto) [Act 3 Boss]</option>
<option value="diablo">暗黑破坏神 (Diablo) [Act 4 Boss]</option>
<option value="baal">巴尔 (Baal) [Act 5 Boss]</option>
<option value="dummy">测伤木桩 (Training Dummy) [全零抗/无敌]</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>怪物级别 (Tier)</label>
<select id="mon-tier">
<option value="normal">普通小怪 (Normal)</option>
<option value="boss">关底首领/暗金 (Boss/SuperUnique)</option>
<option value="player">对阵玩家 (Player PvP)</option>
</select>
</div>
<div class="form-group">
<label>目标姿态 (Posture)</label>
<select id="mon-posture">
<option value="stand">站立/攻击 (Standing)</option>
<option value="run">奔跑中 (Running: 防御=0/格挡1/3)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>怪物体型 Size (击退几率)</label>
<select id="mon-size">
<option value="small">小型怪 Small (100% 击退几率)</option>
<option value="medium" selected>中型怪 Medium (50% 击退几率)</option>
<option value="large">大型怪 Large (25% 击退几率)</option>
</select>
</div>
<div class="form-group">
<label>自然回血速率 (RegenRate/4096)</label>
<input type="number" id="mon-regen" min="0" max="100" value="0" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label style="display:flex; align-items:center; gap:6px; cursor:pointer; color:#ef5350; font-size:11px;">
<input type="checkbox" id="mon-is-uber" />
<span>超级火炬 Boss (Pandemonium Uber: 免疫PMH)</span>
</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>怪物等级 (Level 1–99)</label>
<input type="number" id="mon-level" min="1" max="99" value="28" />
</div>
<div class="form-group">
<label>格挡几率 % (Block)</label>
<input type="number" id="mon-block" min="0" max="75" value="0" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>生命值 (HP / Max)</label>
<input type="number" id="mon-hp" min="1" max="999999" value="180" />
</div>
<div class="form-group">
<label>汲取效率 % (Drain Eff)</label>
<input type="number" id="mon-drain-eff" min="0" max="100" value="100" />
</div>
</div>
<div class="form-group">
<label>最大生命 (Max HP)</label>
<input type="number" id="mon-max-hp" min="1" max="999999" value="180" />
</div>
<div class="form-row">
<div class="form-group">
<label>防御力 (DR / AC)</label>
<input type="number" id="mon-dr" min="0" max="99999" value="480" />
</div>
<div class="form-group">
<label>命中等级 (AR / ToHit)</label>
<input type="number" id="mon-ar" min="0" max="99999" value="520" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>怪物攻击最小伤害</label>
<input type="number" id="mon-dmg-min" min="1" max="9999" value="12" />
</div>
<div class="form-group">
<label>怪物攻击最大伤害</label>
<input type="number" id="mon-dmg-max" min="1" max="9999" value="28" />
</div>
</div>
<div class="badge-section">
<div class="section-title">🛡️ 各系元素抗性 (-100% ~ 150%, >=100% 即为免疫)</div>
<div class="form-row">
<div class="form-group">
<label>🛡️ 物理抗性 (Phys %)</label>
<input type="number" id="mon-res-phys" min="-100" max="200" value="0" />
</div>
<div class="form-group">
<label>🔥 火焰抗性 (Fire %)</label>
<input type="number" id="mon-res-fire" min="-100" max="200" value="0" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>❄️ 冰冷抗性 (Cold %)</label>
<input type="number" id="mon-res-cold" min="-100" max="200" value="0" />
</div>
<div class="form-group">
<label>⚡ 闪电抗性 (Light %)</label>
<input type="number" id="mon-res-light" min="-100" max="200" value="0" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>☠️ 毒素抗性 (Poison %)</label>
<input type="number" id="mon-res-poison" min="-100" max="200" value="0" />
</div>
<div class="form-group">
<label>🔮 魔法抗性 (Magic %)</label>
<input type="number" id="mon-res-magic" min="-100" max="200" value="0" />
</div>
</div>
</div>
<div class="badge-section">
<div class="section-title">📊 怪物减伤 (Damage Reduction)</div>
<div class="form-row">
<div class="form-group">
<label>物理减免 (Flat DR)</label>
<input type="number" id="mon-flat-dr" min="0" max="999" value="0" />
</div>
<div class="form-group">
<label>魔法减免 (Flat MDR)</label>
<input type="number" id="mon-flat-mdr" min="0" max="999" value="0" />
</div>
</div>
</div>
</div>
</aside>
</div>
<script type="module" src="/src/arena.ts"></script>
</body>
</html>

924
boss.html
View File

@ -1,924 +0,0 @@
<!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;
}
/* Showcase Toolbar & Language Switcher (Issue #122) */
.showcase-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
background: #0d0906;
border: 1px solid #2a1c11;
border-radius: 6px;
padding: 8px 14px;
margin-bottom: 4px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-icon {
font-size: 14px;
}
.toolbar-title {
font-size: 12px;
font-weight: 700;
color: var(--gold-bright);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.lang-switch-row {
display: flex;
align-items: center;
gap: 6px;
}
.lang-label {
font-size: 11px;
color: var(--text-dim);
margin-right: 4px;
}
.lang-pill {
background: #18110b;
border: 1px solid #3d2a1b;
color: #aa9c8d;
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.lang-pill:hover {
background: #24170e;
border-color: var(--border-gold);
color: var(--gold-bright);
}
.lang-pill.active {
background: linear-gradient(180deg, #44260d 0%, #2e1806 100%);
border-color: #ff9800;
color: #ffe082;
box-shadow: 0 0 8px rgba(255, 152, 0, 0.35);
}
/* 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, .d2-item-card.quality-暗金 { border-color: rgba(200, 161, 90, 0.5); background: linear-gradient(180deg, #161009 0%, #0a0704 100%); }
.d2-item-card.quality-set, .d2-item-card.quality-套装 { border-color: rgba(0, 230, 0, 0.4); background: linear-gradient(180deg, #091609 0%, #040a04 100%); }
.d2-item-card.quality-rare, .d2-item-card.quality-亮金 { border-color: rgba(255, 255, 96, 0.4); background: linear-gradient(180deg, #161609 0%, #0a0a04 100%); }
.d2-item-card.quality-magic, .d2-item-card.quality-魔法 { border-color: rgba(72, 128, 255, 0.4); background: linear-gradient(180deg, #09101c 0%, #04070c 100%); }
.d2-item-card.quality-rune, .d2-item-card.quality-符文 { border-color: rgba(255, 140, 0, 0.5); background: linear-gradient(180deg, #180f07 0%, #0a0603 100%); }
.d2-item-card.quality-gem, .d2-item-card.quality-宝石 { border-color: rgba(220, 220, 220, 0.4); background: linear-gradient(180deg, #141416 0%, #08080a 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 .val-blue {
color: #6888ff;
font-weight: 700;
}
.base-stat-item .val-base {
color: #9e9382;
font-weight: 500;
}
.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;
}
.tooltip-stat-line.line-red {
color: #d03a3a;
font-weight: 700;
margin-top: 6px;
}
.tooltip-stat-line.line-instruction {
color: #e0d5c1;
margin-bottom: 6px;
}
.tooltip-stat-line.line-gray {
color: #999999;
}
.tooltip-stat-line.line-yellow {
color: #ffff00;
}
</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>
<!-- Showcase Toolbar / Language Switcher -->
<div class="showcase-toolbar">
<div class="toolbar-left">
<span class="toolbar-icon">💎</span>
<span class="toolbar-title">战利品展示面板</span>
</div>
<div class="lang-switch-row">
<span class="lang-label">装备语言:</span>
<button type="button" class="lang-pill active" data-lang="en">EN 原版</button>
<button type="button" class="lang-pill" data-lang="zh">中文</button>
<button type="button" class="lang-pill" data-lang="bilingual">EN / ZH 双语</button>
</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>

1331
loot.html

File diff suppressed because it is too large Load Diff

View File

@ -1,183 +0,0 @@
import { spawn } from 'child_process'
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function runBrowserAudit() {
console.log('1. Starting Google Chrome headless...')
const chromeProc = spawn('/usr/bin/google-chrome', [
'--headless=new',
'--remote-debugging-port=9222',
'--no-sandbox',
'--disable-gpu',
'--disable-extensions',
'http://localhost:8080/boss.html',
])
// Wait for Chrome to be ready
let wsUrl = null
for (let i = 0; i < 30; i++) {
await sleep(300)
try {
const res = await fetch('http://127.0.0.1:9222/json/list')
const pages = await res.json()
const page = pages.find(p => p.type === 'page' && p.url.includes('boss.html'))
if (page && page.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch (e) {
// keep trying
}
}
if (!wsUrl) {
chromeProc.kill()
throw new Error('Failed to connect to Chrome headless CDP within 10s')
}
console.log('2. Connected to Chrome CDP on page:', wsUrl)
const ws = new WebSocket(wsUrl)
await new Promise(resolve => ws.onopen = resolve)
let idCounter = 1
function sendCommand(method, params = {}) {
return new Promise((resolve, reject) => {
const id = idCounter++
const handler = event => {
const msg = JSON.parse(event.data)
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(msg.error)
else resolve(msg.result)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
}
// Enable Runtime
await sendCommand('Runtime.enable')
// Wait 1.5s for initial drop tables to load
await sleep(1500)
console.log('3. Triggering Chinese language switch and 10x Boss Kill in browser DOM...')
const evalResult = await sendCommand('Runtime.evaluate', {
expression: `(async () => {
// 1. Click Chinese language pill
const zhPill = document.querySelector('.lang-pill[data-lang="zh"]');
if (zhPill) zhPill.click();
// 2. Click 10x kill button
const kill10Btn = document.getElementById('btn-kill-10');
if (kill10Btn) kill10Btn.click();
// Wait a short moment for DOM rendering
await new Promise(r => setTimeout(r, 800));
// 3. Extract items
const cards = Array.from(document.querySelectorAll('#loot-items-grid .d2-item-card'));
return cards.map(c => {
const title = c.querySelector('.item-title')?.textContent?.trim() || '';
const subTitle = c.querySelector('.item-subtitle')?.textContent?.trim() || '';
const quality = c.querySelector('.item-quality-pill')?.textContent?.trim() || '';
const ilvl = c.querySelector('.item-ilvl-pill')?.textContent?.trim() || '';
const stats = Array.from(c.querySelectorAll('.item-base-stats .base-stat-item')).map(s => s.textContent.trim());
const affixes = Array.from(c.querySelectorAll('.item-affix-lines .tooltip-stat-line')).map(a => a.textContent.trim());
return {
title,
subTitle,
quality,
ilvl,
stats,
affixes
};
});
})()`,
awaitPromise: true,
returnByValue: true,
})
console.log('4. Evaluation completed. Closing Chrome...')
ws.close()
chromeProc.kill()
const items = evalResult.result.value
console.log(`\nSuccessfully captured ${items.length} items from http://localhost:8080/boss.html!\n`)
return items
}
runBrowserAudit()
.then(items => {
console.log('================================================================');
console.log(`=== AUDITING ALL ${items.length} ITEMS GENERATED FROM 10x BOSS KILL ===`);
console.log('================================================================\n');
let errorsFound = 0;
const traditionalChars = ['屬', '無', '紅', '綠', '變', '爛', '誕', '誠', '雲', '犧', '嚇', '層', '靜', '換', '亂', '節', '甦'];
items.forEach((item, index) => {
console.log(`[#${index + 1}] [${item.quality}] ${item.title} (底材: ${item.subTitle || '无'}, ${item.ilvl})`);
if (item.stats.length > 0) {
console.log(` 基础属性: ${item.stats.join(' | ')}`);
}
if (item.affixes.length > 0) {
console.log(` 词条属性:`);
item.affixes.forEach(aff => console.log(` * ${aff}`));
}
// Check 1: Negative numbers in charged spells
item.affixes.forEach(aff => {
if (aff.includes('次充能') && (aff.includes('等级 -') || aff.includes('(-'))) {
console.error(` ❌ ERROR: Negative value in charged spell: "${aff}"`);
errorsFound++;
}
});
// Check 2: Missing English affix translations (e.g. English letters followed by 之 or preceded by 之)
const englishAffixMatch = item.title.match(/[a-zA-Z]+之|之[a-zA-Z]+/);
if (englishAffixMatch) {
console.error(` ❌ ERROR: Untranslated English affix in title: "${item.title}"`);
errorsFound++;
}
// Check 3: Word order anomaly: base name followed by '之' at the end of title
if (item.subTitle && item.title.endsWith(item.subTitle + '之')) {
console.error(` ❌ ERROR: Word order inversion (base followed by suffix particle): "${item.title}"`);
errorsFound++;
}
// Check 4: Traditional Chinese characters
const allText = item.title + ' ' + item.subTitle + ' ' + item.affixes.join(' ');
traditionalChars.forEach(ch => {
if (allText.includes(ch)) {
console.error(` ❌ ERROR: Traditional Chinese character '${ch}' found in: "${allText}"`);
errorsFound++;
}
});
console.log('');
});
console.log('================================================================');
if (errorsFound === 0) {
console.log(`🎉 AUDIT PASSED! All ${items.length} items checked with ZERO errors!`);
console.log(`✅ All names in correct Chinese grammar word order`);
console.log(`✅ 100% Affix translations matched without fallback artifacts`);
console.log(`✅ Charged spell positive charges and positive skill levels verified`);
console.log(`✅ Normalized Simplified Chinese characters verified`);
} else {
console.error(`⚠️ AUDIT FOUND ${errorsFound} ERRORS!`);
process.exit(1);
}
console.log('================================================================\n');
})
.catch(err => {
console.error('Audit execution failed:', err);
process.exit(1);
});

View File

@ -7,12 +7,93 @@
* npx tsx scripts/audit-parity.ts --kills=1000 --boss=mephisto --difficulty=hell --mf=250 --players=1
*/
import { BOSS_LIST, type Difficulty } from '../src/boss.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { executeDropPipeline } from '../src/game/drop-pipeline.ts'
import type { UpgradableItemBase } from '../src/game/item-upgrade.ts'
import type { Item } from '../src/game/items.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 mlvl: { normal: number; nightmare: number; hell: number }
readonly tc: { normal: string; nightmare: string; hell: string }
readonly questTc: { normal: string; nightmare: string; hell: string }
readonly baseNoDrop: number
readonly picks: number
readonly monsterType?: number
}
export type BossDefinition = BossPreset
export const ACT_BOSSES: readonly BossPreset[] = [
{
id: 'andariel',
act: 1,
nameCn: '安达利尔 (Andariel)',
nameEn: 'Maiden of Anguish',
mlvl: { normal: 12, nightmare: 49, hell: 75 },
tc: { normal: 'Andariel', nightmare: 'Andariel (N)', hell: 'Andariel (H)' },
questTc: { normal: 'Andarielq', nightmare: 'Andarielq (N)', hell: 'Andarielq (H)' },
baseNoDrop: 19,
picks: 7,
monsterType: 4,
},
{
id: 'duriel',
act: 2,
nameCn: '督瑞尔 (Duriel)',
nameEn: 'Lord of Pain',
mlvl: { normal: 22, nightmare: 55, hell: 88 },
tc: { normal: 'Duriel', nightmare: 'Duriel (N)', hell: 'Duriel (H)' },
questTc: { normal: 'Durielq', nightmare: 'Durielq (N)', hell: 'Durielq (H)' },
baseNoDrop: 19,
picks: 7,
monsterType: 4,
},
{
id: 'mephisto',
act: 3,
nameCn: '墨菲斯托 (Mephisto)',
nameEn: 'Lord of Hatred',
mlvl: { normal: 26, nightmare: 59, hell: 87 },
tc: { normal: 'Mephisto', nightmare: 'Mephisto (N)', hell: 'Mephisto (H)' },
questTc: { normal: 'Mephistoq', nightmare: 'Mephistoq (N)', hell: 'Mephistoq (H)' },
baseNoDrop: 15,
picks: 7,
monsterType: 4,
},
{
id: 'diablo',
act: 4,
nameCn: '暗黑破坏神 (Diablo)',
nameEn: 'Lord of Terror',
mlvl: { normal: 40, nightmare: 62, hell: 94 },
tc: { normal: 'Diablo', nightmare: 'Diablo (N)', hell: 'Diablo (H)' },
questTc: { normal: 'Diabloq', nightmare: 'Diabloq (N)', hell: 'Diabloq (H)' },
baseNoDrop: 15,
picks: 7,
monsterType: 4,
},
{
id: 'baal',
act: 5,
nameCn: '巴尔 (Baal)',
nameEn: 'Lord of Destruction',
mlvl: { normal: 60, nightmare: 75, hell: 99 },
tc: { normal: 'Baal', nightmare: 'Baal (N)', hell: 'Baal (H)' },
questTc: { normal: 'Baalq', nightmare: 'Baalq (N)', hell: 'Baalq (H)' },
baseNoDrop: 15,
picks: 7,
monsterType: 4,
},
]
export const BOSS_LIST: readonly BossDefinition[] = ACT_BOSSES
export interface ParityAuditSummary {
readonly config: {
readonly boss: string

View File

@ -1,473 +0,0 @@
/**
* Comprehensive Browser-based E2E Verification & Screenshot Audit for Diablo II Skill Calculator.
*/
import { spawn, type ChildProcess } from 'child_process'
import { createServer } from 'http'
import { readFileSync, existsSync, mkdirSync, writeFileSync, copyFileSync } from 'fs'
import { join, extname } from 'path'
async function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.json': 'application/json',
}
async function runAudit() {
console.log('=== Starting Diablo II Skill Calculator Browser Audit ===')
// 1. Start simple static server on port 8899 serving dist/
const PORT = 8899
const baseDir = join(process.cwd(), 'dist')
if (!existsSync(baseDir)) {
throw new Error('dist/ directory does not exist! Run npm run build first.')
}
const server = createServer((req, res) => {
let reqPath = req.url?.split('?')[0]?.split('#')[0] || '/'
if (reqPath === '/') reqPath = '/skills.html'
const filePath = join(baseDir, reqPath)
if (existsSync(filePath)) {
const ext = extname(filePath)
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' })
res.end(readFileSync(filePath))
} else {
// Check public/ folder for skill images
const publicPath = join(process.cwd(), 'public', reqPath)
if (existsSync(publicPath)) {
const ext = extname(publicPath)
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' })
res.end(readFileSync(publicPath))
} else {
res.writeHead(404)
res.end('Not found: ' + reqPath)
}
}
})
await new Promise<void>(resolve => server.listen(PORT, '127.0.0.1', () => resolve()))
console.log(`1. Local HTTP server listening at http://127.0.0.1:${PORT}/`)
// 2. Launch headless Chrome with CDP
console.log('2. Starting Chrome headless on port 9223...')
const chromeProc: ChildProcess = spawn('/usr/bin/google-chrome', [
'--headless=new',
'--remote-debugging-port=9223',
'--no-sandbox',
'--disable-gpu',
'--disable-extensions',
'--window-size=1280,900',
`http://127.0.0.1:${PORT}/skills.html`,
])
let wsUrl: string | null = null
for (let i = 0; i < 40; i++) {
await sleep(300)
try {
const res = await fetch('http://127.0.0.1:9223/json/list')
const pages = await res.json()
const page = pages.find((p: any) => p.type === 'page' && p.url.includes('skills.html'))
if (page && page.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {}
}
if (!wsUrl) {
chromeProc.kill()
server.close()
throw new Error('Failed to connect to Chrome headless on port 9223')
}
console.log('3. Connected to Chrome WebSocket:', wsUrl)
const ws = new WebSocket(wsUrl)
await new Promise(resolve => ws.onopen = resolve)
let idCounter = 1
function sendCommand(method: string, params: any = {}): Promise<any> {
return new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: any) => {
const msg = JSON.parse(event.data)
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(msg.error)
else resolve(msg.result)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
}
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
await sleep(1500)
// Test 1: Check initial Amazon render
console.log('4. Auditing initial page render (Amazon)...')
const initialData = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const titles = Array.from(document.querySelectorAll('.tree-title')).map(el => el.textContent.trim());
const sockets = document.querySelectorAll('.skill-socket');
const reqLvl = document.getElementById('val-req-level')?.textContent;
const spent = document.getElementById('val-points-spent')?.textContent;
return { titles, socketCount: sockets.length, reqLvl, spent };
})()`,
returnByValue: true,
})
console.log(' Initial render state:', initialData.result.value)
if (initialData.result.value.socketCount !== 30) {
throw new Error(`Expected 30 skill sockets for Amazon, found: ${initialData.result.value.socketCount}`)
}
if (initialData.result.value.titles.length !== 3) {
throw new Error(`Expected 3 tree titles, found: ${initialData.result.value.titles.length}`)
}
// Test 2: Left-click point allocation on Jab (id 10)
console.log('5. Auditing Left-click allocation on Jab (id 10)...')
const allocData = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const jabSocket = document.querySelector('.skill-socket[data-skill-id="10"]');
if (!jabSocket) return { error: 'Jab socket not found' };
// Left click
jabSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const newJab = document.querySelector('.skill-socket[data-skill-id="10"]');
const badge = newJab?.querySelector('.skill-pts-badge')?.textContent;
const reqLvl = document.getElementById('val-req-level')?.textContent;
const spent = document.getElementById('val-points-spent')?.textContent;
return { badge, reqLvl, spent };
})()`,
returnByValue: true,
})
console.log(' After 1 click on Jab:', allocData.result.value)
if (allocData.result.value.badge !== '1') {
throw new Error(`Expected Jab badge to be 1, got ${allocData.result.value.badge}`)
}
// Shift+Click allocation (+5 points)
console.log('6. Auditing Shift+Left-click allocation on Jab (+5 points)...')
const shiftAllocData = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const jabSocket = document.querySelector('.skill-socket[data-skill-id="10"]');
jabSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
const newJab = document.querySelector('.skill-socket[data-skill-id="10"]');
const badge = newJab?.querySelector('.skill-pts-badge')?.textContent;
const spent = document.getElementById('val-points-spent')?.textContent;
return { badge, spent };
})()`,
returnByValue: true,
})
console.log(' After Shift+Click on Jab:', shiftAllocData.result.value)
if (shiftAllocData.result.value.badge !== '6') {
throw new Error(`Expected Jab badge to be 6, got ${shiftAllocData.result.value.badge}`)
}
// Max out Jab to 20 points
await sendCommand('Runtime.evaluate', {
expression: `(() => {
const jabSocket = document.querySelector('.skill-socket[data-skill-id="10"]');
for (let i = 0; i < 3; i++) {
jabSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
}
})()`,
})
// Test 3: Prerequisite chain unlocking
console.log('7. Auditing Prerequisite chain: Power Strike (id 14) and Impale (id 19)...')
const prereqCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const psSocket = document.querySelector('.skill-socket[data-skill-id="14"]');
const impSocket = document.querySelector('.skill-socket[data-skill-id="19"]');
const psLocked = psSocket?.classList.contains('locked');
const impLocked = impSocket?.classList.contains('locked');
// Allocate 5 points to Power Strike
psSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
const newPs = document.querySelector('.skill-socket[data-skill-id="14"]');
const psBadge = newPs?.querySelector('.skill-pts-badge')?.textContent;
return { psLocked, impLocked, psBadge };
})()`,
returnByValue: true,
})
console.log(' Prerequisite unlocking result:', prereqCheck.result.value)
if (prereqCheck.result.value.psLocked || prereqCheck.result.value.impLocked) {
throw new Error('Power Strike or Impale remained locked after allocating prerequisite Jab!')
}
if (prereqCheck.result.value.psBadge !== '5') {
throw new Error(`Expected Power Strike badge to be 5, got ${prereqCheck.result.value.psBadge}`)
}
// Test 4: Tooltip verification on Charged Strike (id 24)
console.log('8. Auditing Hover Tooltip on Charged Strike (id 24)...')
const tooltipCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const csSocket = document.querySelector('.skill-socket[data-skill-id="24"]');
const rect = csSocket.getBoundingClientRect();
csSocket.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: rect.left + 20,
clientY: rect.top + 20
}));
const tt = document.getElementById('skill-tooltip');
const display = tt?.style.display;
const title = tt?.querySelector('.tt-title')?.textContent;
const damage = tt?.querySelector('.tt-damage-main')?.textContent?.trim();
const synergies = Array.from(tt?.querySelectorAll('.tt-syn-item') || []).map(el => el.textContent.trim());
return { display, title, damage, synergies };
})()`,
returnByValue: true,
})
console.log(' Hover Tooltip data:', tooltipCheck.result.value)
if (tooltipCheck.result.value.display !== 'block') {
throw new Error('Tooltip was not displayed on mouseenter!')
}
if (!tooltipCheck.result.value.title?.includes('充能') && !tooltipCheck.result.value.title?.includes('Charged Strike')) {
throw new Error(`Unexpected tooltip title: ${tooltipCheck.result.value.title}`)
}
// Test 5: Right-click deallocation and Orphan Protection
console.log('9. Auditing Orphan Protection on Jab and Power Strike...')
const orphanCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
// Allocate prerequisites for Charged Strike (24): Poison Javelin (15) -> Lightning Bolt (20) -> Charged Strike (24)
document.querySelector('.skill-socket[data-skill-id="15"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
document.querySelector('.skill-socket[data-skill-id="20"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
document.querySelector('.skill-socket[data-skill-id="24"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
// Now attempt to right-click Power Strike (14) down to 0 points with shiftKey
document.querySelector('.skill-socket[data-skill-id="14"]')?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, shiftKey: true }));
const newPs = document.querySelector('.skill-socket[data-skill-id="14"]');
const psBadge = newPs?.querySelector('.skill-pts-badge')?.textContent;
const toastText = document.getElementById('toast')?.textContent;
return { psBadge, toastText };
})()`,
returnByValue: true,
})
console.log(' Orphan protection result:', orphanCheck.result.value)
if (orphanCheck.result.value.psBadge === '0') {
throw new Error('Orphan protection failed! Power Strike was reduced to 0 while Charged Strike still depends on it!')
}
// Test 6: Class switcher to Sorceress, URL Hash, and bilingual toggle
console.log('10. Auditing Class Switcher to Sorceress (sor)...')
const sorCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const sorBtn = document.querySelector('.class-btn[data-class-code="sor"]');
sorBtn.click();
const titles = Array.from(document.querySelectorAll('.tree-title')).map(el => el.textContent.trim());
const hash = window.location.hash;
return { titles, hash };
})()`,
returnByValue: true,
})
console.log(' Sorceress tabs:', sorCheck.result.value)
if (!sorCheck.result.value.titles[0]?.includes('火焰法术') && !sorCheck.result.value.titles[0]?.includes('Fire Spells')) {
throw new Error('Failed to switch to Sorceress trees!')
}
// Test 7: Bilingual toggle
console.log('11. Auditing Language Toggle...')
const langCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const btnLang = document.getElementById('btn-lang');
btnLang.click(); // Switch to EN
const titlesEn = Array.from(document.querySelectorAll('.tree-title')).map(el => el.textContent.trim());
const respecText = document.getElementById('btn-respec')?.textContent;
return { titlesEn, respecText };
})()`,
returnByValue: true,
})
console.log(' English switch result:', langCheck.result.value)
if (!langCheck.result.value.titlesEn[0]?.includes('Fire Spells')) {
throw new Error(`Expected English tab 'Fire Spells', got: ${langCheck.result.value.titlesEn[0]}`)
}
// Test 8: Non-Damage Skills Tooltip Display Audit (Warmth, Raise Skeleton, Battle Orders, Fanaticism)
console.log('12. Auditing Non-Damage Skills (Warmth, Raise Skeleton, Battle Orders, Fanaticism)...')
const nonDamageAudit = await sendCommand('Runtime.evaluate', {
expression: `(() => {
document.getElementById('btn-lang').click(); // Back to Chinese
// 1. Warmth (37)
const warmthSocket = document.querySelector('.skill-socket[data-skill-id="37"]');
warmthSocket.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
const tt = document.getElementById('skill-tooltip');
const warmthHasDmg = Boolean(tt.querySelector('.tt-damage-box'));
const warmthEffects = Array.from(tt.querySelectorAll('.tt-effect-line')).map(e => e.textContent);
// 2. Switch to Necromancer (nec) for Raise Skeleton (70)
document.querySelector('.class-btn[data-class-code="nec"]').click();
const skelSocket = document.querySelector('.skill-socket[data-skill-id="70"]');
const smSocket = document.querySelector('.skill-socket[data-skill-id="69"]');
// Allocate 20 Raise Skeleton & 20 Skeleton Mastery
for (let i = 0; i < 4; i++) skelSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
for (let i = 0; i < 4; i++) smSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
const skelRect = skelSocket.getBoundingClientRect();
skelSocket.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: skelRect.left + 24,
clientY: skelRect.top + 24
}));
const skelHasDmg = Boolean(tt.querySelector('.tt-damage-box'));
const skelEffects = Array.from(tt.querySelectorAll('.tt-effect-line')).map(e => e.textContent);
// 3. Switch to Barbarian (bar) for Battle Orders (149)
document.querySelector('.class-btn[data-class-code="bar"]').click();
const boSocket = document.querySelector('.skill-socket[data-skill-id="149"]');
boSocket.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
const boHasDmg = Boolean(tt.querySelector('.tt-damage-box'));
const boEffects = Array.from(tt.querySelectorAll('.tt-effect-line')).map(e => e.textContent);
// 4. Switch to Paladin (pal) for Fanaticism (122)
document.querySelector('.class-btn[data-class-code="pal"]').click();
const fanSocket = document.querySelector('.skill-socket[data-skill-id="122"]');
fanSocket.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
const fanHasDmg = Boolean(tt.querySelector('.tt-damage-box'));
const fanEffects = Array.from(tt.querySelectorAll('.tt-effect-line')).map(e => e.textContent);
return {
warmth: { hasDmg: warmthHasDmg, effects: warmthEffects },
raiseSkeleton: { hasDmg: skelHasDmg, effects: skelEffects },
battleOrders: { hasDmg: boHasDmg, effects: boEffects },
fanaticism: { hasDmg: fanHasDmg, effects: fanEffects },
};
})()`,
returnByValue: true,
})
console.log(' Non-Damage Audit Results:', JSON.stringify(nonDamageAudit.result.value, null, 2))
const nd = nonDamageAudit.result.value
if (nd.warmth.hasDmg) throw new Error('Warmth MUST NOT display direct damage!')
if (!nd.warmth.effects.some((t: string) => t.includes('法力回復速度'))) {
throw new Error('Warmth missing mana recovery rate effect!')
}
if (nd.raiseSkeleton.hasDmg) throw new Error('Raise Skeleton MUST NOT display direct damage!')
if (!nd.raiseSkeleton.effects.some((t: string) => t.includes('骷髏總數'))) {
throw new Error('Raise Skeleton missing skeleton count effect!')
}
if (nd.battleOrders.hasDmg) throw new Error('Battle Orders MUST NOT display direct damage!')
if (!nd.battleOrders.effects.some((t: string) => t.includes('生命最大值'))) {
throw new Error('Battle Orders missing max life effect!')
}
if (nd.fanaticism.hasDmg) throw new Error('Fanaticism MUST NOT display direct damage!')
if (!nd.fanaticism.effects.some((t: string) => t.includes('攻擊速度'))) {
throw new Error('Fanaticism missing attack speed effect!')
}
console.log('✓ Non-Damage Skills Tooltip Audit Passed: No fake damage, all authentic mechanics displayed!')
// Capture Necromancer Raise Skeleton Screenshot
console.log('12.1 Capturing Necromancer Raise Skeleton Tooltip Screenshot...')
await sendCommand('Runtime.evaluate', {
expression: `(() => {
document.querySelector('.class-btn[data-class-code="nec"]').click();
const skelSocket = document.querySelector('.skill-socket[data-skill-id="70"]');
const smSocket = document.querySelector('.skill-socket[data-skill-id="69"]');
for (let i = 0; i < 4; i++) skelSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
for (let i = 0; i < 4; i++) smSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
const skelRect = skelSocket.getBoundingClientRect();
skelSocket.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: skelRect.left + 24,
clientY: skelRect.top + 24
}));
})()`,
})
await sleep(500)
const necShot = await sendCommand('Page.captureScreenshot', { format: 'png', quality: 100, fromSurface: true })
const necBuf = Buffer.from(necShot.data, 'base64')
const necPathLocal = join(process.cwd(), 'tests', 'skills_calculator_nec_audit.png')
const necPathArtifact = '/usr/local/google/home/taodao/.gemini/jetski/brain/d8366262-b464-4b0a-bff5-16918852fb1a/skills_calculator_nec_audit.png'
const necPathX20 = '/google/data/rw/users/ta/taodao/skills_calculator_nec_audit.png'
writeFileSync(necPathLocal, necBuf)
writeFileSync(necPathArtifact, necBuf)
try { copyFileSync(necPathLocal, necPathX20) } catch {}
console.log(`✓ Saved Necromancer screenshot to: ${necPathArtifact} and x20`)
// Switch back to Sorceress and allocate Fire Ball build for final screenshot
console.log('13. Configuring Fire Sorceress build for visual audit screenshot...')
await sendCommand('Runtime.evaluate', {
expression: `(() => {
document.querySelector('.class-btn[data-class-code="sor"]').click();
// Allocate 20 Fire Bolt (36)
const bolt = document.querySelector('.skill-socket[data-skill-id="36"]');
for (let i = 0; i < 4; i++) bolt.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
// Allocate 20 Fire Ball (47)
const ball = document.querySelector('.skill-socket[data-skill-id="47"]');
for (let i = 0; i < 4; i++) ball.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
// Allocate 20 Fire Mastery (61)
const fm = document.querySelector('.skill-socket[data-skill-id="61"]');
for (let i = 0; i < 4; i++) fm.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
// Add +3 All Skills
const incBtn = document.getElementById('btn-all-skills-inc');
incBtn.click(); incBtn.click(); incBtn.click();
// Trigger hover tooltip on Fire Ball (47)
const ballRect = ball.getBoundingClientRect();
ball.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: ballRect.left + 24,
clientY: ballRect.top + 24
}));
})()`,
})
await sleep(600)
// Capture screenshot of the full viewport
console.log('13. Capturing full-page screenshot via CDP...')
const screenshotResult = await sendCommand('Page.captureScreenshot', {
format: 'png',
quality: 100,
fromSurface: true,
})
const screenshotBuf = Buffer.from(screenshotResult.data, 'base64')
const localScreenshotPath = join(process.cwd(), 'tests', 'skills_calculator_audit.png')
const publicScreenshotPath = join(process.cwd(), 'public', 'skills_calculator_audit.png')
const x20Dir = '/google/data/rw/users/ta/taodao'
const x20Path = join(x20Dir, 'skills_calculator_audit.png')
const artifactPath = '/usr/local/google/home/taodao/.gemini/jetski/brain/d8366262-b464-4b0a-bff5-16918852fb1a/skills_calculator_audit.png'
writeFileSync(localScreenshotPath, screenshotBuf)
writeFileSync(publicScreenshotPath, screenshotBuf)
writeFileSync(artifactPath, screenshotBuf)
if (existsSync(x20Dir)) {
try {
copyFileSync(localScreenshotPath, x20Path)
console.log(`✓ Copied audit screenshot to x20: ${x20Path}`)
} catch (e) {
console.warn('Could not copy to x20:', e)
}
}
console.log(`✓ Saved screenshot to: ${localScreenshotPath}`)
console.log(`✓ Saved screenshot to: ${artifactPath}`)
// Cleanup
ws.close()
chromeProc.kill()
server.close()
console.log('=== All Browser Audit Tests Passed Successfully! ===')
}
runAudit().catch(err => {
console.error('Audit failed:', err)
process.exit(1)
})

View File

@ -1,140 +0,0 @@
import {
MONSTER_PRESETS,
FAMOUS_ITEMS_META,
classifyItemCategory,
loadCanonicalTreasureClasses,
calculateAnalyticProbabilities,
runMonteCarloSimulation,
buildReverseLookupIndex,
reverseLookup,
} from '../src/loot';
async function main() {
console.log('=== [1] 验证 MONSTER_PRESETS 与 TC 完整性 ===');
const { tcTable, autoTcs } = loadCanonicalTreasureClasses();
console.log(`载入系统 TC 节点数: ${tcTable.all.length}, 自动生成 TC 节点数: ${autoTcs.size}`);
let missingTcCount = 0;
for (const mon of MONSTER_PRESETS) {
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
const tcName = mon.tc[diff];
if (!tcTable.get(tcName) && !autoTcs.has(tcName)) {
console.error(`❌ [缺失] 怪物 ${mon.nameCn} 在 ${diff} 难度的 TC 不存在: ${tcName}`);
missingTcCount++;
}
if (mon.questTc) {
const questTcName = mon.questTc[diff];
if (!tcTable.get(questTcName) && !autoTcs.has(questTcName)) {
console.error(`❌ [缺失] 怪物 ${mon.nameCn} 在 ${diff} 难度的 首杀TC 不存在: ${questTcName}`);
missingTcCount++;
}
}
}
}
if (missingTcCount === 0) {
console.log(`✅ 所有 ${MONSTER_PRESETS.length} 个怪物预设在全难度的基础 TC 与首杀 TC 校验 100% 存在!`);
} else {
throw new Error(`存在 ${missingTcCount} 个无效的 TC 引用`);
}
console.log('\n=== [2] 验证 FAMOUS_ITEMS_META 与物品分类器 ===');
const famousCount = Object.keys(FAMOUS_ITEMS_META).length;
console.log(`已收录核心暗金与极品底模元数据条目: ${famousCount} 条`);
// Test classifyItemCategory
const r30Cat = classifyItemCategory('r30');
const shakoCat = classifyItemCategory('uap', 'unique');
const phaseBladeCat = classifyItemCategory('7cr');
const sojCat = classifyItemCategory('rin', 'unique');
const tokenCat = classifyItemCategory('tes');
console.log(`- 30# 贝 (r30) 分类: ${r30Cat} (期望: rune)`);
console.log(`- 军帽 (uap, unique) 分类: ${shakoCat} (期望: unique)`);
console.log(`- 幻化之刃 (7cr) 分类: ${phaseBladeCat} (期望: base)`);
console.log(`- 戒指/护身符 (rin) 分类: ${sojCat} (期望: charm_gem)`);
console.log(`- 洗点徽章 (tes) 分类: ${tokenCat} (期望: misc)`);
if (r30Cat !== 'rune' || shakoCat !== 'unique' || phaseBladeCat !== 'base' || sojCat !== 'charm_gem' || tokenCat !== 'misc') {
throw new Error('分类器逻辑测试失败');
}
console.log('✅ 物品多维分类器运行正确!');
console.log('\n=== [3] 验证精确概率表解析 (Baal H 与 Andariel H) ===');
const baalOdds = calculateAnalyticProbabilities('Baal (H)', { tcTable, autoTcs, gamePlayers: 1, partyPlayers: 1 });
console.log(`Baal (H) - 叶子节点数: ${baalOdds.leaves.length}, Picks: ${baalOdds.picks}, NoDrop: ${(baalOdds.noDropCompoundProb * 100).toFixed(2)}%`);
// Verify Shako / uap odds for Baal
const uapLeaf = baalOdds.leaves.find(l => l.item === 'uap');
if (uapLeaf) {
console.log(`- 军帽底模 (uap) 单次概率: ${(uapLeaf.singleProbability * 100).toFixed(4)}%, 综合概率: ${(uapLeaf.compoundProbability * 100).toFixed(4)}%, 几率比: 1 : ${uapLeaf.oneInOdds.toFixed(1)}`);
}
// Verify Quest Drop for Andariel
const andyNormalOdds = calculateAnalyticProbabilities('Andariel (H)', { tcTable, autoTcs, gamePlayers: 1, partyPlayers: 1 });
const andyQuestOdds = calculateAnalyticProbabilities('Andarielq (H)', { tcTable, autoTcs, gamePlayers: 1, partyPlayers: 1 });
console.log(`Andariel (H) 普通杀 NoDrop: ${(andyNormalOdds.noDropCompoundProb * 100).toFixed(2)}%`);
console.log(`Andarielq (H) Bug首杀 NoDrop: ${(andyQuestOdds.noDropCompoundProb * 100).toFixed(2)}%`);
const andyNormalUap = andyNormalOdds.leaves.find(l => l.item === 'uap')?.compoundProbability || 0;
const andyQuestUap = andyQuestOdds.leaves.find(l => l.item === 'uap')?.compoundProbability || 0;
console.log(`- 安姐普通杀 军帽(uap) 几率: ${(andyNormalUap * 100).toFixed(4)}%`);
console.log(`- 安姐Bug首杀 军帽(uap) 几率: ${(andyQuestUap * 100).toFixed(4)}% (提升 +${(((andyQuestUap - andyNormalUap) / andyNormalUap) * 100).toFixed(1)}%)`);
const andyQuestHasJunk = andyQuestOdds.leaves.some(l => l.item === 'tsc' || l.item === 'isc' || l.item === 'gld');
if (andyQuestHasJunk) {
throw new Error('安姐首杀 TC 不应包含卷轴或金币等垃圾杂项!');
}
if (andyQuestUap <= andyNormalUap) {
throw new Error('安姐首杀装备出货率应当显著高于普通击杀!');
}
console.log('✅ 安姐首杀剔除杂质与装备出货率暴增验证通过!');
console.log('\n=== [4] 验证 1,000 次蒙特卡洛掉落模拟 (实测出货与品质分布) ===');
const simRes = await runMonteCarloSimulation({
tcName: 'Baal (H)',
runs: 1000,
gamePlayers: 3,
partyPlayers: 1,
magicFind: 250,
difficulty: 'hell',
tcTable,
autoTcs,
});
console.log(`模拟总轮数: ${simRes.totalRuns}`);
console.log(`掉落总件数: ${simRes.itemsDropped} 件 (平均每轮 ${(simRes.itemsDropped / simRes.totalRuns).toFixed(2)} 件)`);
console.log(`品质分布:`);
console.log(` - Unique (暗金): ${simRes.qualityCounts.unique} 件`);
console.log(` - Set (套装): ${simRes.qualityCounts.set} 件`);
console.log(` - Rare (稀有): ${simRes.qualityCounts.rare} 件`);
console.log(` - Magic (魔法): ${simRes.qualityCounts.magic} 件`);
console.log(` - Rune (符文): ${simRes.qualityCounts.rune} 件`);
console.log(` - Normal/Sup (底模): ${simRes.qualityCounts.normal + simRes.qualityCounts.superior} 件`);
console.log(` - Gold (金币): ${simRes.qualityCounts.gold} 堆`);
if (simRes.itemsDropped === 0) {
throw new Error('1000次模拟依然为0件掉落!');
}
console.log(`✅ 1,000 次模拟正常出货,共 ${simRes.itemSummaries.length} 种不同物品!`);
console.log('\n=== [5] 验证反查索引 (Reverse Lookup) ===');
const revIndex = buildReverseLookupIndex(tcTable, autoTcs);
const shakoRev = reverseLookup(revIndex, 'shako');
console.log(`搜索 "shako" 匹配到的掉落源数量: ${shakoRev[0]?.entries.length || 0} 个 TC`);
if (shakoRev.length > 0 && shakoRev[0]!.entries.length > 0) {
console.log(`- 最佳掉落源 TOP 1: ${shakoRev[0]!.entries[0]!.tcName}, 综合几率: ${(shakoRev[0]!.entries[0]!.compoundProbability * 100).toFixed(4)}%`);
} else {
throw new Error('反查 shako 失败');
}
console.log('✅ 反查索引测试通过!');
console.log('\n========================================');
console.log('🎉 全部 Maxroll 怪物选择器与掉落列表核心功能实测 100% 成功!');
console.log('========================================');
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@ -1,652 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>暗黑破坏神 2 · 全职业技能加点模拟器 (Diablo II Skill Calculator v1.13c)</title>
<style>
:root {
color-scheme: dark;
--bg-void: #060504;
--bg-main: #0c0906;
--bg-panel: rgba(18, 14, 10, 0.97);
--bg-card: #15110c;
--border-dim: #2b2219;
--border-gold: #73572d;
--border-gold-glow: #c8a15a;
--border-gold-bright: #eecf87;
--gold-primary: #c8a15a;
--gold-bright: #eecf87;
--gold-light: #f5eedc;
--text-dim: #998772;
--text-muted: #6e5e4d;
--text-red: #ff4d4d;
--text-green: #55ff55;
--text-blue: #4880ff;
--text-orange: #ff9900;
--text-yellow: #ffff55;
--btn-bg: #1c1611;
--btn-hover: #33271c;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100vh;
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", sans-serif;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
/* Custom Scrollbars */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #0c0907; }
::-webkit-scrollbar-thumb { background: #2f251c; border-radius: 4px; border: 1px solid #4a3825; }
::-webkit-scrollbar-thumb:hover { background: #523f2f; }
/* Top Header */
header {
height: 50px;
background: linear-gradient(180deg, #1c140e 0%, #100b08 100%);
border-bottom: 2px solid var(--border-gold);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.7);
z-index: 100;
position: relative;
}
.logo-area {
display: flex;
align-items: center;
gap: 10px;
}
.logo-title {
font-family: 'Cinzel', 'Palatino Linotype', 'Book Antiqua', 'Georgia', serif;
font-size: 15px;
font-weight: 700;
color: var(--gold-bright);
letter-spacing: 1px;
text-shadow: 0 2px 4px #000;
}
.version-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: #251b12;
border: 1px solid var(--border-dim);
color: var(--text-dim);
}
/* Class Selection Tabs */
.class-tabs {
display: flex;
align-items: center;
gap: 4px;
}
.class-btn {
background: #140f0b;
border: 1px solid var(--border-dim);
border-radius: 3px;
color: var(--text-dim);
padding: 6px 12px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 6px;
}
.class-btn:hover {
background: #241b13;
color: var(--gold-bright);
border-color: var(--border-gold);
}
.class-btn.active {
background: linear-gradient(180deg, #38281a 0%, #20160e 100%);
color: var(--gold-bright);
border-color: var(--border-gold-glow);
box-shadow: 0 0 8px rgba(200, 161, 90, 0.35);
font-weight: bold;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-link {
color: var(--text-dim);
text-decoration: none;
font-size: 11px;
padding: 5px 8px;
border-radius: 3px;
border: 1px solid var(--border-dim);
background: #120e0a;
transition: all 0.15s;
}
.header-link:hover {
color: var(--gold-bright);
border-color: var(--border-gold);
}
.lang-btn {
background: #19120c;
border: 1px solid var(--border-gold);
color: var(--gold-bright);
border-radius: 3px;
padding: 5px 10px;
font-size: 11px;
cursor: pointer;
}
.lang-btn:hover {
background: #2b2014;
}
/* Stats & Summary Bar */
.stats-bar {
height: 44px;
background: #0f0b08;
border-bottom: 1px solid var(--border-dim);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
.stats-left {
display: flex;
align-items: center;
gap: 20px;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.stat-label {
color: var(--text-dim);
}
.stat-val {
color: var(--gold-bright);
font-weight: bold;
}
.stat-val.warning {
color: var(--text-red);
}
.stepper-control {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 4px;
}
.stepper-btn {
width: 20px;
height: 20px;
border-radius: 3px;
border: 1px solid var(--border-dim);
background: #1b150f;
color: var(--gold-light);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
padding: 0;
font-size: 12px;
line-height: 1;
}
.stepper-btn:hover {
background: #33261a;
border-color: var(--border-gold);
color: var(--gold-bright);
}
.stepper-val {
min-width: 24px;
text-align: center;
color: var(--text-blue);
font-weight: bold;
}
.stats-right {
display: flex;
align-items: center;
gap: 10px;
}
.action-btn {
background: #19130d;
border: 1px solid var(--border-gold);
color: var(--gold-light);
padding: 5px 12px;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.action-btn:hover {
background: #312316;
color: var(--gold-bright);
border-color: var(--border-gold-glow);
}
.action-btn.danger:hover {
background: #3d1414;
border-color: #8c2e2e;
color: #ff9999;
}
/* Main Viewport (Isolated Scroll Context) */
main.trees-viewport {
height: calc(100vh - 94px);
overflow-y: auto;
overflow-x: auto;
padding: 24px 16px 40px 16px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.trees-container {
display: flex;
gap: 20px;
justify-content: center;
align-items: flex-start;
}
/* Individual Skill Tree Column (Matching Screenshot) */
.tree-column {
width: 260px;
background: #110d09;
border: 2px solid var(--border-gold);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.8), inset 0 0 10px rgba(0, 0, 0, 0.7);
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tree-header {
padding: 8px 10px;
background: linear-gradient(180deg, #201710 0%, #140e0a 100%);
border-bottom: 1px solid var(--border-dim);
text-align: center;
}
.tree-title {
font-family: 'Cinzel', 'Palatino Linotype', 'Book Antiqua', 'Georgia', serif;
font-size: 12.5px;
font-weight: bold;
color: var(--gold-bright);
letter-spacing: 1px;
text-transform: uppercase;
text-shadow: 0 2px 4px #000;
margin-bottom: 3px;
}
.tree-points-banner {
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.5px;
text-transform: uppercase;
}
/* Stone Tree Canvas (256x432) */
.tree-canvas {
width: 256px;
height: 432px;
position: relative;
background-color: #0c0907;
background-repeat: no-repeat;
background-position: 0 0;
background-size: 256px 432px;
margin: 0 auto;
}
/* Skill Socket */
.skill-socket {
position: absolute;
width: 48px;
height: 48px;
box-sizing: border-box;
}
.skill-icon {
width: 48px;
height: 48px;
display: block;
image-rendering: pixelated;
cursor: pointer;
transition: transform 0.05s ease, filter 0.15s ease;
border-radius: 2px;
}
.skill-icon:active {
transform: scale(0.96);
}
/* Locked state */
.skill-socket.locked .skill-icon {
filter: grayscale(85%) brightness(0.5);
opacity: 0.65;
cursor: not-allowed;
}
.skill-socket.locked .skill-icon:active {
transform: none;
}
/* Active allocated state */
.skill-socket.active .skill-icon {
filter: drop-shadow(0 0 4px rgba(200, 161, 90, 0.4));
}
/* Maxed 20-point state */
.skill-socket.maxed .skill-icon {
filter: drop-shadow(0 0 6px rgba(238, 207, 135, 0.7));
}
/* Point Badge (Next to icon or bottom-right) */
.skill-pts-badge {
position: absolute;
width: 18px;
height: 18px;
background: #110d09;
border: 1px solid #4a3825;
box-shadow: inset 0 0 4px #000;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
color: var(--text-muted);
pointer-events: none;
}
.skill-pts-badge.has-points {
color: #ffffff;
background: #1c150e;
border-color: var(--border-gold);
text-shadow: 0 1px 2px #000;
}
.skill-pts-badge.has-soft-points {
color: var(--text-blue);
background: #141b24;
border-color: #385d8a;
text-shadow: 0 0 4px rgba(72, 128, 255, 0.5);
}
.skill-pts-badge.is-maxed {
color: var(--gold-bright);
border-color: var(--border-gold-glow);
text-shadow: 0 0 4px rgba(238, 207, 135, 0.6);
}
/* Tree Footer (Tree Soft Points) */
.tree-footer {
padding: 8px 10px;
background: #140e0a;
border-top: 1px solid var(--border-dim);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 11px;
color: var(--text-dim);
}
/* Floating Diablo II Tooltip */
#skill-tooltip {
position: fixed;
width: 320px;
max-width: 360px;
background: rgba(14, 10, 7, 0.97);
border: 2px solid var(--border-gold);
outline: 1px solid #2f2115;
outline-offset: -3px;
border-radius: 4px;
padding: 12px 14px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.95), inset 0 0 12px rgba(0, 0, 0, 0.8);
pointer-events: none;
z-index: 9999;
display: none;
font-size: 12px;
line-height: 1.4;
}
.tt-header {
margin-bottom: 8px;
border-bottom: 1px solid var(--border-dim);
padding-bottom: 6px;
}
.tt-title {
font-family: 'Cinzel', 'Palatino Linotype', 'Book Antiqua', 'Georgia', serif;
font-size: 15px;
font-weight: bold;
color: var(--gold-bright);
text-shadow: 0 2px 4px #000;
}
.tt-subtitle {
font-size: 11px;
color: var(--text-dim);
}
.tt-level-tag {
font-size: 12px;
color: var(--gold-light);
margin-top: 2px;
}
.tt-level-tag .soft-highlight {
color: var(--text-blue);
font-weight: bold;
}
.tt-section {
margin-bottom: 8px;
}
.tt-row {
display: flex;
justify-content: space-between;
margin-bottom: 2px;
}
.tt-label {
color: var(--text-dim);
}
.tt-val {
color: var(--gold-light);
font-weight: 500;
}
.tt-val.err {
color: var(--text-red);
}
.tt-val.ok {
color: var(--text-green);
}
.tt-damage-box {
background: rgba(26, 18, 12, 0.6);
border: 1px solid var(--border-dim);
border-radius: 3px;
padding: 6px 8px;
margin: 6px 0;
}
.tt-damage-main {
font-size: 13.5px;
font-weight: bold;
color: var(--text-orange);
margin-bottom: 3px;
}
.tt-damage-sub {
font-size: 11px;
color: var(--text-dim);
}
.tt-effects-box {
background: rgba(16, 20, 32, 0.6);
border: 1px solid #2a3550;
border-radius: 3px;
padding: 6px 8px;
margin: 6px 0;
}
.tt-effect-line {
font-size: 12px;
color: #5588ee;
line-height: 1.45;
margin-bottom: 2px;
text-shadow: 0 1px 2px #000;
}
.tt-effect-line:last-child {
margin-bottom: 0;
}
.tt-next-level .tt-effect-line {
color: #4a77cc;
font-size: 11px;
}
.tt-synergies {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed var(--border-dim);
}
.tt-syn-title {
font-size: 11px;
font-weight: bold;
color: var(--gold-primary);
margin-bottom: 3px;
}
.tt-syn-item {
font-size: 11px;
color: #b0a08e;
padding-left: 6px;
margin-bottom: 2px;
}
.tt-syn-item.active {
color: var(--text-green);
}
.tt-next-level {
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid var(--border-dim);
color: var(--text-dim);
font-size: 11px;
}
.tt-next-title {
color: var(--gold-primary);
font-weight: bold;
margin-bottom: 2px;
}
.tt-desc {
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid var(--border-dim);
font-size: 11px;
color: #a89885;
font-style: italic;
}
.tt-hint {
margin-top: 8px;
padding: 4px 6px;
background: #18120b;
border-radius: 2px;
font-size: 10px;
color: var(--gold-dim);
text-align: center;
}
/* Toast Notification */
#toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: #1c150e;
border: 1px solid var(--border-gold-glow);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.8);
color: var(--gold-bright);
padding: 8px 18px;
border-radius: 4px;
font-size: 13px;
pointer-events: none;
transition: transform 0.25s ease, opacity 0.25s ease;
opacity: 0;
z-index: 10000;
}
#toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
</style>
</head>
<body>
<header>
<div class="logo-area">
<span class="logo-title" id="txt-app-title">暗黑破坏神 2 · 技能加点模拟器</span>
<span class="version-badge">v1.13c</span>
</div>
<nav class="class-tabs" id="class-tabs-container">
<!-- Injected via JS -->
</nav>
<div class="header-actions">
<a href="./loot.html" class="header-link" id="lnk-loot">战利品模拟</a>
<a href="./boss.html" class="header-link" id="lnk-boss">首领挑战</a>
<a href="./acts.html" class="header-link" id="lnk-acts">大地图</a>
<button class="lang-btn" id="btn-lang" title="Toggle Language">中 / EN</button>
</div>
</header>
<div class="stats-bar">
<div class="stats-left">
<div class="stat-item">
<span class="stat-label" id="lbl-req-level">需求角色等级:</span>
<span class="stat-val" id="val-req-level">1</span>
</div>
<div class="stat-item">
<span class="stat-label" id="lbl-points-spent">已用技能点:</span>
<span class="stat-val" id="val-points-spent">0 / 110</span>
</div>
<div class="stat-item">
<span class="stat-label" id="lbl-points-remain">剩余技能点:</span>
<span class="stat-val" id="val-points-remain">110</span>
</div>
<div class="stat-item">
<span class="stat-label" id="lbl-all-skills">+ 全部技能:</span>
<div class="stepper-control">
<button class="stepper-btn" id="btn-all-skills-dec">-</button>
<span class="stepper-val" id="val-all-skills">0</span>
<button class="stepper-btn" id="btn-all-skills-inc">+</button>
</div>
</div>
</div>
<div class="stats-right">
<button class="action-btn danger" id="btn-respec">重置配点 (Respec)</button>
<button class="action-btn" id="btn-copy-link">复制配点链接 (Share)</button>
</div>
</div>
<main class="trees-viewport">
<div class="trees-container" id="trees-container">
<!-- 3 Skill Tree Panels Injected Here -->
</div>
</main>
<!-- Tooltip Element -->
<div id="skill-tooltip"></div>
<!-- Toast Notification -->
<div id="toast">已复制配点链接到剪贴板!</div>
<script type="module" src="/src/skills.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because one or more lines are too long

View File

@ -1,787 +0,0 @@
/**
* Diablo II v1.13c Skill Point Calculator Web Application.
*
* Implements full 7-class skill tree planner matching the reference layout:
* - 3 side-by-side authentic 256x432 skill trees per class
* - Left-click to allocate, right-click to deallocate (with orphan protection)
* - Authentic 1.13c synergy engine & masteries calculation
* - Dynamic tooltip with damage, mana, requirements, synergies, next-level preview
* - URL Hash serialization for build sharing
* - Chinese / English bilingual localization
*/
import {
type CharacterClassCode,
CHARACTER_CLASS_CODES,
} from './game/classes.ts'
import {
type SkillCatalogEntry,
SKILLS_BY_ID,
SKILLS_CATALOG,
} from './data/skills-catalog.ts'
import {
type SkillCalcState,
allocatePoint,
deallocatePoint,
respec,
canAllocate,
canDeallocate,
getEffectiveLevel,
getTotalPointsSpent,
getTabPointsSpent,
getRequiredCharacterLevel,
buildTooltipViewModel,
serializeBuildToHash,
deserializeBuildFromHash,
} from './game/skill-calc-engine.ts'
type Lang = 'zh' | 'en'
// Authentic Diablo II class tab orders (top-to-bottom in game UI translates to left-to-right)
// Amazon: Javelin & Spear (tab 2), Passive & Magic (tab 1), Bow & Crossbow (tab 0) -> exact match with reference image!
const CLASS_TAB_ORDER: Record<CharacterClassCode, readonly number[]> = {
ama: [2, 1, 0],
sor: [0, 1, 2],
nec: [2, 1, 0],
pal: [0, 1, 2],
bar: [0, 1, 2],
dru: [2, 1, 0],
ass: [2, 1, 0],
}
const CLASS_NAMES: Record<CharacterClassCode, { zh: string; en: string }> = {
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 = `
<div class="tree-title">${tabName}</div>
<div class="tree-points-banner">
${currentLang === 'zh' ? `该系已投入 ${tabSpent} 点` : `${tabSpent} POINTS IN THIS TREE`}
</div>
`
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 = `
<span>${currentLang === 'zh' ? `+ 该系技能:` : `+ to ${tabName}:`}</span>
<div class="stepper-control">
<button class="stepper-btn tab-soft-dec" data-tab="${tabIndex}">-</button>
<span class="stepper-val tab-soft-val">${currentTabSoft}</span>
<button class="stepper-btn tab-soft-inc" data-tab="${tabIndex}">+</button>
</div>
`
// 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'
? `当前技能等级: <span class="soft-highlight">${vm.effectiveLevel}</span> (基础: ${vm.hardPoints}, 装备: +${soft})`
: `Current Skill Level: <span class="soft-highlight">${vm.effectiveLevel}</span> (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 = `
<div class="tt-header">
<div class="tt-title">${skillName}</div>
<div class="tt-subtitle">${className} · ${tabName}</div>
<div class="tt-level-tag">${levelText}</div>
</div>
<div class="tt-section">
<div class="tt-row">
<span class="tt-label">${currentLang === 'zh' ? '需求角色等级:' : 'Required Character Level:'}</span>
<span class="tt-val ${vm.levelMet ? 'ok' : 'err'}">${vm.reqlevel}</span>
</div>
`
// Prerequisites
if (vm.prereqs.length > 0) {
html += `
<div class="tt-row" style="margin-top: 4px;">
<span class="tt-label">${currentLang === 'zh' ? '前置技能需求:' : 'Prerequisites:'}</span>
</div>
`
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 += `
<div class="tt-row" style="padding-left: 8px;">
<span class="tt-val ${statusClass}">${statusIcon} ${pName}</span>
<span class="tt-val ${statusClass}" style="font-size: 10px;">${statusDesc}</span>
</div>
`
}
}
// Mana Cost & Delay
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
html += `
<div class="tt-row" style="margin-top: 4px;">
<span class="tt-label">${currentLang === 'zh' ? '法力消耗:' : 'Mana Cost:'}</span>
<span class="tt-val">${vm.current.manaCost}</span>
</div>
`
}
if (vm.current.delaySeconds > 0) {
html += `
<div class="tt-row">
<span class="tt-label">${currentLang === 'zh' ? '施法延迟:' : 'Casting Delay:'}</span>
<span class="tt-val">${vm.current.delaySeconds} ${currentLang === 'zh' ? '秒' : 'sec'}</span>
</div>
`
}
html += `</div>` // close tt-section
// Damage & Mechanics Box
const dmg = vm.current.damage
if (dmg.hasDamage || dmg.weaponDamagePercent) {
html += `<div class="tt-damage-box">`
// Final Damage
if (dmg.hasDamage) {
const typeLabel = currentLang === 'zh' ? dmg.typeZh : `${dmg.type.toUpperCase()} Damage`
html += `
<div class="tt-damage-main">
${dmg.finalMin} - ${dmg.finalMax} ${typeLabel}
</div>
`
if (dmg.durationSeconds) {
html += `
<div class="tt-damage-sub">
${currentLang === 'zh' ? `持续时间: ${dmg.durationSeconds} 秒` : `Duration: ${dmg.durationSeconds} seconds`}
</div>
`
}
}
// Weapon Damage %
if (dmg.weaponDamagePercent) {
html += `
<div class="tt-damage-sub">
${currentLang === 'zh' ? `武器伤害: ${dmg.weaponDamagePercent}%` : `Weapon Damage: ${dmg.weaponDamagePercent}%`}
</div>
`
}
// Base damage breakdown if multiplier applied
if (dmg.hasDamage && (dmg.synergyMultiplier > 1.0 || dmg.masteryMultiplier > 1.0)) {
html += `
<div class="tt-damage-sub" style="margin-top: 2px;">
${currentLang === 'zh' ? `基础伤害: ${dmg.baseMin} - ${dmg.baseMax}` : `Base: ${dmg.baseMin} - ${dmg.baseMax}`}
${dmg.synergyBonusPct > 0 ? ` · 协同 +${dmg.synergyBonusPct}%` : ''}
${dmg.masteryBonusPct > 0 ? ` · 精通 +${dmg.masteryBonusPct}%` : ''}
</div>
`
}
html += `</div>`
}
// Skill Effects & Mechanics Box (Stats, Auras, Minions, Buffs, Curses)
if (vm.current.effects && vm.current.effects.length > 0) {
html += `<div class="tt-effects-box">`
for (const eff of vm.current.effects) {
const lineText = currentLang === 'zh' ? eff.textZh : eff.textEn
html += `<div class="tt-effect-line">${lineText}</div>`
}
html += `</div>`
}
// Synergies Received List
if (vm.synergiesReceived.length > 0) {
html += `
<div class="tt-synergies">
<div class="tt-syn-title">
${currentLang === 'zh' ? '接收协同加成 (Synergies Received):' : 'Synergies Received:'}
</div>
`
for (const syn of vm.synergiesReceived) {
const pName = currentLang === 'zh' ? syn.providerNameZh : syn.providerName
const activeClass = syn.hardPoints > 0 ? 'active' : ''
html += `
<div class="tt-syn-item ${activeClass}">
• ${pName}: +${syn.percentPerLevel}% / ${currentLang === 'zh' ? '级' : 'lvl'}
(${currentLang === 'zh' ? `当前 ${syn.hardPoints} 点: +${syn.currentBonusPct}%` : `Current ${syn.hardPoints} pts: +${syn.currentBonusPct}%`})
</div>
`
}
html += `</div>`
}
// Mastery info if applicable
if (vm.masteryInfo && vm.masteryInfo.active) {
html += `
<div class="tt-synergies">
<div class="tt-syn-title">
${currentLang === 'zh' ? '精通加成 (Mastery Applied):' : 'Mastery Applied:'}
</div>
<div class="tt-syn-item active">
• ${currentLang === 'zh' ? vm.masteryInfo.descriptionZh : vm.masteryInfo.descriptionEn}
</div>
</div>
`
}
// Synergies Given List (为此技能提供协同)
if (vm.synergiesGiven.length > 0) {
html += `
<div class="tt-synergies">
<div class="tt-syn-title">
${currentLang === 'zh' ? '输出协同加成 (Synergies Given):' : 'Synergies Given:'}
</div>
`
for (const given of vm.synergiesGiven) {
const tName = currentLang === 'zh' ? given.targetNameZh : given.targetName
html += `
<div class="tt-syn-item">
• ${tName}: +${given.percentPerLevel}% / ${currentLang === 'zh' ? '级' : 'lvl'}
</div>
`
}
html += `</div>`
}
// 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 += `
<div class="tt-next-level">
<div class="tt-next-title">${currentLang === 'zh' ? '[下一级效果]' : '[Next Level]'} (Lv ${vm.nextLevel.level})</div>
`
if (hasNextDmg) {
const typeLabel = currentLang === 'zh' ? nextDmg.typeZh : `${nextDmg.type.toUpperCase()} Damage`
html += `<div>${nextDmg.finalMin} - ${nextDmg.finalMax} ${typeLabel}</div>`
}
if (hasNextEffects) {
for (const eff of vm.nextLevel.effects) {
const lineText = currentLang === 'zh' ? eff.textZh : eff.textEn
html += `<div class="tt-effect-line">${lineText}</div>`
}
}
if (hasManaChange) {
html += `<div>${currentLang === 'zh' ? '法力消耗:' : 'Mana Cost:'} ${vm.nextLevel.manaCost}</div>`
}
html += `</div>`
}
}
// Description / Lore
const desc = currentLang === 'zh' ? (vm.shortDescZh || vm.shortDesc) : vm.shortDesc
if (desc) {
html += `<div class="tt-desc">${desc}</div>`
}
// Helper Hint
html += `
<div class="tt-hint">
${currentLang === 'zh'
? '左键: +1点 (Shift+点击: +5) | 右键: -1点 (Shift+右键: 快速撤点)'
: 'Left-click: +1 (Shift: +5) | Right-click: -1 (Shift: Refund max)'}
</div>
`
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()

View File

@ -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)
})
})

View File

@ -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<string, ReverseLookupEntry[]>
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)')
})
})
})

View File

@ -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'

View File

@ -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'),
},
},
},