This commit is contained in:
parent
78b5351cce
commit
93a854d9ad
|
|
@ -0,0 +1,781 @@
|
|||
<!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>
|
||||
<a href="/index.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>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,813 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateHitChance,
|
||||
calculatePlayerBlockChance,
|
||||
calculateCriticalChance,
|
||||
evaluateCriticalAndDeadlyStrike,
|
||||
calculateCrushingBlowDamage,
|
||||
calculateOpenWoundsDamage,
|
||||
calculateLeech,
|
||||
calculateFreezeTarget,
|
||||
calculateResistanceFactor,
|
||||
calculateResistanceReduction,
|
||||
calculateMonsterRegeneration,
|
||||
evaluatePreventMonsterHeal,
|
||||
calculateKnockbackSuccess,
|
||||
calculateSlowTarget,
|
||||
calculateHitBlindsTarget,
|
||||
calculateHitCausesFlee,
|
||||
calculatePhysicalWeaponDamage,
|
||||
calculatePoisonStack,
|
||||
checkHitRecovery,
|
||||
calculateAttack,
|
||||
D2_EXPERIENCE_TABLE_99,
|
||||
} from '../src/game/formulas.ts'
|
||||
|
||||
describe('Diablo II Combat Mathematics (Issue #24 / M9)', () => {
|
||||
describe('calculateHitChance (AR vs DR)', () => {
|
||||
it('calculates exact 50% hit chance when AR == DR and AttackerLevel == DefenderLevel', () => {
|
||||
// 100% * (2 * 1000 / (1000 + 1000)) * (20 / (20 + 20)) = 100% * 1 * 0.5 = 50%
|
||||
const chance = calculateHitChance(1000, 1000, 20, 20)
|
||||
expect(chance).toBe(50)
|
||||
})
|
||||
|
||||
it('clamps upper bound to 95% even with overwhelmingly high AR and level advantage', () => {
|
||||
const chance = calculateHitChance(50000, 10, 90, 1)
|
||||
expect(chance).toBe(95)
|
||||
})
|
||||
|
||||
it('clamps lower bound to 5% even with tiny AR and severe level disadvantage', () => {
|
||||
const chance = calculateHitChance(10, 50000, 1, 90)
|
||||
expect(chance).toBe(5)
|
||||
})
|
||||
|
||||
it('accurately reproduces community calculator scenario (Level 25 Paladin vs Level 20 Monster)', () => {
|
||||
const chance = calculateHitChance(1200, 800, 25, 20)
|
||||
expect(chance).toBeCloseTo(66.67, 1)
|
||||
})
|
||||
|
||||
it('handles Ignore Target Defense (ITD) against normal monsters vs bosses', () => {
|
||||
// Normal monster: ITD treats defense as 0.
|
||||
// 100% * (2 * 1000 / (1000 + 0)) * (20 / (20 + 20)) = 100% * 2 * 0.5 = 100% -> clamped to 95%
|
||||
const normalWithItd = calculateHitChance(1000, 10000, 20, 20, { ignoreTargetDefense: true, isBossOrChampion: false })
|
||||
expect(normalWithItd).toBe(95)
|
||||
|
||||
// Boss / Champion: ITD fails! Retains full 10000 defense.
|
||||
const bossWithItd = calculateHitChance(1000, 10000, 20, 20, { ignoreTargetDefense: true, isBossOrChampion: true })
|
||||
// 100% * (2000 / 11000) * 0.5 = 9.09%
|
||||
expect(bossWithItd).toBeCloseTo(9.09, 1)
|
||||
})
|
||||
|
||||
it('halves -% Target Defense against bosses (50% effectiveness)', () => {
|
||||
// Normal monster: -50% defense reduces 1000 to 500.
|
||||
const normalDefRed = calculateHitChance(1000, 1000, 20, 20, { targetDefenseReductionPercent: 50, isBossOrChampion: false })
|
||||
// 100% * (2000 / 1500) * 0.5 = 66.67%
|
||||
expect(normalDefRed).toBeCloseTo(66.67, 1)
|
||||
|
||||
// Boss: -50% defense only reduces 25% (defense 1000 -> 750).
|
||||
const bossDefRed = calculateHitChance(1000, 1000, 20, 20, { targetDefenseReductionPercent: 50, isBossOrChampion: true })
|
||||
// 100% * (2000 / 1750) * 0.5 = 57.14%
|
||||
expect(bossDefRed).toBeCloseTo(57.14, 1)
|
||||
})
|
||||
|
||||
it('treats defender DR as 0 when target is running', () => {
|
||||
const runningChance = calculateHitChance(1000, 50000, 20, 20, { isTargetRunning: true })
|
||||
expect(runningChance).toBe(95)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePlayerBlockChance (Shield Blocking)', () => {
|
||||
it('returns 0% if character has <= 15 dexterity or no shield block', () => {
|
||||
expect(calculatePlayerBlockChance(50, 15, 30)).toBe(0)
|
||||
expect(calculatePlayerBlockChance(0, 100, 30)).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates standard max-block target: 75% cap reached with sufficient dexterity', () => {
|
||||
const block = calculatePlayerBlockChance(75, 150, 50)
|
||||
expect(block).toBe(75)
|
||||
})
|
||||
|
||||
it('calculates partial block correctly', () => {
|
||||
const block = calculatePlayerBlockChance(50, 50, 40)
|
||||
expect(block).toBe(21)
|
||||
})
|
||||
|
||||
it('reduces block to 1/3 and caps at 25% when running', () => {
|
||||
const blockRunning = calculatePlayerBlockChance(75, 150, 50, true)
|
||||
expect(blockRunning).toBe(25)
|
||||
|
||||
const partialRunning = calculatePlayerBlockChance(50, 50, 40, true)
|
||||
expect(partialRunning).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Critical Strike (CS) vs Deadly Strike (DS)', () => {
|
||||
it('calculates combined double damage chance (P = CS + DS * (1 - CS))', () => {
|
||||
expect(calculateCriticalChance(25, 0)).toBe(25)
|
||||
expect(calculateCriticalChance(0, 30)).toBe(30)
|
||||
expect(calculateCriticalChance(20, 50)).toBe(60)
|
||||
expect(calculateCriticalChance(50, 50)).toBe(75)
|
||||
expect(calculateCriticalChance(100, 100)).toBe(100)
|
||||
})
|
||||
|
||||
it('evaluates CS first, and bypasses DS roll if CS procs', () => {
|
||||
const rolls = [0.10] // CS roll is 10% (< 50% CS)
|
||||
let idx = 0
|
||||
const res = evaluateCriticalAndDeadlyStrike(50, 50, () => rolls[idx++] ?? 0)
|
||||
expect(res.isCritical).toBe(true)
|
||||
expect(res.triggeredBy).toBe('critical_strike')
|
||||
expect(res.csRoll).toBe(10)
|
||||
expect(res.dsRoll).toBe(0) // DS roll skipped
|
||||
})
|
||||
|
||||
it('evaluates DS if CS roll fails', () => {
|
||||
const rolls = [0.80, 0.20] // CS roll is 80% (fails), DS roll is 20% (< 50% DS -> procs!)
|
||||
let idx = 0
|
||||
const res = evaluateCriticalAndDeadlyStrike(50, 50, () => rolls[idx++] ?? 0)
|
||||
expect(res.isCritical).toBe(true)
|
||||
expect(res.triggeredBy).toBe('deadly_strike')
|
||||
expect(res.csRoll).toBe(80)
|
||||
expect(res.dsRoll).toBe(20)
|
||||
})
|
||||
|
||||
it('returns none if both CS and DS fail', () => {
|
||||
const rolls = [0.80, 0.90] // CS roll 80%, DS roll 90%
|
||||
let idx = 0
|
||||
const res = evaluateCriticalAndDeadlyStrike(50, 50, () => rolls[idx++] ?? 0)
|
||||
expect(res.isCritical).toBe(false)
|
||||
expect(res.triggeredBy).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateCrushingBlowDamage', () => {
|
||||
it('deals 25% of current HP on melee hit against normal monster', () => {
|
||||
expect(calculateCrushingBlowDamage(1000, 'normal', false)).toBe(250)
|
||||
expect(calculateCrushingBlowDamage(1000, false, false)).toBe(250) // backward-compatibility
|
||||
})
|
||||
|
||||
it('deals 12.5% of current HP on ranged hit against normal monster', () => {
|
||||
expect(calculateCrushingBlowDamage(1000, 'normal', true)).toBe(125)
|
||||
expect(calculateCrushingBlowDamage(1000, false, true)).toBe(125)
|
||||
})
|
||||
|
||||
it('deals 12.5% of current HP on melee hit against boss', () => {
|
||||
expect(calculateCrushingBlowDamage(1000, 'boss', false)).toBe(125)
|
||||
expect(calculateCrushingBlowDamage(1000, true, false)).toBe(125)
|
||||
})
|
||||
|
||||
it('deals 6.25% of current HP on ranged hit against boss', () => {
|
||||
expect(calculateCrushingBlowDamage(1000, 'boss', true)).toBe(62)
|
||||
expect(calculateCrushingBlowDamage(1000, true, true)).toBe(62)
|
||||
})
|
||||
|
||||
it('deals 10% melee / 5% ranged on player tier (PvP)', () => {
|
||||
expect(calculateCrushingBlowDamage(1000, 'player', false)).toBe(100)
|
||||
expect(calculateCrushingBlowDamage(1000, 'player', true)).toBe(50)
|
||||
})
|
||||
|
||||
it('reduces CB damage by target physical resistance (DR%)', () => {
|
||||
// 1000 HP, normal melee = 250 base. With 50% physical resistance -> 125
|
||||
expect(calculateCrushingBlowDamage(1000, 'normal', false, 50)).toBe(125)
|
||||
// With 75% physical resistance -> 62
|
||||
expect(calculateCrushingBlowDamage(1000, 'normal', false, 75)).toBe(62)
|
||||
})
|
||||
|
||||
it('completely negates CB if target has 100% or greater physical resistance (Physical Immune)', () => {
|
||||
expect(calculateCrushingBlowDamage(1000, 'normal', false, 100)).toBe(0)
|
||||
expect(calculateCrushingBlowDamage(1000, 'normal', false, 120)).toBe(0)
|
||||
})
|
||||
|
||||
it('does NOT amplify CB damage when physical resistance is negative (e.g. Amplify Damage / Decrepify)', () => {
|
||||
// 1000 HP base = 250. Negative DR cannot increase CB damage above 1.0 factor!
|
||||
expect(calculateCrushingBlowDamage(1000, 'normal', false, -50)).toBe(250)
|
||||
expect(calculateCrushingBlowDamage(1000, 'normal', false, -100)).toBe(250)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateOpenWoundsDamage (OW 200-frame level formula)', () => {
|
||||
it('calculates exact damage for Level 1–15 bracket', () => {
|
||||
// Level 10: 9 * 10 + 31 = 121 / 256 HP/frame. 200 frames: floor(121 * 200 / 256) = 94 HP
|
||||
const ow = calculateOpenWoundsDamage(10, 'normal', false)
|
||||
expect(ow.lifeDrainPerFrame).toBe(121)
|
||||
expect(ow.totalDamage200Frames).toBe(94)
|
||||
expect(ow.durationSeconds).toBe(8.0)
|
||||
})
|
||||
|
||||
it('calculates exact damage for Level 16–30 bracket', () => {
|
||||
// Level 25: 18 * 25 - 104 = 346 / 256. 200 frames: floor(346 * 200 / 256) = 270 HP
|
||||
const ow = calculateOpenWoundsDamage(25, 'normal', false)
|
||||
expect(ow.lifeDrainPerFrame).toBe(346)
|
||||
expect(ow.totalDamage200Frames).toBe(270)
|
||||
})
|
||||
|
||||
it('calculates exact damage for Level 31–45 bracket', () => {
|
||||
// Level 40: 27 * 40 - 374 = 706 / 256. 200 frames: floor(706 * 200 / 256) = 551 HP
|
||||
const ow = calculateOpenWoundsDamage(40, 'normal', false)
|
||||
expect(ow.lifeDrainPerFrame).toBe(706)
|
||||
expect(ow.totalDamage200Frames).toBe(551)
|
||||
})
|
||||
|
||||
it('calculates exact damage for Level 46–60 bracket', () => {
|
||||
// Level 55: 36 * 55 - 779 = 1201 / 256. 200 frames: floor(1201 * 200 / 256) = 938 HP
|
||||
const ow = calculateOpenWoundsDamage(55, 'normal', false)
|
||||
expect(ow.lifeDrainPerFrame).toBe(1201)
|
||||
expect(ow.totalDamage200Frames).toBe(938)
|
||||
})
|
||||
|
||||
it('calculates exact damage for Level >60 bracket', () => {
|
||||
// Level 80: 45 * 80 - 1319 = 2281 / 256. 200 frames: floor(2281 * 200 / 256) = 1782 HP
|
||||
const ow = calculateOpenWoundsDamage(80, 'normal', false)
|
||||
expect(ow.lifeDrainPerFrame).toBe(2281)
|
||||
expect(ow.totalDamage200Frames).toBe(1782)
|
||||
})
|
||||
|
||||
it('halves damage against Bosses (0.5 modifier)', () => {
|
||||
// Level 80 against Boss: 1782 / 2 = 891 HP
|
||||
const owBoss = calculateOpenWoundsDamage(80, 'boss', false)
|
||||
expect(owBoss.modifierFactor).toBe(0.5)
|
||||
expect(owBoss.totalDamage200Frames).toBe(891)
|
||||
})
|
||||
|
||||
it('applies player modifiers (0.25 melee / 0.125 ranged)', () => {
|
||||
const owPlayerMelee = calculateOpenWoundsDamage(80, 'player', false)
|
||||
expect(owPlayerMelee.modifierFactor).toBe(0.25)
|
||||
expect(owPlayerMelee.totalDamage200Frames).toBe(445)
|
||||
|
||||
const owPlayerRanged = calculateOpenWoundsDamage(80, 'player', true)
|
||||
expect(owPlayerRanged.modifierFactor).toBe(0.125)
|
||||
expect(owPlayerRanged.totalDamage200Frames).toBe(222)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateLeech (Life & Mana Steal)', () => {
|
||||
it('leeches correctly in Normal difficulty with 100% drain effectiveness', () => {
|
||||
// 100 physical damage, 10% LL, 5% ML
|
||||
const leech = calculateLeech(100, 10, 5, 100, 'normal')
|
||||
expect(leech.lifeLeeched).toBe(10)
|
||||
expect(leech.manaLeeched).toBe(5)
|
||||
})
|
||||
|
||||
it('applies 50% penalty in Nightmare and 33.3% penalty in Hell', () => {
|
||||
// 100 physical damage, 20% LL
|
||||
const leechNM = calculateLeech(100, 20, 0, 100, 'nightmare')
|
||||
expect(leechNM.lifeLeeched).toBe(10) // 20 * 0.5 = 10
|
||||
|
||||
const leechHell = calculateLeech(100, 30, 0, 100, 'hell')
|
||||
expect(leechHell.lifeLeeched).toBe(10) // 30 * (1/3) = 10
|
||||
})
|
||||
|
||||
it('returns 0 leech against targets with 0% drain effectiveness (e.g. Skeletons)', () => {
|
||||
const leechSkeleton = calculateLeech(500, 20, 20, 0, 'normal')
|
||||
expect(leechSkeleton.lifeLeeched).toBe(0)
|
||||
expect(leechSkeleton.manaLeeched).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 leech if physical damage dealt is 0', () => {
|
||||
const leechZeroDmg = calculateLeech(0, 20, 20, 100, 'normal')
|
||||
expect(leechZeroDmg.lifeLeeched).toBe(0)
|
||||
expect(leechZeroDmg.manaLeeched).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateFreezeTarget', () => {
|
||||
it('calculates freeze chance and duration on normal monster', () => {
|
||||
// Bonus = 1, AttLvl = 30, DefLvl = 20, Melee:
|
||||
// Chance = 5 * (4*1 + 30 - 20) + 30 = 5 * 14 + 30 = 100%
|
||||
const res = calculateFreezeTarget(1, 30, 20, 'normal', false, 'normal', () => 0.10)
|
||||
expect(res.willFreeze).toBe(true)
|
||||
expect(res.isChilledOnly).toBe(false)
|
||||
expect(res.durationSeconds).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('falls back to chill on Bosses without hard-freezing', () => {
|
||||
const resBoss = calculateFreezeTarget(2, 50, 50, 'boss', false, 'normal', () => 0.05)
|
||||
expect(resBoss.willFreeze).toBe(false)
|
||||
expect(resBoss.isChilledOnly).toBe(true)
|
||||
expect(resBoss.durationSeconds).toBe(3.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateResistanceFactor (Elemental Resistances & Immunities)', () => {
|
||||
it('handles immunity at 100% or higher', () => {
|
||||
const res100 = calculateResistanceFactor(100)
|
||||
expect(res100.isImmune).toBe(true)
|
||||
expect(res100.factor).toBe(0)
|
||||
|
||||
const res120 = calculateResistanceFactor(120)
|
||||
expect(res120.isImmune).toBe(true)
|
||||
expect(res120.factor).toBe(0)
|
||||
})
|
||||
|
||||
it('handles normal resistance (0% to 99%)', () => {
|
||||
expect(calculateResistanceFactor(0).factor).toBe(1.0)
|
||||
expect(calculateResistanceFactor(50).factor).toBe(0.5)
|
||||
expect(calculateResistanceFactor(75).factor).toBe(0.25)
|
||||
})
|
||||
|
||||
it('handles negative resistance (amplified damage up to 2x at -100%)', () => {
|
||||
expect(calculateResistanceFactor(-50).factor).toBe(1.5)
|
||||
expect(calculateResistanceFactor(-100).factor).toBe(2.0)
|
||||
expect(calculateResistanceFactor(-150).factor).toBe(2.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkHitRecovery', () => {
|
||||
it('triggers 100% when damage >= 25% of MaxHP', () => {
|
||||
const check = checkHitRecovery(30, 100, 0.99)
|
||||
expect(check.chancePercent).toBe(100)
|
||||
expect(check.triggered).toBe(true)
|
||||
})
|
||||
|
||||
it('triggers 50% chance when 12.5% <= damage < 25% of MaxHP', () => {
|
||||
const checkPass = checkHitRecovery(20, 100, 0.40)
|
||||
expect(checkPass.chancePercent).toBe(50)
|
||||
expect(checkPass.triggered).toBe(true)
|
||||
|
||||
const checkFail = checkHitRecovery(20, 100, 0.60)
|
||||
expect(checkFail.chancePercent).toBe(50)
|
||||
expect(checkFail.triggered).toBe(false)
|
||||
})
|
||||
|
||||
it('triggers 25% chance when 6.25% <= damage < 12.5% of MaxHP', () => {
|
||||
const checkPass = checkHitRecovery(10, 100, 0.20)
|
||||
expect(checkPass.chancePercent).toBe(25)
|
||||
expect(checkPass.triggered).toBe(true)
|
||||
|
||||
const checkFail = checkHitRecovery(10, 100, 0.30)
|
||||
expect(checkFail.chancePercent).toBe(25)
|
||||
expect(checkFail.triggered).toBe(false)
|
||||
})
|
||||
|
||||
it('does not trigger when damage < 6.25% of MaxHP', () => {
|
||||
const check = checkHitRecovery(5, 100, 0.01)
|
||||
expect(check.chancePercent).toBe(0)
|
||||
expect(check.triggered).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateAttack integrated', () => {
|
||||
it('correctly reports a miss when hitRoll exceeds hitChancePercent', () => {
|
||||
const result = calculateAttack(
|
||||
10, 100,
|
||||
{ physicalMin: 10, physicalMax: 20 },
|
||||
{ level: 20, defense: 500, blockPercent: 0, currentHp: 100, maxHp: 100 },
|
||||
{},
|
||||
() => 0.90,
|
||||
)
|
||||
expect(result.isHit).toBe(false)
|
||||
expect(result.totalDamage).toBe(0)
|
||||
})
|
||||
|
||||
it('correctly reports blocked hit and negates damage', () => {
|
||||
const rolls = [0.10, 0.10]
|
||||
let idx = 0
|
||||
const result = calculateAttack(
|
||||
50, 5000,
|
||||
{ physicalMin: 50, physicalMax: 50 },
|
||||
{ level: 50, defense: 1000, blockPercent: 50, currentHp: 200, maxHp: 200 },
|
||||
{},
|
||||
() => rolls[idx++] ?? 0.5,
|
||||
)
|
||||
expect(result.isHit).toBe(true)
|
||||
expect(result.isBlocked).toBe(true)
|
||||
expect(result.totalDamage).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates full damage and triggers hit recovery on critical strike', () => {
|
||||
// 1: hit roll = 0.20 (hit)
|
||||
// 2: crit roll (CS) = 0.10 (< 50% -> critical strike)
|
||||
// 3: damage roll = 0.50 (20 * 2 = 40)
|
||||
// 4: hit recovery roll = 0.10 (< 100% -> triggered)
|
||||
const rolls = [0.20, 0.10, 0.50, 0.10]
|
||||
let idx = 0
|
||||
const result = calculateAttack(
|
||||
50, 5000,
|
||||
{ physicalMin: 20, physicalMax: 20, fire: 15 },
|
||||
{ level: 50, defense: 1000, blockPercent: 0, resPhysical: 25, resFire: -20, currentHp: 100, maxHp: 100 },
|
||||
{ criticalStrikePercent: 50 },
|
||||
() => rolls[idx++] ?? 0.5,
|
||||
)
|
||||
expect(result.isHit).toBe(true)
|
||||
expect(result.isBlocked).toBe(false)
|
||||
expect(result.isCritical).toBe(true)
|
||||
expect(result.criticalTrigger).toBe('critical_strike')
|
||||
// Physical: 20 * 2 (crit) = 40. Res 25% => 40 * 0.75 = 30.
|
||||
expect(result.finalPhysicalDamage).toBe(30)
|
||||
// Fire: 15. Res -20% => 15 * 1.20 = 18.
|
||||
expect(result.finalFireDamage).toBe(18)
|
||||
expect(result.totalDamage).toBe(48)
|
||||
expect(result.hitRecoveryTriggered).toBe(true)
|
||||
})
|
||||
|
||||
it('integrates Open Wounds and Leech into combat result', () => {
|
||||
// 1: hit roll = 0.10 (hit)
|
||||
// 2: OW roll = 0.10 (< 100% -> triggers OW)
|
||||
// 3: damage roll = 0.50 (50 physical)
|
||||
// 4: recovery roll = 0.90
|
||||
const rolls = [0.10, 0.10, 0.50, 0.90]
|
||||
let idx = 0
|
||||
const result = calculateAttack(
|
||||
50, 5000,
|
||||
{ physicalMin: 50, physicalMax: 50 },
|
||||
{ level: 50, defense: 100, blockPercent: 0, currentHp: 500, maxHp: 500, drainEffectiveness: 100 },
|
||||
{ openWoundsPercent: 100, lifeLeechPercent: 20 },
|
||||
() => rolls[idx++] ?? 0.5,
|
||||
)
|
||||
expect(result.isHit).toBe(true)
|
||||
expect(result.isOpenWounds).toBe(true)
|
||||
expect(result.openWoundsResult?.attackerLevel).toBe(50)
|
||||
expect(result.finalPhysicalDamage).toBe(50)
|
||||
expect(result.lifeLeeched).toBe(10) // 50 * 0.20 = 10
|
||||
})
|
||||
|
||||
it('bypasses AR/DR when isSpell is set', () => {
|
||||
const result = calculateAttack(
|
||||
1, 0,
|
||||
{ physicalMin: 0, physicalMax: 0, fire: 50 },
|
||||
{ level: 90, defense: 99999, blockPercent: 75, resFire: 0, currentHp: 500, maxHp: 500 },
|
||||
{ isSpell: true },
|
||||
() => 0.99,
|
||||
)
|
||||
expect(result.isHit).toBe(true)
|
||||
expect(result.isBlocked).toBe(false)
|
||||
expect(result.finalFireDamage).toBe(50)
|
||||
expect(result.totalDamage).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('D2_EXPERIENCE_TABLE_99', () => {
|
||||
it('contains exact 100 elements (indices 0 to 99)', () => {
|
||||
expect(D2_EXPERIENCE_TABLE_99.length).toBe(100)
|
||||
})
|
||||
|
||||
it('matches known progression thresholds', () => {
|
||||
expect(D2_EXPERIENCE_TABLE_99[1]).toBe(0)
|
||||
expect(D2_EXPERIENCE_TABLE_99[2]).toBe(500)
|
||||
expect(D2_EXPERIENCE_TABLE_99[10]).toBe(57715)
|
||||
expect(D2_EXPERIENCE_TABLE_99[30]).toBe(4663904)
|
||||
expect(D2_EXPERIENCE_TABLE_99[90]).toBe(1619361124)
|
||||
expect(D2_EXPERIENCE_TABLE_99[99]).toBe(3522678586)
|
||||
})
|
||||
|
||||
it('is strictly monotonic increasing from level 1 to 99', () => {
|
||||
for (let i = 2; i <= 99; i++) {
|
||||
expect(D2_EXPERIENCE_TABLE_99[i]!).toBeGreaterThan(D2_EXPERIENCE_TABLE_99[i - 1]!)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PvE Depth Mechanics (Issue #39)', () => {
|
||||
describe('calculateResistanceReduction (1/5th Rule & Immunity Breaking)', () => {
|
||||
it('applies 1/5th penalty to Conviction and Lower Resist against immune monsters (>= 100% res)', () => {
|
||||
// Monster has 110% Lightning resistance.
|
||||
// Conviction Level 12 provides -85% res. Against immune target, only 85 / 5 = 17% is effective!
|
||||
// 110% - 17% = 93% (< 100% => Immunity Broken!)
|
||||
const result = calculateResistanceReduction(110, 85, 0, 0)
|
||||
expect(result.wasImmune).toBe(true)
|
||||
expect(result.effectiveConviction).toBe(17)
|
||||
expect(result.resAfterSkills).toBe(93)
|
||||
expect(result.immunityBroken).toBe(true)
|
||||
expect(result.isImmune).toBe(false)
|
||||
expect(result.finalRes).toBe(93)
|
||||
expect(result.damageMultiplier).toBeCloseTo(0.07, 2)
|
||||
})
|
||||
|
||||
it('prevents direct item -% resistance from working when immunity is NOT broken', () => {
|
||||
// Monster has 120% Fire resistance.
|
||||
// Conviction 85% provides 17% effective reduction -> 120% - 17% = 103% (still >= 100%!).
|
||||
// Player has -20% Enemy Fire Resistance from equipment.
|
||||
// In D2, equipment -% res CANNOT break immunity and does NOT apply if target remains immune!
|
||||
const result = calculateResistanceReduction(120, 85, 0, 20)
|
||||
expect(result.wasImmune).toBe(true)
|
||||
expect(result.immunityBroken).toBe(false)
|
||||
expect(result.isImmune).toBe(true)
|
||||
expect(result.effectiveItemMinusRes).toBe(0) // Item minus res completely fails!
|
||||
expect(result.finalRes).toBe(103)
|
||||
expect(result.damageMultiplier).toBe(0)
|
||||
})
|
||||
|
||||
it('applies direct item -% resistance at 100% full strength once immunity is broken by skills', () => {
|
||||
// Monster has 110% Lightning resistance.
|
||||
// Conviction 85% -> 17% reduction -> 93% res (broken!).
|
||||
// Now equipment -25% from Griffon's Eye applies at 100% full effect!
|
||||
// 93% - 25% = 68% final resistance.
|
||||
const result = calculateResistanceReduction(110, 85, 0, 25)
|
||||
expect(result.immunityBroken).toBe(true)
|
||||
expect(result.effectiveItemMinusRes).toBe(25)
|
||||
expect(result.finalRes).toBe(68)
|
||||
expect(result.damageMultiplier).toBeCloseTo(0.32, 2)
|
||||
})
|
||||
|
||||
it('combines Conviction and Lower Resist both with 1/5th penalty', () => {
|
||||
// Monster has 130% Lightning resistance.
|
||||
// Conviction 85% -> 17% reduction.
|
||||
// Lower Resist 60% -> 12% reduction.
|
||||
// Total skill reduction = 17 + 12 = 29% -> 130 - 29 = 101% (still immune!).
|
||||
const stillImmune = calculateResistanceReduction(130, 85, 60, 0)
|
||||
expect(stillImmune.effectiveConviction).toBe(17)
|
||||
expect(stillImmune.effectiveLowerRes).toBe(12)
|
||||
expect(stillImmune.resAfterSkills).toBe(101)
|
||||
expect(stillImmune.isImmune).toBe(true)
|
||||
|
||||
// But against 125% res: 125 - 29 = 96% (broken!).
|
||||
const broken = calculateResistanceReduction(125, 85, 60, 0)
|
||||
expect(broken.immunityBroken).toBe(true)
|
||||
expect(broken.resAfterSkills).toBe(96)
|
||||
expect(broken.isImmune).toBe(false)
|
||||
})
|
||||
|
||||
it('applies 100% full effectiveness to non-immune monsters (< 100% res) and respects -100% cap', () => {
|
||||
// Monster has 50% Fire resistance.
|
||||
// Conviction 85% + Lower Resist 30% + Equipment -25% = -140% total reduction.
|
||||
// 50% - 140% = -90%.
|
||||
const result = calculateResistanceReduction(50, 85, 30, 25)
|
||||
expect(result.wasImmune).toBe(false)
|
||||
expect(result.effectiveConviction).toBe(85)
|
||||
expect(result.effectiveLowerRes).toBe(30)
|
||||
expect(result.effectiveItemMinusRes).toBe(25)
|
||||
expect(result.finalRes).toBe(-90)
|
||||
expect(result.damageMultiplier).toBe(1.9)
|
||||
|
||||
// Lower bound clamp at -100% (2x damage)
|
||||
const capped = calculateResistanceReduction(0, 85, 60, 25)
|
||||
expect(capped.finalRes).toBe(-100)
|
||||
expect(capped.damageMultiplier).toBe(2.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateMonsterRegeneration & evaluatePreventMonsterHeal', () => {
|
||||
it('calculates natural monster regeneration per frame and per second', () => {
|
||||
// Monster with 40,960 HP, regen rate 10
|
||||
// Regen/frame = (40960 * 10) / 4096 = 100 HP/frame
|
||||
// Regen/sec = 100 * 25 = 2500 HP/sec
|
||||
const regen = calculateMonsterRegeneration(40960, 10)
|
||||
expect(regen.rawRegenPerFrame).toBe(100)
|
||||
expect(regen.rawRegenPerSecond).toBe(2500)
|
||||
expect(regen.effectiveRegenPerFrame).toBe(100)
|
||||
expect(regen.effectiveRegenPerSecond).toBe(2500)
|
||||
})
|
||||
|
||||
it('suppresses natural monster regeneration to 0 when Open Wounds (OW) is active', () => {
|
||||
const regen = calculateMonsterRegeneration(40960, 10, true, false)
|
||||
expect(regen.isBlockedByOW).toBe(true)
|
||||
expect(regen.effectiveRegenPerFrame).toBe(0)
|
||||
expect(regen.effectiveRegenPerSecond).toBe(0)
|
||||
})
|
||||
|
||||
it('suppresses natural monster regeneration to 0 when PMH is active', () => {
|
||||
const regen = calculateMonsterRegeneration(40960, 10, false, true)
|
||||
expect(regen.isBlockedByPMH).toBe(true)
|
||||
expect(regen.effectiveRegenPerFrame).toBe(0)
|
||||
expect(regen.effectiveRegenPerSecond).toBe(0)
|
||||
})
|
||||
|
||||
it('evaluates Prevent Monster Heal constraints (player only, uber immunity, 80 min duration)', () => {
|
||||
// Player attack succeeds
|
||||
const playerAttack = evaluatePreventMonsterHeal('player', 'boss')
|
||||
expect(playerAttack.applied).toBe(true)
|
||||
expect(playerAttack.durationFrames).toBe(120000)
|
||||
expect(playerAttack.durationSeconds).toBe(4800)
|
||||
|
||||
// Mercenary attack fails
|
||||
const mercAttack = evaluatePreventMonsterHeal('mercenary', 'boss')
|
||||
expect(mercAttack.applied).toBe(false)
|
||||
expect(mercAttack.durationFrames).toBe(0)
|
||||
|
||||
// Minion / Iron Golem attack fails
|
||||
const minionAttack = evaluatePreventMonsterHeal('minion', 'boss')
|
||||
expect(minionAttack.applied).toBe(false)
|
||||
|
||||
// Pandemonium Ubers are immune to PMH
|
||||
const uberAttack = evaluatePreventMonsterHeal('player', 'uber')
|
||||
expect(uberAttack.applied).toBe(false)
|
||||
expect(uberAttack.durationFrames).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateKnockbackSuccess (Size & Boss Immunity)', () => {
|
||||
it('never knocks back bosses or siege beasts', () => {
|
||||
const bossKb = calculateKnockbackSuccess(true, 'small', true, false, () => 0.01)
|
||||
expect(bossKb.triggered).toBe(false)
|
||||
expect(bossKb.finalChancePercent).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0% if attack has no knockback affix', () => {
|
||||
const noKb = calculateKnockbackSuccess(false, 'small', false, false, () => 0.01)
|
||||
expect(noKb.triggered).toBe(false)
|
||||
expect(noKb.hasKnockbackAffix).toBe(false)
|
||||
})
|
||||
|
||||
it('scales knockback chance by target size for melee attacks (Small 100%, Medium 50%, Large 25%)', () => {
|
||||
const small = calculateKnockbackSuccess(true, 'small', false, false, () => 0.99)
|
||||
expect(small.baseSizeChancePercent).toBe(100)
|
||||
expect(small.triggered).toBe(true) // 100% always triggers
|
||||
|
||||
const medSuccess = calculateKnockbackSuccess(true, 'medium', false, false, () => 0.40)
|
||||
expect(medSuccess.baseSizeChancePercent).toBe(50)
|
||||
expect(medSuccess.triggered).toBe(true)
|
||||
|
||||
const medFail = calculateKnockbackSuccess(true, 'medium', false, false, () => 0.60)
|
||||
expect(medFail.triggered).toBe(false)
|
||||
|
||||
const largeSuccess = calculateKnockbackSuccess(true, 'large', false, false, () => 0.20)
|
||||
expect(largeSuccess.baseSizeChancePercent).toBe(25)
|
||||
expect(largeSuccess.triggered).toBe(true)
|
||||
|
||||
const largeFail = calculateKnockbackSuccess(true, 'large', false, false, () => 0.30)
|
||||
expect(largeFail.triggered).toBe(false)
|
||||
})
|
||||
|
||||
it('halves knockback chances for ranged attacks (Small 50%, Medium 25%, Large 12.5%)', () => {
|
||||
const smallRanged = calculateKnockbackSuccess(true, 'small', false, true, () => 0.40)
|
||||
expect(smallRanged.baseSizeChancePercent).toBe(50)
|
||||
expect(smallRanged.triggered).toBe(true)
|
||||
|
||||
const medRanged = calculateKnockbackSuccess(true, 'medium', false, true, () => 0.20)
|
||||
expect(medRanged.baseSizeChancePercent).toBe(25)
|
||||
expect(medRanged.triggered).toBe(true)
|
||||
|
||||
const largeRanged = calculateKnockbackSuccess(true, 'large', false, true, () => 0.10)
|
||||
expect(largeRanged.baseSizeChancePercent).toBe(12.5)
|
||||
expect(largeRanged.triggered).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateSlowTarget', () => {
|
||||
it('allows up to 90% slow on normal monsters', () => {
|
||||
const slow75 = calculateSlowTarget(75, false)
|
||||
expect(slow75.effectiveSlowPercent).toBe(75)
|
||||
|
||||
const slow95 = calculateSlowTarget(95, false)
|
||||
expect(slow95.effectiveSlowPercent).toBe(90) // Capped at 90%
|
||||
})
|
||||
|
||||
it('caps slow at 50% for bosses and champions', () => {
|
||||
const bossSlow = calculateSlowTarget(75, true)
|
||||
expect(bossSlow.effectiveSlowPercent).toBe(50) // Capped at 50%
|
||||
})
|
||||
})
|
||||
|
||||
describe('Crowd Control: Hit Blinds Target (HBT) & Hit Causes Monster to Flee (HCMTF)', () => {
|
||||
it('calculates Hit Blinds Target chance, duration and boss immunity', () => {
|
||||
// Attacker Lv50, Defender Lv40, Bonus 1:
|
||||
// Raw melee chance = 50 + (50 + 4 - 40) * 2 = 50 + 28 = 78%
|
||||
const blindMelee = calculateHitBlindsTarget(1, 50, 40, false, false, () => 0.50)
|
||||
expect(blindMelee.chancePercent).toBe(78)
|
||||
expect(blindMelee.triggered).toBe(true)
|
||||
expect(blindMelee.durationSeconds).toBe(8) // (1*4+4) = 8s
|
||||
expect(blindMelee.durationFrames).toBe(200)
|
||||
|
||||
// Ranged divides chance by 3: 78 / 3 = 26%
|
||||
const blindRanged = calculateHitBlindsTarget(1, 50, 40, false, true, () => 0.20)
|
||||
expect(blindRanged.chancePercent).toBe(26)
|
||||
expect(blindRanged.triggered).toBe(true)
|
||||
|
||||
// Bosses are immune
|
||||
const blindBoss = calculateHitBlindsTarget(1, 50, 40, true, false, () => 0.01)
|
||||
expect(blindBoss.triggered).toBe(false)
|
||||
expect(blindBoss.chancePercent).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates Hit Causes Monster to Flee duration (125 frames / 5s) and boss immunity', () => {
|
||||
const fleeNormal = calculateHitCausesFlee(50, false, () => 0.20)
|
||||
expect(fleeNormal.triggered).toBe(true)
|
||||
expect(fleeNormal.durationSeconds).toBe(5.0)
|
||||
expect(fleeNormal.durationFrames).toBe(125)
|
||||
|
||||
const fleeBoss = calculateHitCausesFlee(50, true, () => 0.01)
|
||||
expect(fleeBoss.triggered).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePhysicalWeaponDamage (Off-Weapon ED & Grief +Flat Damage)', () => {
|
||||
it('calculates pure Grief Phase Blade with Fortitude and Strength bonus', () => {
|
||||
// Phase Blade base damage: 31 to 35
|
||||
// On-weapon ED: 0%
|
||||
// Grief flat damage: +400
|
||||
// Base damage becomes: [31+400, 35+400] = [431, 435]
|
||||
// Off-Weapon ED: 300% (Fortitude Armor) + 150% (Fanaticism Aura) = 450%
|
||||
// Stat Bonus: 150% (Strength)
|
||||
// Total Multiplier = 1 + (450 + 150) / 100 = 7.0x
|
||||
// Final Min = 431 * 7 = 3017
|
||||
// Final Max = 435 * 7 = 3045
|
||||
const griefDmg = calculatePhysicalWeaponDamage({
|
||||
weaponBaseMin: 31,
|
||||
weaponBaseMax: 35,
|
||||
onWeaponEdPercent: 0,
|
||||
flatDamage: 400,
|
||||
offWeaponEdPercent: 450,
|
||||
statBonusPercent: 150,
|
||||
})
|
||||
expect(griefDmg.baseMinWithFlat).toBe(431)
|
||||
expect(griefDmg.baseMaxWithFlat).toBe(435)
|
||||
expect(griefDmg.totalMultiplier).toBe(7.0)
|
||||
expect(griefDmg.finalMinDamage).toBe(3017)
|
||||
expect(griefDmg.finalMaxDamage).toBe(3045)
|
||||
})
|
||||
|
||||
it('correctly layers on-weapon ED before flat damage', () => {
|
||||
// Base: 10 to 20
|
||||
// On-Weapon ED: 100% -> [20, 40]
|
||||
// Flat Damage: +10 -> [30, 50]
|
||||
// Off-Weapon ED: 100% -> Multiplier 2.0x -> [60, 100]
|
||||
const dmg = calculatePhysicalWeaponDamage({
|
||||
weaponBaseMin: 10,
|
||||
weaponBaseMax: 20,
|
||||
onWeaponEdPercent: 100,
|
||||
flatDamage: 10,
|
||||
offWeaponEdPercent: 100,
|
||||
})
|
||||
expect(dmg.baseMinWithWeaponEd).toBe(20)
|
||||
expect(dmg.baseMaxWithWeaponEd).toBe(40)
|
||||
expect(dmg.baseMinWithFlat).toBe(30)
|
||||
expect(dmg.baseMaxWithFlat).toBe(50)
|
||||
expect(dmg.finalMinDamage).toBe(60)
|
||||
expect(dmg.finalMaxDamage).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePoisonStack (Bit Rates & Averaging)', () => {
|
||||
it('calculates bit rate summation and average duration across multiple poison charms', () => {
|
||||
// Source 1: 100 poison damage over 5 seconds (125 frames)
|
||||
// Bit rate 1 = (100 * 256) / 125 = 204.8
|
||||
// Source 2: 50 poison damage over 3 seconds (75 frames)
|
||||
// Bit rate 2 = (50 * 256) / 75 = 170.6667
|
||||
// Total BitRate = 204.8 + 170.6667 = 375.4667
|
||||
// Average Duration = (125 + 75) / 2 = 100 frames (4.0 seconds)
|
||||
// Total Poison Damage = floor(375.4667 * 100 / 256) = floor(146.6667) = 146
|
||||
const poison = calculatePoisonStack([
|
||||
{ damage: 100, durationSeconds: 5 },
|
||||
{ damage: 50, durationSeconds: 3 },
|
||||
])
|
||||
expect(poison.sourcesCount).toBe(2)
|
||||
expect(poison.averageDurationFrames).toBe(100)
|
||||
expect(poison.averageDurationSeconds).toBe(4.0)
|
||||
expect(poison.totalPoisonDamage).toBe(146)
|
||||
expect(poison.damagePerSecond).toBeCloseTo(36.67, 1)
|
||||
})
|
||||
|
||||
it('handles empty poison sources safely', () => {
|
||||
const empty = calculatePoisonStack([])
|
||||
expect(empty.totalPoisonDamage).toBe(0)
|
||||
expect(empty.totalBitRate).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateAttack with integrated PvE options', () => {
|
||||
it('breaks elemental immunity with conviction and applies equipment -% res', () => {
|
||||
const rolls = [0.1, 0.5, 0.5]
|
||||
let idx = 0
|
||||
const result = calculateAttack(
|
||||
80, 5000,
|
||||
{ physicalMin: 10, physicalMax: 10, lightning: 1000 },
|
||||
{ level: 80, defense: 500, blockPercent: 0, resLightning: 110, currentHp: 5000, maxHp: 5000 },
|
||||
{
|
||||
convictionPenalty: 85,
|
||||
itemMinusLightRes: 25,
|
||||
},
|
||||
() => rolls[idx++] ?? 0.5,
|
||||
)
|
||||
// Lightning resistance 110% -> 93% (Conviction 17%) -> 68% (Griffon -25%) -> 32% damage factor
|
||||
expect(result.resistanceBreakResults?.lightning?.immunityBroken).toBe(true)
|
||||
expect(result.resistanceBreakResults?.lightning?.finalRes).toBe(68)
|
||||
expect(result.finalLightningDamage).toBe(320)
|
||||
})
|
||||
|
||||
it('evaluates knockback size and boss immunity in calculateAttack', () => {
|
||||
// Small monster knocked back
|
||||
const smallResult = calculateAttack(
|
||||
50, 5000,
|
||||
{ physicalMin: 20, physicalMax: 20 },
|
||||
{ level: 50, defense: 100, blockPercent: 0, currentHp: 200, maxHp: 200, size: 'small', tier: 'normal' },
|
||||
{ knockbackPercent: 100 },
|
||||
() => 0.05,
|
||||
)
|
||||
expect(smallResult.knockbackTriggered).toBe(true)
|
||||
|
||||
// Boss monster immune to knockback
|
||||
const bossResult = calculateAttack(
|
||||
50, 5000,
|
||||
{ physicalMin: 20, physicalMax: 20 },
|
||||
{ level: 50, defense: 100, blockPercent: 0, currentHp: 200, maxHp: 200, size: 'small', tier: 'boss' },
|
||||
{ knockbackPercent: 100 },
|
||||
() => 0.05,
|
||||
)
|
||||
expect(bossResult.knockbackTriggered).toBe(false)
|
||||
})
|
||||
|
||||
it('applies PMH and calculates monster natural regeneration suppression', () => {
|
||||
const result = calculateAttack(
|
||||
50, 5000,
|
||||
{ physicalMin: 50, physicalMax: 50 },
|
||||
{ level: 50, defense: 100, blockPercent: 0, currentHp: 40960, maxHp: 40960, regenRate: 10, tier: 'boss' },
|
||||
{ preventMonsterHeal: true, attackerType: 'player' },
|
||||
() => 0.05,
|
||||
)
|
||||
expect(result.pmhResult?.applied).toBe(true)
|
||||
expect(result.monsterRegenResult?.isBlockedByPMH).toBe(true)
|
||||
expect(result.monsterRegenResult?.effectiveRegenPerFrame).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ export default defineConfig({
|
|||
walk: new URL('./walk.html', import.meta.url).pathname,
|
||||
net: new URL('./net.html', import.meta.url).pathname,
|
||||
acts: new URL('./acts.html', import.meta.url).pathname,
|
||||
arena: new URL('./arena.html', import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue