feat(combat): implement Diablo II 1.10+ combat math & PvE depth mechanics (fixes #24, fixes #39)

This commit is contained in:
troytt 2026-09-17 02:52:00 +00:00
parent 78b5351cce
commit 93a854d9ad
5 changed files with 5352 additions and 0 deletions

781
arena.html Normal file
View File

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

1842
src/arena.ts Normal file

File diff suppressed because it is too large Load Diff

1915
src/game/formulas.ts Normal file

File diff suppressed because it is too large Load Diff

813
tests/formulas.test.ts Normal file
View File

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

View File

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