chore: remove standalone simulator pages (arena, loot, boss, skills) and related entry code
This commit is contained in:
parent
4914b4f770
commit
a100885552
10
README.md
10
README.md
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
780
arena.html
780
arena.html
|
|
@ -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
924
boss.html
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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);
|
||||
});
|
||||
652
skills.html
652
skills.html
|
|
@ -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>
|
||||
1842
src/arena.ts
1842
src/arena.ts
File diff suppressed because it is too large
Load Diff
658
src/boss.ts
658
src/boss.ts
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Auto-run on DOM ready
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => initBossApp())
|
||||
} else {
|
||||
initBossApp()
|
||||
}
|
||||
}
|
||||
2137
src/loot.ts
2137
src/loot.ts
File diff suppressed because one or more lines are too long
787
src/skills.ts
787
src/skills.ts
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue