From 93a854d9ad7a54e8c69c9c091d06e2bc547b8311 Mon Sep 17 00:00:00 2001
From: troytt <47798984@qq.com>
Date: Thu, 17 Sep 2026 02:52:00 +0000
Subject: [PATCH] feat(combat): implement Diablo II 1.10+ combat math & PvE
depth mechanics (fixes #24, fixes #39)
---
arena.html | 781 ++++++++++++++++
src/arena.ts | 1842 ++++++++++++++++++++++++++++++++++++++
src/game/formulas.ts | 1915 ++++++++++++++++++++++++++++++++++++++++
tests/formulas.test.ts | 813 +++++++++++++++++
vite.config.ts | 1 +
5 files changed, 5352 insertions(+)
create mode 100644 arena.html
create mode 100644 src/arena.ts
create mode 100644 src/game/formulas.ts
create mode 100644 tests/formulas.test.ts
diff --git a/arena.html b/arena.html
new file mode 100644
index 0000000..7bec03e
--- /dev/null
+++ b/arena.html
@@ -0,0 +1,781 @@
+
+
+
+
+
+ d2web · 战斗数学与攻防测试竞技场(Issue #24 / M9)
+
+
+
+
+
+ ⚔️
+ 暗黑破坏神 2 · 真实战斗数学与攻防测试竞技场
+ (Issue #24 / M9)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/arena.ts b/src/arena.ts
new file mode 100644
index 0000000..73e157d
--- /dev/null
+++ b/src/arena.ts
@@ -0,0 +1,1842 @@
+/**
+ * Standalone Combat Mathematics & Attack/Magic Testing Arena (Issue #24 / M9).
+ *
+ * Renders 1 Player and 1 Monster in an isometric testing duel ring.
+ * Allows interactive configuration of character attributes, monster types, and levels.
+ * Provides real-time execution and step-by-step mathematical breakdown for:
+ * - Melee physical attacks (AR vs DR to-hit, Shield Block, Critical/Deadly Strike, Crushing Blow, DR%)
+ * - Fire, Cold, Lightning, Poison, and Magic spells (resistances, immunities, MDR, chill/stun effects)
+ * - Monster counter-attacks, Hit Recovery (FHR), and continuous DPS benchmarks.
+ */
+
+import {
+ calculateAttack,
+ calculateHitChance,
+ calculatePlayerBlockChance,
+ calculateCriticalChance,
+ evaluateCriticalAndDeadlyStrike,
+ calculateCrushingBlowDamage,
+ calculateOpenWoundsDamage,
+ calculateLeech,
+ calculateFreezeTarget,
+ calculateResistanceFactor,
+ calculateResistanceReduction,
+ calculateMonsterRegeneration,
+ evaluatePreventMonsterHeal,
+ calculateKnockbackSuccess,
+ calculateSlowTarget,
+ calculateHitBlindsTarget,
+ calculateHitCausesFlee,
+ calculatePhysicalWeaponDamage,
+ calculatePoisonStack,
+ checkHitRecovery,
+ D2_EXPERIENCE_TABLE_99,
+} from './game/formulas.ts'
+import type { CombatCalculationResult, TargetTier, GameDifficulty, MonsterSize } from './game/formulas.ts'
+
+// --- Monster Database & Scaling ---------------------------------------------
+
+interface MonsterBaseDefinition {
+ readonly id: string
+ readonly name: string
+ readonly category: string
+ readonly baseMinHp: number
+ readonly baseMaxHp: number
+ readonly baseAc: number
+ readonly baseTh: number
+ readonly baseMinD: number
+ readonly baseMaxD: number
+ readonly baseExp: number
+ readonly blockPercent: number
+ readonly resPhysical: number
+ readonly resFire: number
+ readonly resLightning: number
+ readonly resCold: number
+ readonly resPoison: number
+ readonly resMagic: number
+ readonly color: string
+ readonly tier?: TargetTier
+ readonly drainEffectiveness?: number
+ readonly size?: MonsterSize
+ readonly regenRate?: number
+}
+
+const MONSTER_TEMPLATES: Record = {
+ fallen1: {
+ id: 'fallen1', name: '沉沦魔 (Fallen)', category: '恶魔 (Demon)',
+ baseMinHp: 21, baseMaxHp: 61, baseAc: 84, baseTh: 101, baseMinD: 51, baseMaxD: 101, baseExp: 61,
+ blockPercent: 0, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
+ color: '#e64a19', tier: 'normal', drainEffectiveness: 100, size: 'small', regenRate: 0,
+ },
+ zombie1: {
+ id: 'zombie1', name: '僵尸 (Zombie)', category: '不死 (Undead)',
+ baseMinHp: 31, baseMaxHp: 61, baseAc: 64, baseTh: 81, baseMinD: 51, baseMaxD: 101, baseExp: 111,
+ blockPercent: 0, resPhysical: 0, resFire: -20, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
+ color: '#558b2f', tier: 'normal', drainEffectiveness: 50, size: 'medium', regenRate: 5,
+ },
+ quillrat1: {
+ id: 'quillrat1', name: '刺鼠 (Quill Rat)', category: '动物 (Animal)',
+ baseMinHp: 21, baseMaxHp: 41, baseAc: 64, baseTh: 101, baseMinD: 51, baseMaxD: 101, baseExp: 71,
+ blockPercent: 0, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
+ color: '#8d6e63', tier: 'normal', drainEffectiveness: 100, size: 'small', regenRate: 0,
+ },
+ skeleton1: {
+ id: 'skeleton1', name: '骷髅战士 (Skeleton)', category: '不死 (Undead)',
+ baseMinHp: 21, baseMaxHp: 51, baseAc: 84, baseTh: 81, baseMinD: 51, baseMaxD: 101, baseExp: 81,
+ blockPercent: 15, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 25, resMagic: 0,
+ color: '#cfd8dc', tier: 'normal', drainEffectiveness: 0, size: 'medium', regenRate: 0,
+ },
+ corruptrogue1: {
+ id: 'corruptrogue1', name: '黑暗猎手 (Dark Hunter)', category: '恶魔 (Demon)',
+ baseMinHp: 25, baseMaxHp: 55, baseAc: 101, baseTh: 121, baseMinD: 61, baseMaxD: 111, baseExp: 91,
+ blockPercent: 20, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
+ color: '#7b1fa2', tier: 'normal', drainEffectiveness: 100, size: 'medium', regenRate: 0,
+ },
+ goatman1: {
+ id: 'goatman1', name: '月亮氏族羊头人 (Moon Clan)', category: '动物 (Animal)',
+ baseMinHp: 41, baseMaxHp: 81, baseAc: 101, baseTh: 101, baseMinD: 81, baseMaxD: 141, baseExp: 131,
+ blockPercent: 0, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 20, resPoison: 0, resMagic: 0,
+ color: '#4e342e', tier: 'normal', drainEffectiveness: 100, size: 'large', regenRate: 0,
+ },
+ bloodraven: {
+ id: 'bloodraven', name: '血鸟 (Blood Raven)', category: '暗金Boss (SuperUnique)',
+ baseMinHp: 120, baseMaxHp: 200, baseAc: 150, baseTh: 180, baseMinD: 100, baseMaxD: 180, baseExp: 500,
+ blockPercent: 30, resPhysical: 10, resFire: 20, resLightning: 20, resCold: 20, resPoison: 20, resMagic: 0,
+ color: '#c2185b', tier: 'boss', drainEffectiveness: 50, size: 'medium', regenRate: 8,
+ },
+ andariel: {
+ id: 'andariel', name: '安达利尔 (Andariel)', category: '幕终首领 (Act Boss)',
+ baseMinHp: 1025, baseMaxHp: 1200, baseAc: 175, baseTh: 220, baseMinD: 150, baseMaxD: 260, baseExp: 3200,
+ blockPercent: 0, resPhysical: 15, resFire: -50, resLightning: 50, resCold: 50, resPoison: 80, resMagic: 0,
+ color: '#2e7d32', tier: 'boss', drainEffectiveness: 100, size: 'large', regenRate: 15,
+ },
+ duriel: {
+ id: 'duriel', name: '督瑞尔 (Duriel)', category: '幕终首领 (Act Boss)',
+ baseMinHp: 3995, baseMaxHp: 4500, baseAc: 300, baseTh: 350, baseMinD: 250, baseMaxD: 400, baseExp: 8500,
+ blockPercent: 0, resPhysical: 20, resFire: 20, resLightning: 20, resCold: 50, resPoison: 20, resMagic: 0,
+ color: '#00838f', tier: 'boss', drainEffectiveness: 100, size: 'large', regenRate: 20,
+ },
+ mephisto: {
+ id: 'mephisto', name: '墨菲斯托 (Mephisto)', category: '幕终首领 (Act Boss)',
+ baseMinHp: 7300, baseMaxHp: 8000, baseAc: 450, baseTh: 500, baseMinD: 350, baseMaxD: 550, baseExp: 18000,
+ blockPercent: 25, resPhysical: 20, resFire: 50, resLightning: 75, resCold: 50, resPoison: 75, resMagic: 0,
+ color: '#1565c0', tier: 'boss', drainEffectiveness: 20, size: 'large', regenRate: 25,
+ },
+ diablo: {
+ id: 'diablo', name: '暗黑破坏神 (Diablo)', category: '幕终首领 (Act Boss)',
+ baseMinHp: 13800, baseMaxHp: 15000, baseAc: 600, baseTh: 700, baseMinD: 500, baseMaxD: 800, baseExp: 35000,
+ blockPercent: 30, resPhysical: 30, resFire: 50, resLightning: 50, resCold: 50, resPoison: 50, resMagic: 0,
+ color: '#d32f2f', tier: 'boss', drainEffectiveness: 50, size: 'large', regenRate: 35,
+ },
+ baal: {
+ id: 'baal', name: '毁灭之王巴尔 (Baal)', category: '幕终首领 (Act Boss)',
+ baseMinHp: 26000, baseMaxHp: 30000, baseAc: 800, baseTh: 900, baseMinD: 600, baseMaxD: 1000, baseExp: 60000,
+ blockPercent: 40, resPhysical: 50, resFire: 50, resLightning: 50, resCold: 50, resPoison: 50, resMagic: 0,
+ color: '#ff8f00', tier: 'boss', drainEffectiveness: 33, size: 'large', regenRate: 45,
+ },
+ dummy: {
+ id: 'dummy', name: '测伤木桩 (Training Dummy)', category: '无敌靶桩 (Dummy)',
+ baseMinHp: 100000, baseMaxHp: 100000, baseAc: 100, baseTh: 100, baseMinD: 10, baseMaxD: 20, baseExp: 100,
+ blockPercent: 0, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
+ color: '#ffb300', tier: 'normal', drainEffectiveness: 100, size: 'large', regenRate: 0,
+ },
+}
+
+/** Scale multipliers derived from MonLvl.txt per level */
+function computeMonsterScale(level: number) {
+ const l = Math.max(1, Math.min(99, level))
+ return {
+ ac: Math.round(6 + 2.2 * l),
+ th: Math.round(8 + 2.8 * l),
+ hp: Math.round(7 + 3.0 * l),
+ dm: Math.round(2 + 1.2 * l),
+ xp: Math.round(30 + 10 * l),
+ }
+}
+
+// --- Visual & Particle FX State ---------------------------------------------
+
+interface FloatingText {
+ x: number
+ y: number
+ text: string
+ color: string
+ size: number
+ alpha: number
+ vy: number
+ life: number
+}
+
+interface ProjectileFx {
+ x: number
+ y: number
+ targetX: number
+ targetY: number
+ type: 'fire' | 'cold' | 'light' | 'magic'
+ progress: number
+ speed: number
+ onHit: () => void
+}
+
+interface ParticleFx {
+ x: number
+ y: number
+ vx: number
+ vy: number
+ color: string
+ size: number
+ life: number
+ maxLife: number
+}
+
+// --- App State --------------------------------------------------------------
+
+class CombatArenaApp {
+ private canvas!: HTMLCanvasElement
+ private ctx!: CanvasRenderingContext2D
+
+ // Player State
+ private player = {
+ class: 'Sorceress',
+ level: 30,
+ hp: 350,
+ maxHp: 350,
+ mana: 200,
+ maxMana: 200,
+ ar: 1200,
+ dr: 650,
+ dmgMin: 25,
+ dmgMax: 65,
+ fire: 15,
+ cold: 10,
+ light: 25,
+ poison: 0,
+ magic: 0,
+ block: 45,
+ cs: 20,
+ ds: 15,
+ cb: 25,
+ ow: 25,
+ ll: 10,
+ ml: 5,
+ itd: false,
+ defRed: 25,
+ freezeBonus: 1,
+ knockback: 25,
+ difficulty: 'normal' as GameDifficulty,
+ flatDr: 2,
+ flatMdr: 2,
+ fhr: 30,
+ // PvE mechanics
+ pmh: true,
+ isMerc: false,
+ conviction: 0,
+ lowerRes: 0,
+ minusLightRes: 0,
+ minusFireRes: 0,
+ minusColdRes: 0,
+ minusPoisonRes: 0,
+ slow: 0,
+ hbt: 0,
+ hcmtf: 0,
+ flatDmg: 0,
+ offWeaponEd: 0,
+ statBonus: 0,
+ // Animation state
+ action: 'idle' as 'idle' | 'attack' | 'cast' | 'hit' | 'dead',
+ actionTimer: 0,
+ hitFlash: 0,
+ }
+
+ // Monster State
+ private monster = {
+ typeKey: 'fallen1',
+ name: '沉沦魔 (Fallen)',
+ category: '恶魔 (Demon)',
+ tier: 'normal' as TargetTier,
+ posture: 'stand' as 'stand' | 'run',
+ level: 28,
+ hp: 180,
+ maxHp: 180,
+ dr: 480,
+ ar: 520,
+ dmgMin: 12,
+ dmgMax: 28,
+ block: 0,
+ drainEffectiveness: 100,
+ resPhysical: 0,
+ resFire: 0,
+ resCold: 0,
+ resLightning: 0,
+ resPoison: 0,
+ resMagic: 0,
+ flatDr: 0,
+ flatMdr: 0,
+ size: 'medium' as MonsterSize,
+ regenRate: 0,
+ isUber: false,
+ // Animation & Status state
+ action: 'idle' as 'idle' | 'attack' | 'hit' | 'dead',
+ actionTimer: 0,
+ hitFlash: 0,
+ color: '#e64a19',
+ stunTimer: 0,
+ chillTimer: 0,
+ freezeTimer: 0,
+ openWoundsTimer: 0,
+ openWoundsDmgPerFrame: 0,
+ knockbackOffset: 0,
+ pmhTimer: 0,
+ blindTimer: 0,
+ fleeTimer: 0,
+ slowTimer: 0,
+ slowPercent: 0,
+ }
+
+ // Visuals
+ private floatingTexts: FloatingText[] = []
+ private projectiles: ProjectileFx[] = []
+ private particles: ParticleFx[] = []
+ private autoBattleActive = false
+ private autoBattleInterval: number | null = null
+
+ // Statistics
+ private totalDamageDealt = 0
+ private combatStartTime = 0
+
+ constructor() {
+ this.initDom()
+ this.initEvents()
+ this.updateMonsterStatsFromTypeAndLevel()
+ this.startRenderLoop()
+ }
+
+ private initDom() {
+ this.canvas = document.getElementById('view') as HTMLCanvasElement
+ this.ctx = this.canvas.getContext('2d')!
+ this.resizeCanvas()
+ window.addEventListener('resize', () => this.resizeCanvas())
+ }
+
+ private resizeCanvas() {
+ const wrapper = document.getElementById('viewport-wrapper')!
+ this.canvas.width = wrapper.clientWidth
+ this.canvas.height = wrapper.clientHeight
+ }
+
+ private initEvents() {
+ // Player presets
+ const presetSelect = document.getElementById('char-preset') as HTMLSelectElement
+ presetSelect.addEventListener('change', () => this.applyPlayerPreset(presetSelect.value))
+
+ // Player inputs sync
+ const syncPlayerInputs = () => {
+ this.player.class = (document.getElementById('char-class') as HTMLSelectElement).value
+ this.player.level = Number((document.getElementById('char-level') as HTMLInputElement).value) || 1
+ this.player.hp = Number((document.getElementById('char-hp') as HTMLInputElement).value) || 1
+ this.player.maxHp = this.player.hp
+ this.player.mana = Number((document.getElementById('char-mana') as HTMLInputElement).value) || 0
+ this.player.maxMana = this.player.mana
+ this.player.ar = Number((document.getElementById('char-ar') as HTMLInputElement).value) || 0
+ this.player.dr = Number((document.getElementById('char-dr') as HTMLInputElement).value) || 0
+ this.player.dmgMin = Number((document.getElementById('char-dmg-min') as HTMLInputElement).value) || 1
+ this.player.dmgMax = Number((document.getElementById('char-dmg-max') as HTMLInputElement).value) || 1
+ this.player.fire = Number((document.getElementById('char-fire') as HTMLInputElement).value) || 0
+ this.player.cold = Number((document.getElementById('char-cold') as HTMLInputElement).value) || 0
+ this.player.light = Number((document.getElementById('char-light') as HTMLInputElement).value) || 0
+ this.player.poison = Number((document.getElementById('char-poison') as HTMLInputElement).value) || 0
+ this.player.magic = Number((document.getElementById('char-magic') as HTMLInputElement).value) || 0
+ this.player.block = Number((document.getElementById('char-block') as HTMLInputElement).value) || 0
+ this.player.cs = Number((document.getElementById('char-cs') as HTMLInputElement)?.value) || 0
+ this.player.ds = Number((document.getElementById('char-ds') as HTMLInputElement)?.value) || 0
+ this.player.cb = Number((document.getElementById('char-cb') as HTMLInputElement)?.value) || 0
+ this.player.ow = Number((document.getElementById('char-ow') as HTMLInputElement)?.value) || 0
+ this.player.ll = Number((document.getElementById('char-ll') as HTMLInputElement)?.value) || 0
+ this.player.ml = Number((document.getElementById('char-ml') as HTMLInputElement)?.value) || 0
+ this.player.itd = (document.getElementById('char-itd') as HTMLInputElement)?.checked ?? false
+ this.player.defRed = Number((document.getElementById('char-def-red') as HTMLInputElement)?.value) || 0
+ this.player.freezeBonus = Number((document.getElementById('char-freeze') as HTMLInputElement)?.value) || 0
+ this.player.knockback = Number((document.getElementById('char-knockback') as HTMLInputElement)?.value) || 0
+ this.player.difficulty = ((document.getElementById('game-difficulty') as HTMLSelectElement)?.value ?? 'normal') as GameDifficulty
+ this.player.flatDr = Number((document.getElementById('char-flat-dr') as HTMLInputElement).value) || 0
+ this.player.flatMdr = Number((document.getElementById('char-flat-mdr') as HTMLInputElement).value) || 0
+ this.player.pmh = (document.getElementById('char-pmh') as HTMLInputElement)?.checked ?? false
+ this.player.isMerc = (document.getElementById('char-is-merc') as HTMLInputElement)?.checked ?? false
+ this.player.conviction = Number((document.getElementById('char-conviction') as HTMLInputElement)?.value) || 0
+ this.player.lowerRes = Number((document.getElementById('char-lower-res') as HTMLInputElement)?.value) || 0
+ this.player.minusLightRes = Number((document.getElementById('char-minus-light-res') as HTMLInputElement)?.value) || 0
+ this.player.minusFireRes = Number((document.getElementById('char-minus-fire-res') as HTMLInputElement)?.value) || 0
+ this.player.minusColdRes = Number((document.getElementById('char-minus-cold-res') as HTMLInputElement)?.value) || 0
+ this.player.minusPoisonRes = Number((document.getElementById('char-minus-poison-res') as HTMLInputElement)?.value) || 0
+ this.player.slow = Number((document.getElementById('char-slow') as HTMLInputElement)?.value) || 0
+ this.player.hbt = Number((document.getElementById('char-hbt') as HTMLInputElement)?.value) || 0
+ this.player.hcmtf = Number((document.getElementById('char-hcmtf') as HTMLInputElement)?.value) || 0
+ this.player.flatDmg = Number((document.getElementById('char-flat-dmg') as HTMLInputElement)?.value) || 0
+ this.player.offWeaponEd = Number((document.getElementById('char-off-weapon-ed') as HTMLInputElement)?.value) || 0
+ this.player.statBonus = Number((document.getElementById('char-stat-bonus') as HTMLInputElement)?.value) || 0
+ }
+
+ const playerInputIds = [
+ 'char-class', 'char-level', 'char-hp', 'char-mana', 'char-ar', 'char-dr',
+ 'char-dmg-min', 'char-dmg-max', 'char-fire', 'char-cold', 'char-light',
+ 'char-poison', 'char-magic', 'char-block', 'char-cs', 'char-ds', 'char-cb', 'char-ow',
+ 'char-ll', 'char-ml', 'char-itd', 'char-def-red', 'char-freeze', 'char-knockback',
+ 'game-difficulty', 'char-flat-dr', 'char-flat-mdr',
+ 'char-pmh', 'char-is-merc', 'char-conviction', 'char-lower-res',
+ 'char-minus-light-res', 'char-minus-fire-res', 'char-minus-cold-res', 'char-minus-poison-res',
+ 'char-slow', 'char-hbt', 'char-hcmtf', 'char-flat-dmg', 'char-off-weapon-ed', 'char-stat-bonus',
+ ]
+ for (const id of playerInputIds) {
+ const el = document.getElementById(id)
+ el?.addEventListener('input', syncPlayerInputs)
+ el?.addEventListener('change', syncPlayerInputs)
+ }
+
+ // Monster Type & Level
+ const monTypeSelect = document.getElementById('mon-type') as HTMLSelectElement
+ const monLevelInput = document.getElementById('mon-level') as HTMLInputElement
+ monTypeSelect.addEventListener('change', () => this.updateMonsterStatsFromTypeAndLevel())
+ monLevelInput.addEventListener('input', () => this.updateMonsterStatsFromTypeAndLevel())
+
+ // Monster custom inputs
+ const syncMonsterInputs = () => {
+ this.monster.tier = ((document.getElementById('mon-tier') as HTMLSelectElement)?.value ?? 'normal') as TargetTier
+ this.monster.posture = ((document.getElementById('mon-posture') as HTMLSelectElement)?.value ?? 'stand') as 'stand' | 'run'
+ this.monster.drainEffectiveness = Number((document.getElementById('mon-drain-eff') as HTMLInputElement)?.value) ?? 100
+ this.monster.size = ((document.getElementById('mon-size') as HTMLSelectElement)?.value ?? 'medium') as MonsterSize
+ this.monster.regenRate = Number((document.getElementById('mon-regen') as HTMLInputElement)?.value) || 0
+ this.monster.isUber = (document.getElementById('mon-is-uber') as HTMLInputElement)?.checked ?? false
+ this.monster.hp = Number((document.getElementById('mon-hp') as HTMLInputElement).value) || 1
+ this.monster.maxHp = Number((document.getElementById('mon-max-hp') as HTMLInputElement).value) || 1
+ this.monster.dr = Number((document.getElementById('mon-dr') as HTMLInputElement).value) || 0
+ this.monster.ar = Number((document.getElementById('mon-ar') as HTMLInputElement).value) || 0
+ this.monster.dmgMin = Number((document.getElementById('mon-dmg-min') as HTMLInputElement).value) || 1
+ this.monster.dmgMax = Number((document.getElementById('mon-dmg-max') as HTMLInputElement).value) || 1
+ this.monster.block = Number((document.getElementById('mon-block') as HTMLInputElement).value) || 0
+ this.monster.resPhysical = Number((document.getElementById('mon-res-phys') as HTMLInputElement).value) || 0
+ this.monster.resFire = Number((document.getElementById('mon-res-fire') as HTMLInputElement).value) || 0
+ this.monster.resCold = Number((document.getElementById('mon-res-cold') as HTMLInputElement).value) || 0
+ this.monster.resLightning = Number((document.getElementById('mon-res-light') as HTMLInputElement).value) || 0
+ this.monster.resPoison = Number((document.getElementById('mon-res-poison') as HTMLInputElement).value) || 0
+ this.monster.resMagic = Number((document.getElementById('mon-res-magic') as HTMLInputElement).value) || 0
+ this.monster.flatDr = Number((document.getElementById('mon-flat-dr') as HTMLInputElement).value) || 0
+ this.monster.flatMdr = Number((document.getElementById('mon-flat-mdr') as HTMLInputElement).value) || 0
+ this.updateMonsterBadge()
+ }
+
+ const monsterInputIds = [
+ 'mon-tier', 'mon-posture', 'mon-drain-eff', 'mon-size', 'mon-regen', 'mon-is-uber',
+ 'mon-hp', 'mon-max-hp', 'mon-dr', 'mon-ar', 'mon-dmg-min', 'mon-dmg-max',
+ 'mon-block', 'mon-res-phys', 'mon-res-fire', 'mon-res-cold', 'mon-res-light',
+ 'mon-res-poison', 'mon-res-magic', 'mon-flat-dr', 'mon-flat-mdr',
+ ]
+ for (const id of monsterInputIds) {
+ const el = document.getElementById(id)
+ el?.addEventListener('input', syncMonsterInputs)
+ el?.addEventListener('change', syncMonsterInputs)
+ }
+
+ // Buttons
+ document.getElementById('btn-attack')?.addEventListener('click', () => this.executeMeleeAttack())
+ document.getElementById('btn-fire')?.addEventListener('click', () => this.castFireSpell())
+ document.getElementById('btn-cold')?.addEventListener('click', () => this.castColdSpell())
+ document.getElementById('btn-light')?.addEventListener('click', () => this.castLightningSpell())
+ document.getElementById('btn-magic')?.addEventListener('click', () => this.castMagicSpell())
+ document.getElementById('btn-monster-attack')?.addEventListener('click', () => this.executeMonsterAttack())
+ document.getElementById('btn-auto-battle')?.addEventListener('click', () => this.toggleAutoBattle())
+ document.getElementById('btn-reset')?.addEventListener('click', () => this.resetCombat())
+ document.getElementById('btn-clear-log')?.addEventListener('click', () => {
+ const container = document.getElementById('log-entries')!
+ container.innerHTML = ''
+ })
+
+ // Keyboard Shortcuts
+ window.addEventListener('keydown', (e) => {
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return
+ if (e.key === ' ' || e.key === '1') {
+ e.preventDefault()
+ this.executeMeleeAttack()
+ } else if (e.key === '2') {
+ e.preventDefault()
+ this.castFireSpell()
+ } else if (e.key === '3') {
+ e.preventDefault()
+ this.castColdSpell()
+ } else if (e.key === '4') {
+ e.preventDefault()
+ this.castLightningSpell()
+ } else if (e.key === '5') {
+ e.preventDefault()
+ this.castMagicSpell()
+ } else if (e.key === 'a' || e.key === 'A') {
+ e.preventDefault()
+ this.toggleAutoBattle()
+ } else if (e.key === 'r' || e.key === 'R') {
+ e.preventDefault()
+ this.resetCombat()
+ }
+ })
+ }
+
+ // --- Presets & Data Sync --------------------------------------------------
+
+ private applyPlayerPreset(preset: string) {
+ if (preset === 'novice') {
+ this.setPlayerForm({
+ class: 'Barbarian', level: 1, hp: 45, mana: 20, ar: 110, dr: 35,
+ dmgMin: 2, dmgMax: 6, fire: 0, cold: 0, light: 0, poison: 0, magic: 0,
+ block: 10, cs: 0, ds: 0, cb: 0, ow: 0, ll: 0, ml: 0, itd: false, defRed: 0,
+ freezeBonus: 0, knockback: 0, difficulty: 'normal', flatDr: 0, flatMdr: 0,
+ pmh: true, isMerc: false, conviction: 0, lowerRes: 0,
+ minusLightRes: 0, minusFireRes: 0, minusColdRes: 0, minusPoisonRes: 0,
+ slow: 0, hbt: 0, hcmtf: 0, flatDmg: 0, offWeaponEd: 0, statBonus: 0,
+ })
+ } else if (preset === 'zealot') {
+ this.setPlayerForm({
+ class: 'Paladin', level: 45, hp: 550, mana: 120, ar: 3800, dr: 2200,
+ dmgMin: 45, dmgMax: 110, fire: 20, cold: 15, light: 40, poison: 0, magic: 0,
+ block: 75, cs: 15, ds: 35, cb: 50, ow: 33, ll: 8, ml: 4, itd: true, defRed: 25,
+ freezeBonus: 1, knockback: 0, difficulty: 'nightmare', flatDr: 15, flatMdr: 10,
+ pmh: true, isMerc: false, conviction: 0, lowerRes: 0,
+ minusLightRes: 0, minusFireRes: 0, minusColdRes: 0, minusPoisonRes: 0,
+ slow: 50, hbt: 0, hcmtf: 0, flatDmg: 400, offWeaponEd: 300, statBonus: 150,
+ })
+ } else if (preset === 'sorc_fire') {
+ this.setPlayerForm({
+ class: 'Sorceress', level: 75, hp: 620, mana: 750, ar: 400, dr: 950,
+ dmgMin: 10, dmgMax: 25, fire: 1450, cold: 0, light: 0, poison: 0, magic: 0,
+ block: 20, cs: 0, ds: 0, cb: 0, ow: 0, ll: 0, ml: 0, itd: false, defRed: 0,
+ freezeBonus: 0, knockback: 0, difficulty: 'hell', flatDr: 5, flatMdr: 25,
+ pmh: true, isMerc: false, conviction: 85, lowerRes: 60,
+ minusLightRes: 0, minusFireRes: 20, minusColdRes: 0, minusPoisonRes: 0,
+ slow: 0, hbt: 0, hcmtf: 0, flatDmg: 0, offWeaponEd: 0, statBonus: 0,
+ })
+ } else if (preset === 'sorc_orb') {
+ this.setPlayerForm({
+ class: 'Sorceress', level: 80, hp: 700, mana: 800, ar: 500, dr: 1100,
+ dmgMin: 12, dmgMax: 30, fire: 350, cold: 680, light: 0, poison: 0, magic: 0,
+ block: 25, cs: 0, ds: 0, cb: 0, ow: 0, ll: 0, ml: 0, itd: false, defRed: 0,
+ freezeBonus: 3, knockback: 0, difficulty: 'hell', flatDr: 8, flatMdr: 30,
+ pmh: true, isMerc: false, conviction: 85, lowerRes: 0,
+ minusLightRes: 0, minusFireRes: 0, minusColdRes: 35, minusPoisonRes: 0,
+ slow: 0, hbt: 0, hcmtf: 0, flatDmg: 0, offWeaponEd: 0, statBonus: 0,
+ })
+ } else if (preset === 'hell_slayer') {
+ this.setPlayerForm({
+ class: 'Barbarian', level: 90, hp: 1650, mana: 260, ar: 11500, dr: 7500,
+ dmgMin: 180, dmgMax: 420, fire: 80, cold: 60, light: 120, poison: 100, magic: 0,
+ block: 75, cs: 28, ds: 65, cb: 75, ow: 50, ll: 15, ml: 8, itd: true, defRed: 50,
+ freezeBonus: 2, knockback: 25, difficulty: 'hell', flatDr: 35, flatMdr: 25,
+ pmh: true, isMerc: false, conviction: 0, lowerRes: 0,
+ minusLightRes: 0, minusFireRes: 0, minusColdRes: 0, minusPoisonRes: 0,
+ slow: 35, hbt: 2, hcmtf: 15, flatDmg: 120, offWeaponEd: 450, statBonus: 220,
+ })
+ }
+ }
+
+ private setPlayerForm(p: any) {
+ ;(document.getElementById('char-class') as HTMLSelectElement).value = p.class
+ ;(document.getElementById('char-level') as HTMLInputElement).value = String(p.level)
+ ;(document.getElementById('char-hp') as HTMLInputElement).value = String(p.hp)
+ ;(document.getElementById('char-mana') as HTMLInputElement).value = String(p.mana)
+ ;(document.getElementById('char-ar') as HTMLInputElement).value = String(p.ar)
+ ;(document.getElementById('char-dr') as HTMLInputElement).value = String(p.dr)
+ ;(document.getElementById('char-dmg-min') as HTMLInputElement).value = String(p.dmgMin)
+ ;(document.getElementById('char-dmg-max') as HTMLInputElement).value = String(p.dmgMax)
+ ;(document.getElementById('char-fire') as HTMLInputElement).value = String(p.fire)
+ ;(document.getElementById('char-cold') as HTMLInputElement).value = String(p.cold)
+ ;(document.getElementById('char-light') as HTMLInputElement).value = String(p.light)
+ ;(document.getElementById('char-poison') as HTMLInputElement).value = String(p.poison)
+ ;(document.getElementById('char-magic') as HTMLInputElement).value = String(p.magic)
+ ;(document.getElementById('char-block') as HTMLInputElement).value = String(p.block)
+ ;(document.getElementById('char-cs') as HTMLInputElement).value = String(p.cs ?? 0)
+ ;(document.getElementById('char-ds') as HTMLInputElement).value = String(p.ds ?? 0)
+ ;(document.getElementById('char-cb') as HTMLInputElement).value = String(p.cb ?? 0)
+ ;(document.getElementById('char-ow') as HTMLInputElement).value = String(p.ow ?? 0)
+ ;(document.getElementById('char-ll') as HTMLInputElement).value = String(p.ll ?? 0)
+ ;(document.getElementById('char-ml') as HTMLInputElement).value = String(p.ml ?? 0)
+ ;(document.getElementById('char-itd') as HTMLInputElement).checked = Boolean(p.itd)
+ ;(document.getElementById('char-def-red') as HTMLInputElement).value = String(p.defRed ?? 0)
+ ;(document.getElementById('char-freeze') as HTMLInputElement).value = String(p.freezeBonus ?? 0)
+ ;(document.getElementById('char-knockback') as HTMLInputElement).value = String(p.knockback ?? 0)
+ ;(document.getElementById('game-difficulty') as HTMLSelectElement).value = p.difficulty ?? 'normal'
+ ;(document.getElementById('char-flat-dr') as HTMLInputElement).value = String(p.flatDr)
+ ;(document.getElementById('char-flat-mdr') as HTMLInputElement).value = String(p.flatMdr)
+ const charPmh = document.getElementById('char-pmh') as HTMLInputElement | null
+ if (charPmh) charPmh.checked = Boolean(p.pmh ?? true)
+ const charMerc = document.getElementById('char-is-merc') as HTMLInputElement | null
+ if (charMerc) charMerc.checked = Boolean(p.isMerc ?? false)
+ const charConv = document.getElementById('char-conviction') as HTMLInputElement | null
+ if (charConv) charConv.value = String(p.conviction ?? 0)
+ const charLr = document.getElementById('char-lower-res') as HTMLInputElement | null
+ if (charLr) charLr.value = String(p.lowerRes ?? 0)
+ const charMinL = document.getElementById('char-minus-light-res') as HTMLInputElement | null
+ if (charMinL) charMinL.value = String(p.minusLightRes ?? 0)
+ const charMinF = document.getElementById('char-minus-fire-res') as HTMLInputElement | null
+ if (charMinF) charMinF.value = String(p.minusFireRes ?? 0)
+ const charMinC = document.getElementById('char-minus-cold-res') as HTMLInputElement | null
+ if (charMinC) charMinC.value = String(p.minusColdRes ?? 0)
+ const charMinP = document.getElementById('char-minus-poison-res') as HTMLInputElement | null
+ if (charMinP) charMinP.value = String(p.minusPoisonRes ?? 0)
+ const charSlow = document.getElementById('char-slow') as HTMLInputElement | null
+ if (charSlow) charSlow.value = String(p.slow ?? 0)
+ const charHbt = document.getElementById('char-hbt') as HTMLInputElement | null
+ if (charHbt) charHbt.value = String(p.hbt ?? 0)
+ const charHcmtf = document.getElementById('char-hcmtf') as HTMLInputElement | null
+ if (charHcmtf) charHcmtf.value = String(p.hcmtf ?? 0)
+ const charFlatD = document.getElementById('char-flat-dmg') as HTMLInputElement | null
+ if (charFlatD) charFlatD.value = String(p.flatDmg ?? 0)
+ const charOffEd = document.getElementById('char-off-weapon-ed') as HTMLInputElement | null
+ if (charOffEd) charOffEd.value = String(p.offWeaponEd ?? 0)
+ const charStatB = document.getElementById('char-stat-bonus') as HTMLInputElement | null
+ if (charStatB) charStatB.value = String(p.statBonus ?? 0)
+
+ Object.assign(this.player, p)
+ this.player.maxHp = p.hp
+ this.player.maxMana = p.mana
+ }
+
+ private updateMonsterStatsFromTypeAndLevel() {
+ const typeKey = (document.getElementById('mon-type') as HTMLSelectElement).value
+ const level = Number((document.getElementById('mon-level') as HTMLInputElement).value) || 1
+ const t = MONSTER_TEMPLATES[typeKey] ?? MONSTER_TEMPLATES['fallen1']!
+
+ const scale = computeMonsterScale(level)
+ const hp = Math.max(1, Math.round(((t.baseMinHp + t.baseMaxHp) / 2) * (scale.hp / 100)))
+ const dr = Math.max(1, Math.round(t.baseAc * (scale.ac / 100)))
+ const ar = Math.max(1, Math.round(t.baseTh * (scale.th / 100)))
+ const dmgMin = Math.max(1, Math.round(t.baseMinD * (scale.dm / 100)))
+ const dmgMax = Math.max(dmgMin + 1, Math.round(t.baseMaxD * (scale.dm / 100)))
+
+ this.monster.typeKey = t.id
+ this.monster.name = t.name
+ this.monster.category = t.category
+ this.monster.level = level
+ this.monster.hp = hp
+ this.monster.maxHp = hp
+ this.monster.dr = dr
+ this.monster.ar = ar
+ this.monster.dmgMin = dmgMin
+ this.monster.dmgMax = dmgMax
+ this.monster.block = t.blockPercent
+ this.monster.resPhysical = t.resPhysical
+ this.monster.resFire = t.resFire
+ this.monster.resCold = t.resCold
+ this.monster.resLightning = t.resLightning
+ this.monster.resPoison = t.resPoison
+ this.monster.resMagic = t.resMagic
+ this.monster.color = t.color
+ this.monster.size = t.size ?? 'medium'
+ this.monster.regenRate = t.regenRate ?? 0
+
+ ;(document.getElementById('mon-hp') as HTMLInputElement).value = String(hp)
+ ;(document.getElementById('mon-max-hp') as HTMLInputElement).value = String(hp)
+ ;(document.getElementById('mon-dr') as HTMLInputElement).value = String(dr)
+ ;(document.getElementById('mon-ar') as HTMLInputElement).value = String(ar)
+ ;(document.getElementById('mon-dmg-min') as HTMLInputElement).value = String(dmgMin)
+ ;(document.getElementById('mon-dmg-max') as HTMLInputElement).value = String(dmgMax)
+ ;(document.getElementById('mon-block') as HTMLInputElement).value = String(t.blockPercent)
+ ;(document.getElementById('mon-res-phys') as HTMLInputElement).value = String(t.resPhysical)
+ ;(document.getElementById('mon-res-fire') as HTMLInputElement).value = String(t.resFire)
+ ;(document.getElementById('mon-res-cold') as HTMLInputElement).value = String(t.resCold)
+ ;(document.getElementById('mon-res-light') as HTMLInputElement).value = String(t.resLightning)
+ ;(document.getElementById('mon-res-poison') as HTMLInputElement).value = String(t.resPoison)
+ ;(document.getElementById('mon-res-magic') as HTMLInputElement).value = String(t.resMagic)
+
+ const monSizeEl = document.getElementById('mon-size') as HTMLSelectElement | null
+ if (monSizeEl) monSizeEl.value = this.monster.size
+ const monRegenEl = document.getElementById('mon-regen') as HTMLInputElement | null
+ if (monRegenEl) monRegenEl.value = String(this.monster.regenRate)
+
+ this.monster.tier = (t.tier ?? 'normal') as TargetTier
+ this.monster.drainEffectiveness = t.drainEffectiveness ?? 100
+ const monTierEl = document.getElementById('mon-tier') as HTMLSelectElement | null
+ if (monTierEl) monTierEl.value = this.monster.tier
+ const monDrainEl = document.getElementById('mon-drain-eff') as HTMLInputElement | null
+ if (monDrainEl) monDrainEl.value = String(this.monster.drainEffectiveness)
+
+ this.updateMonsterBadge()
+ }
+
+ private updateMonsterBadge() {
+ const badge = document.getElementById('monster-badge')!
+ const immunes: string[] = []
+ if (this.monster.resPhysical >= 100) immunes.push('物免')
+ if (this.monster.resFire >= 100) immunes.push('火免')
+ if (this.monster.resCold >= 100) immunes.push('冰免')
+ if (this.monster.resLightning >= 100) immunes.push('电免')
+ if (this.monster.resPoison >= 100) immunes.push('毒免')
+ if (this.monster.resMagic >= 100) immunes.push('魔免')
+
+ if (immunes.length > 0) {
+ badge.className = 'tag immune'
+ badge.textContent = immunes.join('·')
+ } else {
+ badge.className = 'tag res'
+ badge.textContent = this.monster.category
+ }
+ }
+
+ // --- Combat Action Triggers -----------------------------------------------
+
+ public executeMeleeAttack() {
+ if (this.player.hp <= 0) return
+ this.player.action = 'attack'
+ this.player.actionTimer = 15
+
+ let pMin = this.player.dmgMin
+ let pMax = this.player.dmgMax
+ if (this.player.flatDmg > 0 || this.player.offWeaponEd > 0 || this.player.statBonus > 0) {
+ const weaponCalc = calculatePhysicalWeaponDamage({
+ weaponBaseMin: pMin,
+ weaponBaseMax: pMax,
+ flatDamage: this.player.flatDmg,
+ offWeaponEdPercent: this.player.offWeaponEd,
+ statBonusPercent: this.player.statBonus,
+ })
+ pMin = weaponCalc.finalMinDamage
+ pMax = weaponCalc.finalMaxDamage
+ }
+
+ const result = calculateAttack(
+ this.player.level,
+ this.player.ar,
+ {
+ physicalMin: pMin,
+ physicalMax: pMax,
+ fire: this.player.fire,
+ cold: this.player.cold,
+ lightning: this.player.light,
+ poison: this.player.poison,
+ magic: this.player.magic,
+ },
+ {
+ level: this.monster.level,
+ defense: this.monster.dr,
+ blockPercent: this.monster.block,
+ resPhysical: this.monster.resPhysical,
+ resFire: this.monster.resFire,
+ resCold: this.monster.resCold,
+ resLightning: this.monster.resLightning,
+ resPoison: this.monster.resPoison,
+ resMagic: this.monster.resMagic,
+ flatDamageReduction: this.monster.flatDr,
+ flatMagicReduction: this.monster.flatMdr,
+ currentHp: this.monster.hp,
+ maxHp: this.monster.maxHp,
+ tier: this.monster.tier,
+ isRunning: this.monster.posture === 'run',
+ drainEffectiveness: this.monster.drainEffectiveness,
+ size: this.monster.size,
+ regenRate: this.monster.regenRate,
+ isUber: this.monster.isUber,
+ },
+ {
+ criticalStrikePercent: this.player.cs,
+ deadlyStrikePercent: this.player.ds,
+ crushingBlowPercent: this.player.cb,
+ openWoundsPercent: this.player.ow,
+ lifeLeechPercent: this.player.ll,
+ manaLeechPercent: this.player.ml,
+ ignoreTargetDefense: this.player.itd,
+ targetDefenseReductionPercent: this.player.defRed,
+ freezeBonus: this.player.freezeBonus,
+ knockbackPercent: this.player.knockback,
+ difficulty: this.player.difficulty,
+ isSpell: false,
+ isRanged: false,
+ // PvE Depth mechanics
+ convictionPenalty: this.player.conviction,
+ lowerResPenalty: this.player.lowerRes,
+ itemMinusFireRes: this.player.minusFireRes,
+ itemMinusLightRes: this.player.minusLightRes,
+ itemMinusColdRes: this.player.minusColdRes,
+ itemMinusPoisonRes: this.player.minusPoisonRes,
+ slowPercent: this.player.slow,
+ hitBlindsTargetBonus: this.player.hbt,
+ hitCausesFleePercent: this.player.hcmtf,
+ preventMonsterHeal: this.player.pmh,
+ attackerType: this.player.isMerc ? 'mercenary' : 'player',
+ },
+ )
+
+ this.applyCombatResult(result, 'melee')
+ }
+
+ public castFireSpell() {
+ if (this.player.hp <= 0) return
+ this.player.action = 'cast'
+ this.player.actionTimer = 16
+
+ const spellDmg = Math.round((this.player.fire > 0 ? this.player.fire * 1.5 : 45) + Math.random() * 20)
+ const pX = this.canvas.width * 0.35
+ const pY = this.canvas.height * 0.52
+ const mX = this.canvas.width * 0.65
+ const mY = this.canvas.height * 0.52
+
+ this.projectiles.push({
+ x: pX, y: pY - 20, targetX: mX, targetY: mY - 20,
+ type: 'fire', progress: 0, speed: 0.08,
+ onHit: () => {
+ const result = calculateAttack(
+ this.player.level, 0,
+ { physicalMin: 0, physicalMax: 0, fire: spellDmg },
+ {
+ level: this.monster.level,
+ defense: this.monster.dr,
+ blockPercent: this.monster.block,
+ resFire: this.monster.resFire,
+ flatMagicReduction: this.monster.flatMdr,
+ currentHp: this.monster.hp,
+ maxHp: this.monster.maxHp,
+ tier: this.monster.tier,
+ isRunning: this.monster.posture === 'run',
+ size: this.monster.size,
+ isUber: this.monster.isUber,
+ },
+ {
+ isSpell: true,
+ difficulty: this.player.difficulty,
+ convictionPenalty: this.player.conviction,
+ lowerResPenalty: this.player.lowerRes,
+ itemMinusFireRes: this.player.minusFireRes,
+ },
+ )
+ this.applyCombatResult(result, 'fire')
+ this.spawnExplosion(mX, mY - 20, '#ff5722', 20)
+ },
+ })
+ }
+
+ public castColdSpell() {
+ if (this.player.hp <= 0) return
+ this.player.action = 'cast'
+ this.player.actionTimer = 16
+
+ const spellDmg = Math.round((this.player.cold > 0 ? this.player.cold * 1.6 : 38) + Math.random() * 15)
+ const pX = this.canvas.width * 0.35
+ const pY = this.canvas.height * 0.52
+ const mX = this.canvas.width * 0.65
+ const mY = this.canvas.height * 0.52
+
+ this.projectiles.push({
+ x: pX, y: pY - 20, targetX: mX, targetY: mY - 20,
+ type: 'cold', progress: 0, speed: 0.07,
+ onHit: () => {
+ const result = calculateAttack(
+ this.player.level, 0,
+ { physicalMin: 0, physicalMax: 0, cold: spellDmg },
+ {
+ level: this.monster.level,
+ defense: this.monster.dr,
+ blockPercent: this.monster.block,
+ resCold: this.monster.resCold,
+ flatMagicReduction: this.monster.flatMdr,
+ currentHp: this.monster.hp,
+ maxHp: this.monster.maxHp,
+ tier: this.monster.tier,
+ isRunning: this.monster.posture === 'run',
+ size: this.monster.size,
+ isUber: this.monster.isUber,
+ },
+ {
+ isSpell: true,
+ difficulty: this.player.difficulty,
+ convictionPenalty: this.player.conviction,
+ lowerResPenalty: this.player.lowerRes,
+ itemMinusColdRes: this.player.minusColdRes,
+ },
+ )
+ this.applyCombatResult(result, 'cold')
+ if (result.finalColdDamage > 0) {
+ this.monster.chillTimer = 60
+ }
+ this.spawnExplosion(mX, mY - 20, '#29b6f6', 24)
+ },
+ })
+ }
+
+ public castLightningSpell() {
+ if (this.player.hp <= 0) return
+ this.player.action = 'cast'
+ this.player.actionTimer = 16
+
+ // Lightning has iconic wide damage range [1 .. High]
+ const spellDmg = Math.round(1 + Math.random() * (this.player.light > 0 ? this.player.light * 2.5 : 90))
+ const mX = this.canvas.width * 0.65
+ const mY = this.canvas.height * 0.52
+
+ const result = calculateAttack(
+ this.player.level, 0,
+ { physicalMin: 0, physicalMax: 0, lightning: spellDmg },
+ {
+ level: this.monster.level,
+ defense: this.monster.dr,
+ blockPercent: this.monster.block,
+ resLightning: this.monster.resLightning,
+ flatMagicReduction: this.monster.flatMdr,
+ currentHp: this.monster.hp,
+ maxHp: this.monster.maxHp,
+ tier: this.monster.tier,
+ isRunning: this.monster.posture === 'run',
+ size: this.monster.size,
+ isUber: this.monster.isUber,
+ },
+ {
+ isSpell: true,
+ difficulty: this.player.difficulty,
+ convictionPenalty: this.player.conviction,
+ lowerResPenalty: this.player.lowerRes,
+ itemMinusLightRes: this.player.minusLightRes,
+ },
+ )
+ this.applyCombatResult(result, 'light')
+ this.spawnExplosion(mX, mY - 20, '#ffee58', 25)
+ }
+
+ public castMagicSpell() {
+ if (this.player.hp <= 0) return
+ this.player.action = 'cast'
+ this.player.actionTimer = 16
+
+ const spellDmg = Math.round((this.player.magic > 0 ? this.player.magic * 1.8 : 55) + Math.random() * 25)
+ const pX = this.canvas.width * 0.35
+ const pY = this.canvas.height * 0.52
+ const mX = this.canvas.width * 0.65
+ const mY = this.canvas.height * 0.52
+
+ this.projectiles.push({
+ x: pX, y: pY - 20, targetX: mX, targetY: mY - 20,
+ type: 'magic', progress: 0, speed: 0.09,
+ onHit: () => {
+ const result = calculateAttack(
+ this.player.level, 0,
+ { physicalMin: 0, physicalMax: 0, magic: spellDmg },
+ {
+ level: this.monster.level,
+ defense: this.monster.dr,
+ blockPercent: this.monster.block,
+ resMagic: this.monster.resMagic,
+ flatMagicReduction: this.monster.flatMdr,
+ currentHp: this.monster.hp,
+ maxHp: this.monster.maxHp,
+ tier: this.monster.tier,
+ isRunning: this.monster.posture === 'run',
+ size: this.monster.size,
+ isUber: this.monster.isUber,
+ },
+ { isSpell: true, difficulty: this.player.difficulty },
+ )
+ this.applyCombatResult(result, 'magic')
+ this.spawnExplosion(mX, mY - 20, '#ab47bc', 20)
+ },
+ })
+ }
+
+ public executeMonsterAttack() {
+ if (this.monster.hp <= 0 || this.monster.freezeTimer > 0 || this.monster.stunTimer > 0 || this.monster.fleeTimer > 0) return
+ this.monster.action = 'attack'
+ const slowFactor = 1 + (this.monster.slowPercent / 100)
+ this.monster.actionTimer = Math.round(18 * slowFactor)
+
+ // Monster strikes Player
+ const result = calculateAttack(
+ this.monster.level,
+ this.monster.ar,
+ {
+ physicalMin: this.monster.dmgMin,
+ physicalMax: this.monster.dmgMax,
+ },
+ {
+ level: this.player.level,
+ defense: this.player.dr,
+ blockPercent: this.player.block,
+ flatDamageReduction: this.player.flatDr,
+ flatMagicReduction: this.player.flatMdr,
+ currentHp: this.player.hp,
+ maxHp: this.player.maxHp,
+ tier: 'player',
+ },
+ {
+ difficulty: this.player.difficulty,
+ },
+ )
+
+ const pX = this.canvas.width * 0.35
+ const pY = this.canvas.height * 0.52
+
+ if (!result.isHit) {
+ this.addFloatingText(pX, pY - 40, 'MISS', '#90a4ae')
+ } else if (result.isBlocked) {
+ this.addFloatingText(pX, pY - 40, '🛡️ BLOCKED', '#64b5f6')
+ } else {
+ this.player.hp = Math.max(0, Math.round((this.player.hp - result.totalDamage) * 10) / 10)
+ this.player.hitFlash = 6
+ ;(document.getElementById('char-hp') as HTMLInputElement).value = String(this.player.hp)
+ this.addFloatingText(pX, pY - 40, `-${result.totalDamage}`, '#e53935', 18)
+ if (this.player.hp <= 0) {
+ this.player.action = 'dead'
+ this.addFloatingText(pX, pY - 60, 'YOU DIED', '#d32f2f', 22)
+ }
+ }
+
+ this.appendLog(result.log, result.isHit ? (result.isBlocked ? 'blocked' : 'hit') : 'miss')
+ }
+
+ private applyCombatResult(result: CombatCalculationResult, kind: string) {
+ const mX = this.canvas.width * 0.65
+ const mY = this.canvas.height * 0.52
+ const pX = this.canvas.width * 0.35
+ const pY = this.canvas.height * 0.52
+
+ if (!result.isHit) {
+ this.addFloatingText(mX, mY - 40, 'MISS', '#90a4ae')
+ this.appendLog(result.log, 'miss')
+ return
+ }
+
+ if (result.isBlocked) {
+ this.addFloatingText(mX, mY - 40, '🛡️ BLOCKED', '#64b5f6')
+ this.appendLog(result.log, 'blocked')
+ return
+ }
+
+ // Apply Damage
+ this.monster.hp = Math.max(0, Math.round((this.monster.hp - result.totalDamage) * 10) / 10)
+ this.monster.hitFlash = 6
+ this.totalDamageDealt += result.totalDamage
+ ;(document.getElementById('mon-hp') as HTMLInputElement).value = String(this.monster.hp)
+
+ // Damage floating text
+ let dmgColor = '#ffffff'
+ if (kind === 'fire' || result.finalFireDamage > 0) dmgColor = '#ff7043'
+ if (kind === 'cold' || result.finalColdDamage > 0) dmgColor = '#4fc3f7'
+ if (kind === 'light' || result.finalLightningDamage > 0) dmgColor = '#ffee58'
+ if (kind === 'magic' || result.finalMagicDamage > 0) dmgColor = '#ba68c8'
+
+ this.addFloatingText(mX, mY - 40, `-${result.totalDamage}`, dmgColor, result.isCritical ? 20 : 16)
+
+ // Critical & Deadly Strike distinction
+ if (result.isCritical) {
+ if (result.criticalTrigger === 'critical_strike') {
+ this.addFloatingText(mX, mY - 60, '⚡ CRITICAL STRIKE! (2x)', '#ffd54f', 14)
+ } else if (result.criticalTrigger === 'deadly_strike') {
+ this.addFloatingText(mX, mY - 60, '⚔️ DEADLY STRIKE! (2x)', '#ffb74d', 14)
+ } else {
+ this.addFloatingText(mX, mY - 60, '⚡ CRITICAL!', '#ffd54f', 14)
+ }
+ }
+
+ // Crushing Blow
+ if (result.isCrushingBlow) {
+ this.addFloatingText(mX, mY - 75, `💥 CRUSHING BLOW! (-${result.crushingBlowDamage})`, '#ef5350', 14)
+ this.spawnExplosion(mX, mY - 20, '#ff1744', 24)
+ }
+
+ // Open Wounds
+ if (result.isOpenWounds && result.openWoundsResult) {
+ this.monster.openWoundsTimer = 200
+ this.monster.openWoundsDmgPerFrame = result.openWoundsResult.damagePerFrame
+ this.addFloatingText(mX, mY - 90, '🩸 OPEN WOUNDS (8s)', '#d32f2f', 13)
+ this.spawnExplosion(mX, mY - 20, '#b71c1c', 16)
+ }
+
+ // Life & Mana Leech
+ if (result.lifeLeeched > 0) {
+ this.player.hp = Math.min(this.player.maxHp, this.player.hp + result.lifeLeeched)
+ ;(document.getElementById('char-hp') as HTMLInputElement).value = String(Math.round(this.player.hp))
+ this.addFloatingText(pX, pY - 45, `+${result.lifeLeeched} HP`, '#4caf50', 13)
+ }
+ if (result.manaLeeched > 0) {
+ this.player.mana = Math.min(this.player.maxMana, this.player.mana + result.manaLeeched)
+ ;(document.getElementById('char-mana') as HTMLInputElement).value = String(Math.round(this.player.mana))
+ this.addFloatingText(pX, pY - 60, `+${result.manaLeeched} MP`, '#42a5f5', 13)
+ }
+
+ // Freeze Target & Chilled
+ if (result.freezeResult?.willFreeze) {
+ this.monster.freezeTimer = result.freezeResult.durationFrames
+ this.addFloatingText(mX, mY - 105, `❄️ FROZEN (${result.freezeResult.durationSeconds}s)`, '#80deea', 14)
+ this.spawnExplosion(mX, mY - 20, '#80deea', 18)
+ } else if (result.freezeResult?.isChilledOnly) {
+ this.monster.chillTimer = result.freezeResult.durationFrames
+ this.addFloatingText(mX, mY - 105, `❄️ CHILLED (${result.freezeResult.durationSeconds}s)`, '#81d4fa', 13)
+ }
+
+ // Knockback
+ if (result.knockbackTriggered) {
+ this.monster.knockbackOffset = 25
+ this.addFloatingText(mX, mY - 30, '💨 KNOCKBACK', '#b0bec5', 12)
+ }
+
+ // Hit recovery
+ if (result.hitRecoveryTriggered) {
+ this.monster.stunTimer = 25
+ this.addFloatingText(mX, mY - 120, '💫 FHR STUN', '#ffca28', 12)
+ }
+
+ // Resistance Break Floating Text
+ if (result.resistanceBreakResults) {
+ for (const [elem, brk] of Object.entries(result.resistanceBreakResults)) {
+ if (brk.immunityBroken) {
+ const name = elem === 'physical' ? '物' : elem === 'fire' ? '火' : elem === 'lightning' ? '电' : elem === 'cold' ? '冰' : '毒'
+ this.addFloatingText(mX, mY - 135, `⚡ 破${name}免成功! (${brk.finalRes}%)`, '#ffd54f', 13)
+ }
+ }
+ }
+
+ // Prevent Monster Heal (PMH)
+ if (result.pmhResult) {
+ if (result.pmhResult.applied) {
+ this.monster.pmhTimer = result.pmhResult.durationFrames
+ this.addFloatingText(mX, mY - 150, '🚫 PMH 自愈阻断 (80分)', '#81c784', 13)
+ } else if (this.player.pmh) {
+ this.addFloatingText(mX, mY - 150, `🚫 PMH无效: ${result.pmhResult.reason}`, '#ef5350', 11)
+ }
+ }
+
+ // Slows Target
+ if (result.slowResult && result.slowResult.effectiveSlowPercent > 0) {
+ this.monster.slowTimer = 150
+ this.monster.slowPercent = result.slowResult.effectiveSlowPercent
+ this.addFloatingText(mX, mY - 120, `🐌 目标减速 -${result.slowResult.effectiveSlowPercent}%`, '#ffb74d', 13)
+ }
+
+ // Hit Blinds Target
+ if (result.blindResult?.triggered) {
+ this.monster.blindTimer = result.blindResult.durationFrames
+ this.addFloatingText(mX, mY - 165, `👁️ 目标致盲 (${result.blindResult.durationSeconds}s)`, '#ba68c8', 13)
+ this.spawnExplosion(mX, mY - 20, '#ab47bc', 14)
+ }
+
+ // Hit Causes Monster to Flee
+ if (result.fleeResult?.triggered) {
+ this.monster.fleeTimer = result.fleeResult.durationFrames
+ this.addFloatingText(mX, mY - 180, '😱 恐吓逃跑 (5s)', '#ff8a65', 13)
+ this.spawnExplosion(mX, mY - 20, '#ff7043', 14)
+ }
+
+ // Death Check
+ if (this.monster.hp <= 0) {
+ this.monster.action = 'dead'
+ this.addFloatingText(mX, mY - 60, '💀 SLAIN', '#e53935', 22)
+ const earnedXp = Math.round(50 * (this.monster.level / Math.max(1, this.player.level)))
+ this.addFloatingText(mX, mY - 80, `+${earnedXp} EXP`, '#81c784', 13)
+ }
+
+ this.appendLog(result.log, result.isCritical ? 'crit' : 'hit')
+ this.updateDpsMeter()
+ }
+
+ private toggleAutoBattle() {
+ this.autoBattleActive = !this.autoBattleActive
+ const btnLabel = document.getElementById('auto-battle-label')!
+ if (this.autoBattleActive) {
+ btnLabel.textContent = '⏸️ 停止对决'
+ this.combatStartTime = performance.now()
+ this.totalDamageDealt = 0
+ this.autoBattleInterval = window.setInterval(() => {
+ if (this.monster.hp <= 0 || this.player.hp <= 0) {
+ this.toggleAutoBattle()
+ return
+ }
+ this.executeMeleeAttack()
+ if (Math.random() < 0.6) {
+ this.executeMonsterAttack()
+ }
+ }, 400)
+ } else {
+ btnLabel.textContent = '🔄 开启自动对决'
+ if (this.autoBattleInterval) {
+ clearInterval(this.autoBattleInterval)
+ this.autoBattleInterval = null
+ }
+ }
+ }
+
+ public resetCombat() {
+ this.player.hp = this.player.maxHp
+ this.player.mana = this.player.maxMana
+ this.player.action = 'idle'
+ ;(document.getElementById('char-hp') as HTMLInputElement).value = String(this.player.hp)
+ ;(document.getElementById('char-mana') as HTMLInputElement).value = String(this.player.mana)
+
+ this.monster.hp = this.monster.maxHp
+ this.monster.action = 'idle'
+ this.monster.stunTimer = 0
+ this.monster.chillTimer = 0
+ this.monster.freezeTimer = 0
+ this.monster.openWoundsTimer = 0
+ this.monster.openWoundsDmgPerFrame = 0
+ this.monster.knockbackOffset = 0
+ this.monster.pmhTimer = 0
+ this.monster.blindTimer = 0
+ this.monster.fleeTimer = 0
+ this.monster.slowTimer = 0
+ this.monster.slowPercent = 0
+ ;(document.getElementById('mon-hp') as HTMLInputElement).value = String(this.monster.hp)
+
+ this.floatingTexts = []
+ this.projectiles = []
+ this.totalDamageDealt = 0
+ this.combatStartTime = 0
+ this.updateDpsMeter()
+
+ this.appendLog(['[重置]: 玩家与怪物恢复满生命值,所有受击硬直、流血、冻结与击退状态已清空。'], 'hit')
+ }
+
+ private updateDpsMeter() {
+ const dpsEl = document.getElementById('stat-dps')!
+ if (this.combatStartTime > 0) {
+ const elapsed = (performance.now() - this.combatStartTime) / 1000
+ if (elapsed > 0.5) {
+ const dps = this.totalDamageDealt / elapsed
+ dpsEl.textContent = `DPS: ${dps.toFixed(1)} (累计: ${this.totalDamageDealt.toFixed(0)})`
+ return
+ }
+ }
+ dpsEl.textContent = `DPS: 0.0 (累计: ${this.totalDamageDealt.toFixed(0)})`
+ }
+
+ private appendLog(lines: readonly string[], style: 'hit' | 'miss' | 'blocked' | 'crit') {
+ const container = document.getElementById('log-entries')!
+ const item = document.createElement('div')
+ item.className = `log-item ${style}`
+ item.innerHTML = lines.map(line => `${escapeHtml(line)}
`).join('')
+ container.insertBefore(item, container.firstChild)
+ // Keep max 80 entries
+ while (container.children.length > 80) {
+ container.removeChild(container.lastChild!)
+ }
+ }
+
+ private addFloatingText(x: number, y: number, text: string, color: string, size = 15) {
+ this.floatingTexts.push({
+ x: x + (Math.random() - 0.5) * 30,
+ y: y + (Math.random() - 0.5) * 15,
+ text, color, size, alpha: 1.0, vy: -1.2, life: 50,
+ })
+ }
+
+ private spawnExplosion(x: number, y: number, color: string, count = 16) {
+ for (let i = 0; i < count; i++) {
+ const angle = Math.random() * Math.PI * 2
+ const speed = 1.5 + Math.random() * 3.5
+ this.particles.push({
+ x, y,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ color,
+ size: 2 + Math.random() * 3,
+ life: 25,
+ maxLife: 25,
+ })
+ }
+ }
+
+ // --- Render Loop ----------------------------------------------------------
+
+ private startRenderLoop() {
+ const loop = () => {
+ this.update()
+ this.render()
+ requestAnimationFrame(loop)
+ }
+ requestAnimationFrame(loop)
+ }
+
+ private update() {
+ // Player timers
+ if (this.player.actionTimer > 0) {
+ this.player.actionTimer -= 1
+ if (this.player.actionTimer === 0 && this.player.action !== 'dead') {
+ this.player.action = 'idle'
+ }
+ }
+ if (this.player.hitFlash > 0) this.player.hitFlash -= 1
+
+ // Monster timers
+ if (this.monster.actionTimer > 0) {
+ this.monster.actionTimer -= 1
+ if (this.monster.actionTimer === 0 && this.monster.action !== 'dead') {
+ this.monster.action = 'idle'
+ }
+ }
+ if (this.monster.hitFlash > 0) this.monster.hitFlash -= 1
+ if (this.monster.stunTimer > 0) this.monster.stunTimer -= 1
+ if (this.monster.chillTimer > 0) this.monster.chillTimer -= 1
+ if (this.monster.freezeTimer > 0) this.monster.freezeTimer -= 1
+ if (this.monster.knockbackOffset > 0) {
+ this.monster.knockbackOffset = Math.max(0, this.monster.knockbackOffset - 1.5)
+ }
+ if (this.monster.pmhTimer > 0) this.monster.pmhTimer -= 1
+ if (this.monster.blindTimer > 0) this.monster.blindTimer -= 1
+ if (this.monster.fleeTimer > 0) this.monster.fleeTimer -= 1
+ if (this.monster.slowTimer > 0) {
+ this.monster.slowTimer -= 1
+ if (this.monster.slowTimer === 0) this.monster.slowPercent = 0
+ }
+
+ // Natural Monster Regeneration (blocked by OW or PMH)
+ if (this.monster.hp > 0 && this.monster.hp < this.monster.maxHp && this.monster.regenRate > 0) {
+ const regen = calculateMonsterRegeneration(
+ this.monster.maxHp,
+ this.monster.regenRate,
+ this.monster.openWoundsTimer > 0,
+ this.monster.pmhTimer > 0,
+ )
+ if (regen.effectiveRegenPerFrame > 0) {
+ this.monster.hp = Math.min(this.monster.maxHp, Math.round((this.monster.hp + regen.effectiveRegenPerFrame) * 100) / 100)
+ const hpInput = document.getElementById('mon-hp') as HTMLInputElement | null
+ if (hpInput) hpInput.value = String(Math.round(this.monster.hp))
+ }
+ }
+
+ // Open Wounds bleeding tick
+ if (this.monster.openWoundsTimer > 0 && this.monster.hp > 0) {
+ this.monster.openWoundsTimer -= 1
+ const tickDmg = this.monster.openWoundsDmgPerFrame
+ this.monster.hp = Math.max(0, Math.round((this.monster.hp - tickDmg) * 10) / 10)
+ const hpInput = document.getElementById('mon-hp') as HTMLInputElement | null
+ if (hpInput) hpInput.value = String(Math.round(this.monster.hp))
+
+ const mX = this.canvas.width * 0.65
+ const mY = this.canvas.height * 0.52
+
+ // Visual tick every 25 frames (1 second in 25 fps game clock)
+ if (this.monster.openWoundsTimer % 25 === 0) {
+ const secDmg = Math.round(tickDmg * 25 * 10) / 10
+ this.addFloatingText(mX + (Math.random() - 0.5) * 20, mY - 35, `-${secDmg} 🩸`, '#d32f2f', 12)
+ this.spawnExplosion(mX, mY - 20, '#b71c1c', 6)
+ }
+
+ if (this.monster.hp <= 0 && this.monster.action !== 'dead') {
+ this.monster.action = 'dead'
+ this.addFloatingText(mX, mY - 60, '💀 BLED TO DEATH', '#b71c1c', 22)
+ }
+ }
+
+ // Update Floating Text
+ for (let i = this.floatingTexts.length - 1; i >= 0; i--) {
+ const ft = this.floatingTexts[i]!
+ ft.y += ft.vy
+ ft.life -= 1
+ ft.alpha = Math.max(0, ft.life / 50)
+ if (ft.life <= 0) this.floatingTexts.splice(i, 1)
+ }
+
+ // Update Projectiles
+ for (let i = this.projectiles.length - 1; i >= 0; i--) {
+ const p = this.projectiles[i]!
+ p.progress += p.speed
+ p.x = p.x + (p.targetX - p.x) * p.speed * 2
+ p.y = p.y + (p.targetY - p.y) * p.speed * 2
+ if (p.progress >= 1 || Math.hypot(p.targetX - p.x, p.targetY - p.y) < 15) {
+ p.onHit()
+ this.projectiles.splice(i, 1)
+ }
+ }
+
+ // Update Particles
+ for (let i = this.particles.length - 1; i >= 0; i--) {
+ const pt = this.particles[i]!
+ pt.x += pt.vx
+ pt.y += pt.vy
+ pt.life -= 1
+ if (pt.life <= 0) this.particles.splice(i, 1)
+ }
+ }
+
+ private render() {
+ const ctx = this.ctx
+ const w = this.canvas.width
+ const h = this.canvas.height
+
+ // Background
+ ctx.fillStyle = '#0a0806'
+ ctx.fillRect(0, 0, w, h)
+
+ // Draw Isometric Duel Arena
+ this.renderArenaGround(ctx, w, h)
+
+ // Positions
+ const pX = w * 0.35
+ const pY = h * 0.52
+ const mX = w * 0.65
+ const mY = h * 0.52
+
+ // Shadows
+ this.renderShadow(ctx, pX, pY + 8, 28, 12)
+ this.renderShadow(ctx, mX + this.monster.knockbackOffset, mY + 8, 32, 14)
+
+ // Render Characters
+ this.renderPlayerAvatar(ctx, pX, pY)
+ this.renderMonsterAvatar(ctx, mX, mY)
+
+ // Render Projectiles & Particles
+ this.renderProjectiles(ctx)
+ this.renderParticles(ctx)
+
+ // Overhead Status Bars
+ this.renderOverheadPlayerBar(ctx, pX, pY - 80)
+ this.renderOverheadMonsterBar(ctx, mX + this.monster.knockbackOffset, mY - 85)
+
+ // Floating combat text
+ this.renderFloatingTexts(ctx)
+ }
+
+ private renderArenaGround(ctx: CanvasRenderingContext2D, w: number, h: number) {
+ const cX = w * 0.5
+ const cY = h * 0.52
+
+ // Large outer rune diamond
+ ctx.save()
+ ctx.beginPath()
+ ctx.moveTo(cX, cY - 140)
+ ctx.lineTo(cX + 280, cY)
+ ctx.lineTo(cX, cY + 140)
+ ctx.lineTo(cX - 280, cY)
+ ctx.closePath()
+ ctx.fillStyle = '#14100c'
+ ctx.fill()
+ ctx.lineWidth = 3
+ ctx.strokeStyle = '#3d2f20'
+ ctx.stroke()
+
+ // Inner patterned ring
+ ctx.beginPath()
+ ctx.ellipse(cX, cY, 200, 95, 0, 0, Math.PI * 2)
+ ctx.fillStyle = '#1a140f'
+ ctx.fill()
+ ctx.lineWidth = 1.5
+ ctx.strokeStyle = '#5a432b'
+ ctx.stroke()
+
+ // Runic circle lines
+ ctx.beginPath()
+ ctx.ellipse(cX, cY, 130, 60, 0, 0, Math.PI * 2)
+ ctx.lineWidth = 1
+ ctx.strokeStyle = '#8c693a'
+ ctx.stroke()
+
+ // Isometric grid lines inside arena
+ ctx.strokeStyle = '#271f16'
+ ctx.lineWidth = 1
+ for (let x = -4; x <= 4; x++) {
+ ctx.beginPath()
+ ctx.moveTo(cX + x * 40, cY - 70 + Math.abs(x) * 15)
+ ctx.lineTo(cX + x * 40, cY + 70 - Math.abs(x) * 15)
+ ctx.stroke()
+ }
+
+ // Torch poles on corners
+ this.renderTorch(ctx, cX - 250, cY)
+ this.renderTorch(ctx, cX + 250, cY)
+ this.renderTorch(ctx, cX, cY - 120)
+ this.renderTorch(ctx, cX, cY + 120)
+
+ ctx.restore()
+ }
+
+ private renderTorch(ctx: CanvasRenderingContext2D, x: number, y: number) {
+ ctx.fillStyle = '#3a2b1c'
+ ctx.fillRect(x - 3, y - 25, 6, 25)
+ ctx.fillStyle = '#1c150e'
+ ctx.fillRect(x - 6, y - 28, 12, 4)
+
+ // Torch Fire Animation
+ const flick = Math.sin(Date.now() * 0.01 + x) * 2
+ ctx.beginPath()
+ ctx.arc(x, y - 32 + flick, 6, 0, Math.PI * 2)
+ ctx.fillStyle = '#ff9800'
+ ctx.fill()
+
+ ctx.beginPath()
+ ctx.arc(x, y - 34 + flick, 3, 0, Math.PI * 2)
+ ctx.fillStyle = '#ffeb3b'
+ ctx.fill()
+ }
+
+ private renderShadow(ctx: CanvasRenderingContext2D, x: number, y: number, rx: number, ry: number) {
+ ctx.save()
+ ctx.beginPath()
+ ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2)
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)'
+ ctx.fill()
+ ctx.restore()
+ }
+
+ private renderPlayerAvatar(ctx: CanvasRenderingContext2D, x: number, y: number) {
+ ctx.save()
+
+ // Hit flash overlay
+ if (this.player.hitFlash > 0) {
+ ctx.filter = 'brightness(1.8) drop-shadow(0 0 8px rgba(255,50,50,0.8))'
+ }
+
+ const isAttacking = this.player.action === 'attack'
+ const isCasting = this.player.action === 'cast'
+ const isDead = this.player.action === 'dead'
+ const breath = Math.sin(Date.now() * 0.004) * 2
+
+ if (isDead) {
+ ctx.translate(x, y)
+ ctx.rotate(-Math.PI / 2)
+ ctx.fillStyle = '#555'
+ ctx.fillRect(-15, -40, 30, 40)
+ ctx.restore()
+ return
+ }
+
+ // Body base
+ ctx.translate(x, y + breath)
+
+ // Cloak / Robes
+ ctx.fillStyle = this.player.class === 'Sorceress' ? '#283593' : '#37474f'
+ ctx.beginPath()
+ ctx.moveTo(-16, 0)
+ ctx.lineTo(16, 0)
+ ctx.lineTo(10, -40)
+ ctx.lineTo(-10, -40)
+ ctx.closePath()
+ ctx.fill()
+
+ // Torso / Armour
+ ctx.fillStyle = '#d4af37'
+ ctx.fillRect(-9, -42, 18, 22)
+
+ // Belt
+ ctx.fillStyle = '#4e342e'
+ ctx.fillRect(-9, -23, 18, 4)
+
+ // Head
+ ctx.fillStyle = '#ffcc80'
+ ctx.beginPath()
+ ctx.arc(0, -48, 8, 0, Math.PI * 2)
+ ctx.fill()
+
+ // Hair / Hood
+ ctx.fillStyle = this.player.class === 'Sorceress' ? '#bf360c' : '#212121'
+ ctx.beginPath()
+ ctx.arc(0, -50, 8.5, Math.PI * 0.8, Math.PI * 2.2)
+ ctx.fill()
+
+ // Weapon / Staff
+ ctx.save()
+ if (isAttacking) {
+ ctx.rotate(0.35)
+ ctx.fillStyle = '#b0bec5'
+ ctx.fillRect(10, -50, 4, 45) // Sword slash
+ ctx.fillStyle = '#ffd54f'
+ ctx.fillRect(6, -25, 12, 4)
+ } else if (isCasting) {
+ ctx.fillStyle = '#81d4fa'
+ ctx.fillRect(8, -65, 3, 55) // Staff raised
+ ctx.beginPath()
+ ctx.arc(9.5, -67, 7, 0, Math.PI * 2)
+ ctx.fillStyle = 'rgba(129, 212, 250, 0.6)'
+ ctx.fill()
+ } else {
+ ctx.fillStyle = '#b0bec5'
+ ctx.fillRect(8, -40, 3, 40)
+ }
+ ctx.restore()
+
+ // Shield (if has block)
+ if (this.player.block > 0) {
+ ctx.fillStyle = '#cfd8dc'
+ ctx.beginPath()
+ ctx.moveTo(-18, -35)
+ ctx.lineTo(-6, -35)
+ ctx.lineTo(-8, -18)
+ ctx.lineTo(-12, -12)
+ ctx.lineTo(-16, -18)
+ ctx.closePath()
+ ctx.fill()
+ ctx.strokeStyle = '#d4af37'
+ ctx.lineWidth = 1.5
+ ctx.stroke()
+ }
+
+ ctx.restore()
+ }
+
+ private renderMonsterAvatar(ctx: CanvasRenderingContext2D, x: number, y: number) {
+ ctx.save()
+
+ const drawX = x + this.monster.knockbackOffset
+
+ if (this.monster.hitFlash > 0) {
+ ctx.filter = 'brightness(2.0) drop-shadow(0 0 10px rgba(255,30,30,0.9))'
+ } else if (this.monster.freezeTimer > 0) {
+ ctx.filter = 'drop-shadow(0 0 12px rgba(0,229,255,0.9)) hue-rotate(190deg) brightness(1.2)'
+ } else if (this.monster.chillTimer > 0 || this.monster.slowTimer > 0) {
+ ctx.filter = 'drop-shadow(0 0 8px rgba(33,150,243,0.8)) hue-rotate(180deg)'
+ } else if (this.monster.blindTimer > 0) {
+ ctx.filter = 'drop-shadow(0 0 8px rgba(171,71,188,0.8)) brightness(0.6)'
+ } else if (this.monster.fleeTimer > 0) {
+ ctx.filter = 'drop-shadow(0 0 8px rgba(255,112,67,0.8))'
+ } else if (this.monster.openWoundsTimer > 0) {
+ ctx.filter = 'drop-shadow(0 0 8px rgba(183,28,28,0.8))'
+ }
+
+ const isAttacking = this.monster.action === 'attack'
+ const isDead = this.monster.action === 'dead'
+ const isStunned = this.monster.stunTimer > 0
+ const breath = Math.sin(Date.now() * 0.005) * 2
+
+ if (isDead) {
+ ctx.translate(drawX, y)
+ ctx.rotate(Math.PI / 2)
+ ctx.fillStyle = '#424242'
+ ctx.fillRect(-15, -40, 30, 40)
+ ctx.restore()
+ return
+ }
+
+ ctx.translate(drawX, y + (isStunned ? -4 : breath))
+ if (this.monster.fleeTimer > 0) {
+ ctx.scale(-1, 1) // Turn around in terror
+ }
+
+ // Monster Base Shape according to template
+ const color = this.monster.color
+ ctx.fillStyle = color
+
+ if (this.monster.typeKey.startsWith('fallen')) {
+ // Small hunched demon
+ ctx.beginPath()
+ ctx.ellipse(0, -22, 14, 18, -0.2, 0, Math.PI * 2)
+ ctx.fill()
+ // Horns
+ ctx.fillStyle = '#212121'
+ ctx.beginPath()
+ ctx.moveTo(-8, -36); ctx.lineTo(-12, -44); ctx.lineTo(-4, -38)
+ ctx.moveTo(4, -38); ctx.lineTo(8, -44); ctx.lineTo(6, -36)
+ ctx.fill()
+ // Small dagger
+ ctx.fillStyle = '#90a4ae'
+ ctx.fillRect(isAttacking ? -22 : -14, -26, 12, 3)
+ } else if (this.monster.typeKey.startsWith('zombie')) {
+ // Tall shambling corpse
+ ctx.fillRect(-12, -48, 24, 45)
+ ctx.fillStyle = '#33691e'
+ ctx.beginPath()
+ ctx.arc(0, -52, 10, 0, Math.PI * 2)
+ ctx.fill()
+ } else if (this.monster.typeKey === 'diablo' || this.monster.typeKey === 'baal') {
+ // Huge boss figure
+ ctx.beginPath()
+ ctx.ellipse(0, -45, 26, 42, 0, 0, Math.PI * 2)
+ ctx.fill()
+ // Giant Horns
+ ctx.fillStyle = '#212121'
+ ctx.beginPath()
+ ctx.moveTo(-16, -75); ctx.lineTo(-32, -95); ctx.lineTo(-12, -80)
+ ctx.moveTo(12, -80); ctx.lineTo(32, -95); ctx.lineTo(16, -75)
+ ctx.fill()
+ // Glowing Eyes
+ ctx.fillStyle = '#ffeb3b'
+ ctx.beginPath()
+ ctx.arc(-8, -70, 3, 0, Math.PI * 2)
+ ctx.arc(8, -70, 3, 0, Math.PI * 2)
+ ctx.fill()
+ } else if (this.monster.typeKey === 'dummy') {
+ // Wood target dummy
+ ctx.fillStyle = '#8d6e63'
+ ctx.fillRect(-6, -45, 12, 45)
+ ctx.fillRect(-22, -38, 44, 10)
+ ctx.beginPath()
+ ctx.arc(0, -45, 12, 0, Math.PI * 2)
+ ctx.fillStyle = '#d7ccc8'
+ ctx.fill()
+ ctx.strokeStyle = '#c62828'
+ ctx.lineWidth = 2
+ ctx.stroke()
+ } else {
+ // Generic humanoid monster
+ ctx.fillRect(-12, -42, 24, 40)
+ ctx.beginPath()
+ ctx.arc(0, -46, 9, 0, Math.PI * 2)
+ ctx.fill()
+ }
+
+ // Frozen Ice Crystal Overlay
+ if (this.monster.freezeTimer > 0 && !isDead) {
+ ctx.save()
+ ctx.fillStyle = 'rgba(128, 222, 234, 0.45)'
+ ctx.strokeStyle = '#80deea'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.rect(-24, -68, 48, 72)
+ ctx.fill()
+ ctx.stroke()
+ // Ice crack lines
+ ctx.beginPath()
+ ctx.moveTo(-14, -50); ctx.lineTo(-2, -36); ctx.lineTo(14, -46)
+ ctx.moveTo(-2, -36); ctx.lineTo(-6, -18)
+ ctx.moveTo(3, -28); ctx.lineTo(12, -14)
+ ctx.strokeStyle = '#e0f7fa'
+ ctx.lineWidth = 1.5
+ ctx.stroke()
+ ctx.restore()
+ }
+
+ // Open Wounds Bleeding Aura
+ if (this.monster.openWoundsTimer > 0 && !isDead) {
+ ctx.save()
+ ctx.fillStyle = '#d32f2f'
+ const t = Date.now() * 0.008
+ for (let i = 0; i < 4; i++) {
+ const dropY = ((t * 40 + i * 14) % 45) - 38
+ const dropX = (i % 2 === 0 ? 1 : -1) * (8 + i * 3)
+ ctx.beginPath()
+ ctx.arc(dropX, dropY, 2, 0, Math.PI * 2)
+ ctx.fill()
+ }
+ ctx.restore()
+ }
+
+ ctx.restore()
+ }
+
+ private renderProjectiles(ctx: CanvasRenderingContext2D) {
+ ctx.save()
+ for (const p of this.projectiles) {
+ ctx.beginPath()
+ ctx.arc(p.x, p.y, 6, 0, Math.PI * 2)
+ if (p.type === 'fire') {
+ ctx.fillStyle = '#ff5722'
+ ctx.shadowColor = '#ff9800'
+ ctx.shadowBlur = 12
+ } else if (p.type === 'cold') {
+ ctx.fillStyle = '#00e5ff'
+ ctx.shadowColor = '#80d8ff'
+ ctx.shadowBlur = 10
+ } else if (p.type === 'light') {
+ ctx.fillStyle = '#ffff00'
+ ctx.shadowColor = '#ffeb3b'
+ ctx.shadowBlur = 12
+ } else {
+ ctx.fillStyle = '#e040fb'
+ ctx.shadowColor = '#ea80fc'
+ ctx.shadowBlur = 12
+ }
+ ctx.fill()
+ }
+ ctx.restore()
+ }
+
+ private renderParticles(ctx: CanvasRenderingContext2D) {
+ ctx.save()
+ for (const pt of this.particles) {
+ ctx.beginPath()
+ ctx.arc(pt.x, pt.y, pt.size, 0, Math.PI * 2)
+ ctx.fillStyle = pt.color
+ ctx.globalAlpha = Math.max(0, pt.life / pt.maxLife)
+ ctx.fill()
+ }
+ ctx.restore()
+ }
+
+ private renderOverheadPlayerBar(ctx: CanvasRenderingContext2D, x: number, y: number) {
+ const barW = 80
+ const barH = 7
+
+ // HP Bar
+ ctx.fillStyle = '#1c150e'
+ ctx.fillRect(x - barW / 2, y, barW, barH)
+ const hpRatio = Math.max(0, Math.min(1, this.player.hp / this.player.maxHp))
+ ctx.fillStyle = '#d32f2f'
+ ctx.fillRect(x - barW / 2, y, barW * hpRatio, barH)
+ ctx.strokeStyle = '#4e342e'
+ ctx.lineWidth = 1
+ ctx.strokeRect(x - barW / 2, y, barW, barH)
+
+ // Mana Bar
+ ctx.fillStyle = '#1c150e'
+ ctx.fillRect(x - barW / 2, y + 8, barW, 4)
+ const manaRatio = Math.max(0, Math.min(1, this.player.mana / this.player.maxMana))
+ ctx.fillStyle = '#1976d2'
+ ctx.fillRect(x - barW / 2, y + 8, barW * manaRatio, 4)
+
+ // Text Label
+ ctx.font = '10px ui-monospace, monospace'
+ ctx.fillStyle = '#fff'
+ ctx.textAlign = 'center'
+ ctx.fillText(`${this.player.class} (Lv${this.player.level})`, x, y - 5)
+ ctx.fillStyle = '#ffcdd2'
+ ctx.fillText(`${Math.round(this.player.hp)}/${this.player.maxHp}`, x, y + barH + 16)
+ }
+
+ private renderOverheadMonsterBar(ctx: CanvasRenderingContext2D, x: number, y: number) {
+ const barW = 100
+ const barH = 8
+
+ // Status Badges (PMH, Slow, Blind, Flee, Frozen, Chill, OW)
+ const badges: { text: string; color: string }[] = []
+ if (this.monster.pmhTimer > 0) badges.push({ text: '🚫PMH', color: '#81c784' })
+ if (this.monster.slowTimer > 0) badges.push({ text: `🐌-${this.monster.slowPercent}%`, color: '#ffb74d' })
+ if (this.monster.blindTimer > 0) badges.push({ text: '👁️BLIND', color: '#ba68c8' })
+ if (this.monster.fleeTimer > 0) badges.push({ text: '😱FLEE', color: '#ff8a65' })
+ if (this.monster.freezeTimer > 0) badges.push({ text: '❄️FROZEN', color: '#80deea' })
+ else if (this.monster.chillTimer > 0) badges.push({ text: '❄️CHILL', color: '#81d4fa' })
+ if (this.monster.openWoundsTimer > 0) badges.push({ text: '🩸OW', color: '#e53935' })
+
+ if (badges.length > 0) {
+ ctx.font = 'bold 9px ui-monospace, monospace'
+ let badgeX = x - (badges.length * 44) / 2 + 22
+ for (const b of badges) {
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'
+ ctx.fillRect(badgeX - 20, y - 22, 40, 13)
+ ctx.fillStyle = b.color
+ ctx.textAlign = 'center'
+ ctx.fillText(b.text, badgeX, y - 12)
+ badgeX += 44
+ }
+ }
+
+ // HP Bar
+ ctx.fillStyle = '#1c150e'
+ ctx.fillRect(x - barW / 2, y, barW, barH)
+ const hpRatio = Math.max(0, Math.min(1, this.monster.hp / this.monster.maxHp))
+ ctx.fillStyle = '#c62828'
+ ctx.fillRect(x - barW / 2, y, barW * hpRatio, barH)
+ ctx.strokeStyle = '#5d4037'
+ ctx.lineWidth = 1
+ ctx.strokeRect(x - barW / 2, y, barW, barH)
+
+ // Text Label
+ ctx.font = '11px ui-monospace, monospace'
+ ctx.fillStyle = '#f0c070'
+ ctx.textAlign = 'center'
+ ctx.fillText(`${this.monster.name} Lv${this.monster.level}`, x, y - 5)
+ ctx.font = '10px ui-monospace, monospace'
+ ctx.fillStyle = '#ffcdd2'
+ ctx.fillText(`${Math.round(this.monster.hp)}/${this.monster.maxHp}`, x, y + barH + 12)
+ }
+
+ private renderFloatingTexts(ctx: CanvasRenderingContext2D) {
+ ctx.save()
+ ctx.textAlign = 'center'
+ for (const ft of this.floatingTexts) {
+ ctx.font = `bold ${ft.size}px ui-monospace, monospace`
+ ctx.globalAlpha = ft.alpha
+ ctx.strokeStyle = '#000'
+ ctx.lineWidth = 3
+ ctx.strokeText(ft.text, ft.x, ft.y)
+ ctx.fillStyle = ft.color
+ ctx.fillText(ft.text, ft.x, ft.y)
+ }
+ ctx.restore()
+ }
+}
+
+function escapeHtml(str: string): string {
+ return str.replace(/&/g, '&').replace(//g, '>')
+}
+
+// Start application when DOM is ready
+window.addEventListener('DOMContentLoaded', () => {
+ new CombatArenaApp()
+})
diff --git a/src/game/formulas.ts b/src/game/formulas.ts
new file mode 100644
index 0000000..65fabd7
--- /dev/null
+++ b/src/game/formulas.ts
@@ -0,0 +1,1915 @@
+/**
+ * Diablo II Combat Mathematics & Formulas.
+ *
+ * Implements canonical combat mathematics according to D2MOO, Diablo II 1.10–1.14c
+ * game logic, and authoritative community documentation (The Phrozen Keep, The Amazon Basin, Arreat Summit):
+ *
+ * 1. Attack Rating (AR) vs Defense Rating (DR/AC) to-hit formula with:
+ * - Ignore Target's Defense (ITD) - only works on normal monsters, fails on bosses/champions/players.
+ * - -% Target Defense - 100% on normal monsters, halved (50% effectiveness) against bosses/champions.
+ * - Running defender defense penalty (DR = 0 when running).
+ * - Clamped to [5%, 95%].
+ * 2. Shield blocking formula with Dexterity and Character Level scaling:
+ * - Capped at 75% while standing/walking/attacking.
+ * - Reduced to 1/3 and capped at 25% while running.
+ * 3. Two distinct Critical Strike types:
+ * - Critical Strike (CS) from skills (Amazon/Barbarian/Assassin).
+ * - Deadly Strike (DS) from equipment.
+ * - Mutually exclusive sequential roll (CS first, then DS; no 4x damage; physical damage only).
+ * 4. Crushing Blow (CB):
+ * - Normal Monster: 25% (melee) / 12.5% (ranged) current HP.
+ * - Boss / Champion / Super Unique: 12.5% (melee) / 6.25% (ranged) current HP.
+ * - Player / Merc: 10% (melee) / 5% (ranged) current HP.
+ * - Physical resistance interaction: reduced by 0%–99% DR; immune at >=100% DR;
+ * NOT amplified by negative DR (multiplier capped at 1.0).
+ * 5. Open Wounds (OW):
+ * - Fixed 200 frames (8.0 seconds at 25 fps).
+ * - Level-dependent 1/256th bit rate across 5 level brackets (1–15, 16–30, 31–45, 46–60, 61–99).
+ * - Bosses/Super Uniques take 50% damage; players take 25% (melee) / 12.5% (ranged).
+ * - Disables natural monster life regeneration while bleeding.
+ * 6. Life & Mana Steal (Leech):
+ * - Applied only to final physical damage.
+ * - Subject to target's Drain Effectiveness (e.g. Skeletons/Hell Mephisto = 0%).
+ * - Subject to game difficulty penalty (Normal 100%, Nightmare 50%, Hell 33.3%).
+ * 7. Control effects:
+ * - Freezes Target +X (with level difference, boss chill fallback, difficulty duration reduction).
+ * - Knockback (size and boss immunity).
+ * 8. Canonical Damage Mitigation Sequence:
+ * - Flat DR/MDR -> Percentage Resistance (%DR & Resists) -> Absorb.
+ * 9. Hit Recovery (FHR) trigger based on net damage vs Max HP thresholds.
+ * 10. Canonical 99-level experience progression table.
+ */
+
+export type TargetTier = 'normal' | 'boss' | 'player'
+export type GameDifficulty = 'normal' | 'nightmare' | 'hell'
+
+/**
+ * Options for To-Hit Chance calculation.
+ */
+export interface HitChanceOptions {
+ /** Ignore Target's Defense (ITD) affix (e.g. from Jah rune, Lightsaber). */
+ readonly ignoreTargetDefense?: boolean | undefined
+ /** -X% Target's Defense (e.g. Eth rune -25%, Conviction). */
+ readonly targetDefenseReductionPercent?: number | undefined
+ /** Whether the defender is currently running (sets defender DR to 0). */
+ readonly isTargetRunning?: boolean | undefined
+ /** Whether the defender is a boss, champion, unique, or player. */
+ readonly isBossOrChampion?: boolean | undefined
+}
+
+/**
+ * 1. To-Hit Chance (Attack Rating vs Defense Rating).
+ *
+ * Canonical formula:
+ * Chance = clamp(5%, 95%, 100% * (2 * AR / (AR + EffectiveDR)) * (AttackerLevel / (AttackerLevel + DefenderLevel)))
+ *
+ * Modifiers:
+ * - Running: Effective DR = 0.
+ * - ITD: Effective DR = 0 if defender is normal monster; fails on bosses/champions/players.
+ * - -% Target Defense: Effective on normal monsters; 50% effectiveness against bosses/champions.
+ *
+ * @param attackerAr - Attacker's Attack Rating (AR).
+ * @param defenderDr - Defender's Defense Rating (DR / AC).
+ * @param attackerLevel - Attacker's level.
+ * @param defenderLevel - Defender's level.
+ * @param options - Defensive reduction and posture options.
+ * @returns To-hit probability in percentage [5, 95].
+ */
+export function calculateHitChance(
+ attackerAr: number,
+ defenderDr: number,
+ attackerLevel: number,
+ defenderLevel: number,
+ options: HitChanceOptions = {},
+): number {
+ const ar = Math.max(0, attackerAr)
+ let dr = Math.max(0, defenderDr)
+ const attLvl = Math.max(1, attackerLevel)
+ const defLvl = Math.max(1, defenderLevel)
+
+ if (options.isTargetRunning) {
+ dr = 0
+ } else if (options.ignoreTargetDefense && !options.isBossOrChampion) {
+ dr = 0
+ } else if (options.targetDefenseReductionPercent && options.targetDefenseReductionPercent > 0) {
+ const factor = options.isBossOrChampion
+ ? options.targetDefenseReductionPercent / 2
+ : options.targetDefenseReductionPercent
+ dr = Math.max(0, dr * (1 - factor / 100))
+ }
+
+ if (ar + dr <= 0) return 5
+
+ const arFactor = (2 * ar) / (ar + dr)
+ const lvlFactor = attLvl / (attLvl + defLvl)
+ const rawChance = 100 * arFactor * lvlFactor
+
+ return Math.max(5, Math.min(95, Math.round(rawChance * 100) / 100))
+}
+
+/**
+ * 2. Player Shield Blocking Chance.
+ *
+ * Canonical formula:
+ * Total Block % = min(75%, floor(BaseBlock * (Dexterity - 15) / (2 * CharacterLevel)))
+ *
+ * When running, block chance is reduced to 1/3 and capped at 25%.
+ *
+ * @param baseBlock - Shield's base block chance + item bonuses.
+ * @param dexterity - Character's total Dexterity.
+ * @param characterLevel - Character level (1–99).
+ * @param isRunning - Whether the character is currently running.
+ * @returns Block chance in percentage [0, 75] (or [0, 25] if running).
+ */
+export function calculatePlayerBlockChance(
+ baseBlock: number,
+ dexterity: number,
+ characterLevel: number,
+ isRunning = false,
+): number {
+ if (baseBlock <= 0 || dexterity <= 15 || characterLevel <= 0) return 0
+ const rawBlock = Math.floor((baseBlock * (dexterity - 15)) / (2 * characterLevel))
+ if (isRunning) {
+ return Math.max(0, Math.min(25, Math.floor(rawBlock / 3)))
+ }
+ return Math.max(0, Math.min(75, rawBlock))
+}
+
+/**
+ * 3. Combined Critical Strike / Deadly Strike chance.
+ *
+ * In D2:
+ * Critical Strike (CS) and Deadly Strike (DS) each double physical damage,
+ * but cannot proc simultaneously to quadruple damage.
+ * Combined probability:
+ * P = CS + DS * (1 - CS)
+ *
+ * @param criticalStrikePercent - Critical Strike % from skills [0, 100].
+ * @param deadlyStrikePercent - Deadly Strike % from equipment [0, 100].
+ * @returns Combined double-damage probability % [0, 100].
+ */
+export function calculateCriticalChance(
+ criticalStrikePercent: number,
+ deadlyStrikePercent: number,
+): number {
+ const cs = Math.max(0, Math.min(100, criticalStrikePercent)) / 100
+ const ds = Math.max(0, Math.min(100, deadlyStrikePercent)) / 100
+ const combined = cs + ds * (1 - cs)
+ return Math.round(combined * 10000) / 100
+}
+
+/**
+ * Detailed outcome of sequential CS/DS evaluation.
+ */
+export interface CriticalEvaluationResult {
+ readonly isCritical: boolean
+ readonly triggeredBy: 'none' | 'critical_strike' | 'deadly_strike'
+ readonly csRoll: number
+ readonly dsRoll: number
+ readonly combinedChancePercent: number
+}
+
+/**
+ * Evaluates Critical Strike (skill) and Deadly Strike (item) in strict canonical sequence:
+ * Roll CS first; if it fails, roll DS. If either procs, physical damage is doubled.
+ *
+ * @param criticalStrikePercent - Critical Strike % from skills.
+ * @param deadlyStrikePercent - Deadly Strike % from equipment.
+ * @param randomFn - Random generator returning [0, 1).
+ */
+export function evaluateCriticalAndDeadlyStrike(
+ criticalStrikePercent: number,
+ deadlyStrikePercent: number,
+ randomFn: () => number = Math.random,
+): CriticalEvaluationResult {
+ const cs = Math.max(0, Math.min(100, criticalStrikePercent))
+ const ds = Math.max(0, Math.min(100, deadlyStrikePercent))
+ const combinedChancePercent = calculateCriticalChance(cs, ds)
+
+ const csRoll = Math.round(randomFn() * 10000) / 100
+ if (cs > 0 && csRoll < cs) {
+ return {
+ isCritical: true,
+ triggeredBy: 'critical_strike',
+ csRoll,
+ dsRoll: 0,
+ combinedChancePercent,
+ }
+ }
+
+ const dsRoll = Math.round(randomFn() * 10000) / 100
+ if (ds > 0 && dsRoll < ds) {
+ return {
+ isCritical: true,
+ triggeredBy: 'deadly_strike',
+ csRoll,
+ dsRoll,
+ combinedChancePercent,
+ }
+ }
+
+ return {
+ isCritical: false,
+ triggeredBy: 'none',
+ csRoll,
+ dsRoll,
+ combinedChancePercent,
+ }
+}
+
+/**
+ * 4. Crushing Blow Damage.
+ *
+ * Base ratio of current HP truncated:
+ * - Normal Monster: Melee 25% (1/4), Ranged 12.5% (1/8).
+ * - Boss / Champion / Super Unique: Melee 12.5% (1/8), Ranged 6.25% (1/16).
+ * - Player / Mercenary: Melee 10% (1/10), Ranged 5% (1/20).
+ *
+ * Physical Resistance (DR%) Interaction:
+ * - If Physical Resistance >= 100% (Physical Immune): CB deals 0 damage.
+ * - If Physical Resistance is in [0%, 99%]: CB damage is reduced by DR%.
+ * - If Physical Resistance < 0%: CB damage is NOT amplified (factor capped at 1.0).
+ * - Flat DR does NOT reduce CB.
+ *
+ * @param currentHp - Target's current HP before attack.
+ * @param targetTierOrBoss - Target classification ('normal', 'boss', 'player', or boolean for backwards-compatibility).
+ * @param isRanged - Whether the attack is ranged.
+ * @param targetPhysicalResistance - Target's physical resistance % (DR%).
+ * @returns Final Crushing Blow damage applied to HP.
+ */
+export function calculateCrushingBlowDamage(
+ currentHp: number,
+ targetTierOrBoss: boolean | TargetTier = false,
+ isRanged = false,
+ targetPhysicalResistance = 0,
+): number {
+ if (currentHp <= 0) return 0
+ if (targetPhysicalResistance >= 100) return 0
+
+ let ratio = 0.25
+ const isPlayer = targetTierOrBoss === 'player'
+ const isBoss = targetTierOrBoss === true || targetTierOrBoss === 'boss'
+
+ if (isPlayer) {
+ ratio = isRanged ? 0.05 : 0.10
+ } else if (isBoss) {
+ ratio = isRanged ? 0.0625 : 0.125
+ } else {
+ ratio = isRanged ? 0.125 : 0.25
+ }
+
+ const baseDmg = Math.floor(currentHp * ratio)
+ const resFactor = targetPhysicalResistance > 0
+ ? Math.max(0, (100 - targetPhysicalResistance) / 100)
+ : 1.0
+
+ return Math.floor(baseDmg * resFactor)
+}
+
+/**
+ * 5. Open Wounds (OW) Damage.
+ *
+ * Canonical 1.10+ formula:
+ * Lasts 200 frames (8.0 seconds at 25 fps).
+ * Life drain per frame in 1/256th point units based on attacker level (Clvl):
+ * Clvl 1–15: (9 * Clvl + 31)
+ * Clvl 16–30: (18 * Clvl - 104)
+ * Clvl 31–45: (27 * Clvl - 374)
+ * Clvl 46–60: (36 * Clvl - 779)
+ * Clvl >60: (45 * Clvl - 1319)
+ *
+ * Modifiers:
+ * - Boss / Champion / Super Unique: 1/2 (x0.5) for both melee and ranged.
+ * - Player: 1/4 (x0.25) for melee, 1/8 (x0.125) for ranged.
+ * - Normal monster: unmodified (x1.0).
+ *
+ * Crucial property: Disables natural monster life regeneration while active.
+ */
+export interface OpenWoundsDamageResult {
+ readonly attackerLevel: number
+ readonly lifeDrainPerFrame: number // in 1/256th point units
+ readonly damagePerFrame: number // actual HP per frame
+ readonly damagePerSecond: number // 25 fps
+ readonly totalDamage200Frames: number
+ readonly durationFrames: number // 200
+ readonly durationSeconds: number // 8.0
+ readonly modifierFactor: number
+ readonly isBoss: boolean
+ readonly isPlayer: boolean
+}
+
+export function calculateOpenWoundsDamage(
+ attackerLevel: number,
+ targetTierOrBoss: boolean | TargetTier = 'normal',
+ isRanged = false,
+): OpenWoundsDamageResult {
+ const lvl = Math.max(1, Math.min(99, Math.floor(attackerLevel)))
+
+ let basePerFrame = 0
+ if (lvl <= 15) {
+ basePerFrame = 9 * lvl + 31
+ } else if (lvl <= 30) {
+ basePerFrame = 18 * lvl - 104
+ } else if (lvl <= 45) {
+ basePerFrame = 27 * lvl - 374
+ } else if (lvl <= 60) {
+ basePerFrame = 36 * lvl - 779
+ } else {
+ basePerFrame = 45 * lvl - 1319
+ }
+
+ let modifierFactor = 1.0
+ const isBoss = targetTierOrBoss === true || targetTierOrBoss === 'boss'
+ const isPlayer = targetTierOrBoss === 'player'
+
+ if (isBoss) {
+ modifierFactor = 0.5
+ } else if (isPlayer) {
+ modifierFactor = isRanged ? 0.125 : 0.25
+ }
+
+ const damagePerFrame = (basePerFrame * modifierFactor) / 256
+ const damagePerSecond = Math.round(damagePerFrame * 25 * 100) / 100
+ const totalDamage200Frames = Math.floor(damagePerFrame * 200)
+
+ return {
+ attackerLevel: lvl,
+ lifeDrainPerFrame: basePerFrame,
+ damagePerFrame,
+ damagePerSecond,
+ totalDamage200Frames,
+ durationFrames: 200,
+ durationSeconds: 8.0,
+ modifierFactor,
+ isBoss,
+ isPlayer,
+ }
+}
+
+/**
+ * 6. Life & Mana Steal (Leech).
+ *
+ * Rules:
+ * - Only applies to the final physical damage dealt to the target.
+ * - Multiplied by target's Drain Effectiveness (from MonStats.txt, 0% to 100%).
+ * - Multiplied by game difficulty penalty:
+ * Normal: 100% (x1.0)
+ * Nightmare: 50% (x0.5)
+ * Hell: 33.3% (x1/3)
+ */
+export interface LeechResult {
+ readonly lifeLeeched: number
+ readonly manaLeeched: number
+ readonly difficultyPenaltyFactor: number
+ readonly drainEffectivenessFactor: number
+ readonly rawLifeAttempted: number
+ readonly rawManaAttempted: number
+}
+
+export function calculateLeech(
+ finalPhysicalDamage: number,
+ lifeLeechPercent: number,
+ manaLeechPercent: number,
+ drainEffectivenessPercent = 100,
+ difficulty: GameDifficulty = 'normal',
+): LeechResult {
+ if (finalPhysicalDamage <= 0) {
+ return {
+ lifeLeeched: 0,
+ manaLeeched: 0,
+ difficultyPenaltyFactor: 1.0,
+ drainEffectivenessFactor: Math.max(0, drainEffectivenessPercent) / 100,
+ rawLifeAttempted: 0,
+ rawManaAttempted: 0,
+ }
+ }
+
+ let diffPenalty = 1.0
+ if (difficulty === 'nightmare') {
+ diffPenalty = 0.5
+ } else if (difficulty === 'hell') {
+ diffPenalty = 1 / 3
+ }
+
+ const drainFactor = Math.max(0, drainEffectivenessPercent) / 100
+ const rawLife = finalPhysicalDamage * (Math.max(0, lifeLeechPercent) / 100)
+ const rawMana = finalPhysicalDamage * (Math.max(0, manaLeechPercent) / 100)
+
+ const lifeLeeched = Math.floor(rawLife * drainFactor * diffPenalty)
+ const manaLeeched = Math.floor(rawMana * drainFactor * diffPenalty)
+
+ return {
+ lifeLeeched,
+ manaLeeched,
+ difficultyPenaltyFactor: diffPenalty,
+ drainEffectivenessFactor: drainFactor,
+ rawLifeAttempted: Math.round(rawLife * 10) / 10,
+ rawManaAttempted: Math.round(rawMana * 10) / 10,
+ }
+}
+
+/**
+ * 7. Freezes Target +X calculation.
+ *
+ * Canonical formulas:
+ * Melee: Chance = clamp(0, 100, 5 * (4 * B + AttackerLevel - DefenderLevel) + 30)
+ * Ranged: Chance = clamp(0, 100, 5 * (4 * B + (AttackerLevel - 6) - DefenderLevel) / 3)
+ *
+ * Target rules:
+ * - Bosses, Champions, Super Uniques and Players cannot be frozen; they are chilled instead.
+ * - Duration = (Chance - Roll) * 2 + 25 frames [clamped 25, 250 frames].
+ * - Duration is halved in Nightmare, quartered in Hell.
+ */
+export interface FreezeTargetResult {
+ readonly chancePercent: number
+ readonly roll: number
+ readonly willFreeze: boolean
+ readonly isChilledOnly: boolean
+ readonly durationFrames: number
+ readonly durationSeconds: number
+}
+
+export function calculateFreezeTarget(
+ freezeBonus: number,
+ attackerLevel: number,
+ defenderLevel: number,
+ targetTierOrBoss: boolean | TargetTier = 'normal',
+ isRanged = false,
+ difficulty: GameDifficulty = 'normal',
+ randomFn: () => number = Math.random,
+): FreezeTargetResult {
+ if (freezeBonus <= 0) {
+ return {
+ chancePercent: 0,
+ roll: 0,
+ willFreeze: false,
+ isChilledOnly: false,
+ durationFrames: 0,
+ durationSeconds: 0,
+ }
+ }
+
+ const B = freezeBonus
+ const al = attackerLevel
+ const dl = defenderLevel
+
+ let rawChance = 0
+ if (!isRanged) {
+ rawChance = 5 * (4 * B + al - dl) + 30
+ } else {
+ rawChance = (5 * (4 * B + (al - 6) - dl)) / 3
+ }
+
+ const chancePercent = Math.max(0, Math.min(100, Math.round(rawChance * 10) / 10))
+ const roll = Math.round(randomFn() * 10000) / 100
+ const success = roll < chancePercent
+
+ if (!success) {
+ return {
+ chancePercent,
+ roll,
+ willFreeze: false,
+ isChilledOnly: false,
+ durationFrames: 0,
+ durationSeconds: 0,
+ }
+ }
+
+ const isBossOrPlayer = targetTierOrBoss === true || targetTierOrBoss === 'boss' || targetTierOrBoss === 'player'
+ if (isBossOrPlayer) {
+ return {
+ chancePercent,
+ roll,
+ willFreeze: false,
+ isChilledOnly: true,
+ durationFrames: 75,
+ durationSeconds: 3.0,
+ }
+ }
+
+ let duration = Math.min(250, Math.max(25, Math.floor((chancePercent - roll) * 2 + 25)))
+ if (difficulty === 'nightmare') {
+ duration = Math.floor(duration / 2)
+ } else if (difficulty === 'hell') {
+ duration = Math.floor(duration / 4)
+ }
+
+ return {
+ chancePercent,
+ roll,
+ willFreeze: true,
+ isChilledOnly: false,
+ durationFrames: duration,
+ durationSeconds: Math.round((duration / 25) * 10) / 10,
+ }
+}
+
+/**
+ * 8. Resistance Damage Multiplier.
+ *
+ * In D2:
+ * - Resistance >= 100% means Immune (0 damage).
+ * - Resistance between 0% and 99% reduces damage: Damage * (1 - Res / 100).
+ * - Resistance < 0% amplifies damage: Damage * (1 + |Res| / 100), capped at -100% (2x damage).
+ *
+ * @param resistancePercent - Target's resistance percentage.
+ * @returns Multiplier factor (0 for immune, >1 for negative resistance) and immunity flag.
+ */
+export function calculateResistanceFactor(resistancePercent: number): {
+ readonly factor: number
+ readonly isImmune: boolean
+} {
+ if (resistancePercent >= 100) {
+ return { factor: 0, isImmune: true }
+ }
+ const clampedRes = Math.max(-100, resistancePercent)
+ const factor = (100 - clampedRes) / 100
+ return { factor, isImmune: false }
+}
+
+/**
+ * 8b. Resistance Reduction & 1/5th Rule for Breaking Immunities.
+ *
+ * Canonical Diablo II 1.10+ rules:
+ * - If target's initial resistance >= 100% (Immune):
+ * - Conviction Aura and Lower Resist Curse have their effectiveness reduced to 1/5th (20%).
+ * - In integer arithmetic: floor(Penalty / 5).
+ * - If (InitialRes - EffectiveConviction - EffectiveLowerRes) >= 100%:
+ * - Immunity is NOT broken.
+ * - Direct equipment reduction (-% Enemy Resistance, e.g. Rainbow Facet, Griffon's Eye) CANNOT apply!
+ * - Target remains immune (0 damage).
+ * - If (InitialRes - EffectiveConviction - EffectiveLowerRes) < 100%:
+ * - Immunity IS BROKEN!
+ * - Direct equipment reduction (-% Enemy Resistance) now applies at 100% FULL effectiveness!
+ * - If target's initial resistance < 100% (Non-Immune):
+ * - Conviction, Lower Resist, and equipment reduction all apply at 100% full strength.
+ * - Resistance is capped at a lower bound of -100% (2x damage multiplier).
+ */
+export interface ResistanceReductionResult {
+ readonly initialRes: number
+ readonly wasImmune: boolean
+ readonly isImmune: boolean
+ readonly immunityBroken: boolean
+ readonly convictionPenalty: number
+ readonly effectiveConviction: number
+ readonly lowerResPenalty: number
+ readonly effectiveLowerRes: number
+ readonly itemMinusRes: number
+ readonly effectiveItemMinusRes: number
+ readonly resAfterSkills: number
+ readonly finalRes: number
+ readonly damageMultiplier: number
+}
+
+export function calculateResistanceReduction(
+ enemyRes: number,
+ convictionPenalty = 0,
+ lowerResPenalty = 0,
+ itemMinusRes = 0,
+): ResistanceReductionResult {
+ const initialRes = enemyRes
+ const wasImmune = initialRes >= 100
+
+ let effectiveConviction = Math.max(0, convictionPenalty)
+ let effectiveLowerRes = Math.max(0, lowerResPenalty)
+
+ if (wasImmune) {
+ // 1/5th Rule: skills only have 20% effectiveness against immune monsters
+ effectiveConviction = Math.floor(effectiveConviction / 5)
+ effectiveLowerRes = Math.floor(effectiveLowerRes / 5)
+ }
+
+ const resAfterSkills = initialRes - (effectiveConviction + effectiveLowerRes)
+ const immunityBroken = wasImmune && resAfterSkills < 100
+
+ let effectiveItemMinusRes = 0
+ let isImmune = false
+ let finalRes = resAfterSkills
+
+ if (resAfterSkills >= 100) {
+ // Immunity persists: equipment -% enemy resistance fails completely
+ isImmune = true
+ effectiveItemMinusRes = 0
+ finalRes = resAfterSkills
+ } else {
+ // Non-immune or broken immunity: equipment -% resistance applies at 100% full effect
+ isImmune = false
+ effectiveItemMinusRes = Math.max(0, itemMinusRes)
+ finalRes = Math.max(-100, resAfterSkills - effectiveItemMinusRes)
+ }
+
+ const damageMultiplier = isImmune ? 0 : Math.round(((100 - finalRes) / 100) * 10000) / 10000
+
+ return {
+ initialRes,
+ wasImmune,
+ isImmune,
+ immunityBroken,
+ convictionPenalty: Math.max(0, convictionPenalty),
+ effectiveConviction,
+ lowerResPenalty: Math.max(0, lowerResPenalty),
+ effectiveLowerRes,
+ itemMinusRes: Math.max(0, itemMinusRes),
+ effectiveItemMinusRes,
+ resAfterSkills,
+ finalRes,
+ damageMultiplier,
+ }
+}
+
+/**
+ * 8c. Monster Life Regeneration & Prevent Monster Heal (PMH).
+ *
+ * Canonical D2 mechanics:
+ * - Natural regeneration per frame:
+ * Regen/frame = (MaxHP * RegenRate) / 4096
+ * (RegenRate is from MonStats.txt, typically 1 to 50).
+ * - At 25 fps:
+ * Regen/sec = (MaxHP * RegenRate * 25) / 4096
+ * - Natural regeneration is completely suppressed to 0 if:
+ * - Target is under Open Wounds (OW) bleeding (lasts 200 frames / 8 seconds).
+ * - Target is inflicted with Prevent Monster Heal (PMH) (lasts 120,000 frames / 80 minutes).
+ */
+export interface MonsterRegenResult {
+ readonly maxHp: number
+ readonly regenRate: number
+ readonly rawRegenPerFrame: number
+ readonly rawRegenPerSecond: number
+ readonly effectiveRegenPerFrame: number
+ readonly effectiveRegenPerSecond: number
+ readonly isBlockedByOW: boolean
+ readonly isBlockedByPMH: boolean
+}
+
+export function calculateMonsterRegeneration(
+ maxHp: number,
+ regenRate: number,
+ hasOW = false,
+ hasPMH = false,
+): MonsterRegenResult {
+ const hp = Math.max(0, maxHp)
+ const rate = Math.max(0, regenRate)
+
+ const rawRegenPerFrame = (hp * rate) / 4096
+ const rawRegenPerSecond = Math.round(rawRegenPerFrame * 25 * 100) / 100
+
+ const isBlocked = hasOW || hasPMH
+ const effectiveRegenPerFrame = isBlocked ? 0 : rawRegenPerFrame
+ const effectiveRegenPerSecond = isBlocked ? 0 : rawRegenPerSecond
+
+ return {
+ maxHp: hp,
+ regenRate: rate,
+ rawRegenPerFrame,
+ rawRegenPerSecond,
+ effectiveRegenPerFrame,
+ effectiveRegenPerSecond,
+ isBlockedByOW: hasOW,
+ isBlockedByPMH: hasPMH,
+ }
+}
+
+export interface PreventMonsterHealEvaluation {
+ readonly applied: boolean
+ readonly durationFrames: number // 120,000 frames = 4800s = 80min
+ readonly durationSeconds: number
+ readonly reason: string
+}
+
+export function evaluatePreventMonsterHeal(
+ attackerType: 'player' | 'mercenary' | 'minion' = 'player',
+ targetTierOrUber: 'normal' | 'champion' | 'boss' | 'uber' = 'normal',
+): PreventMonsterHealEvaluation {
+ if (attackerType !== 'player') {
+ return {
+ applied: false,
+ durationFrames: 0,
+ durationSeconds: 0,
+ reason: 'PMH 仅能由玩家本体近战或远程攻击命中触发 (雇佣兵与仆从佩戴无效)',
+ }
+ }
+
+ if (targetTierOrUber === 'uber') {
+ return {
+ applied: false,
+ durationFrames: 0,
+ durationSeconds: 0,
+ reason: '超级地狱火炬 Boss (Pandemonium Ubers) 免疫阻止怪物自愈 (PMH)',
+ }
+ }
+
+ return {
+ applied: true,
+ durationFrames: 120000,
+ durationSeconds: 4800,
+ reason: '阻止怪物自愈 (PMH) 命中生效,持续 120,000 帧 (80 分钟)',
+ }
+}
+
+/**
+ * 8d. Knockback vs Monster Size & Boss Immunity.
+ *
+ * Canonical D2 rules:
+ * - Bosses (Andariel, Duriel, Mephisto, Diablo, Baal, etc.) and Siege Beasts:
+ * 100% IMMUNE to knockback (0% chance).
+ * - Non-bosses with Knockback affix:
+ * - Melee: Small 100%, Medium 50%, Large 25%.
+ * - Ranged: Small 50%, Medium 25%, Large 12.5%.
+ */
+export type MonsterSize = 'small' | 'medium' | 'large'
+
+export interface KnockbackEvaluationResult {
+ readonly hasKnockbackAffix: boolean
+ readonly isBossOrImmune: boolean
+ readonly size: MonsterSize
+ readonly isRanged: boolean
+ readonly baseSizeChancePercent: number
+ readonly finalChancePercent: number
+ readonly roll: number
+ readonly triggered: boolean
+ readonly reason: string
+}
+
+export function calculateKnockbackSuccess(
+ hasKnockback: boolean,
+ defenderSize: MonsterSize = 'medium',
+ isBossOrImmune = false,
+ isRanged = false,
+ randomFn: () => number = Math.random,
+): KnockbackEvaluationResult {
+ if (!hasKnockback) {
+ return {
+ hasKnockbackAffix: false,
+ isBossOrImmune,
+ size: defenderSize,
+ isRanged,
+ baseSizeChancePercent: 0,
+ finalChancePercent: 0,
+ roll: 0,
+ triggered: false,
+ reason: '攻击未附带击退词缀',
+ }
+ }
+
+ if (isBossOrImmune) {
+ return {
+ hasKnockbackAffix: true,
+ isBossOrImmune: true,
+ size: defenderSize,
+ isRanged,
+ baseSizeChancePercent: 0,
+ finalChancePercent: 0,
+ roll: 0,
+ triggered: false,
+ reason: '关底首领 (Boss) 与攻城巨兽完全豁免击退效果',
+ }
+ }
+
+ let baseChance = 50
+ if (defenderSize === 'small') {
+ baseChance = isRanged ? 50 : 100
+ } else if (defenderSize === 'medium') {
+ baseChance = isRanged ? 25 : 50
+ } else {
+ baseChance = isRanged ? 12.5 : 25
+ }
+
+ const roll = Math.round(randomFn() * 10000) / 100
+ const triggered = roll < baseChance
+
+ return {
+ hasKnockbackAffix: true,
+ isBossOrImmune: false,
+ size: defenderSize,
+ isRanged,
+ baseSizeChancePercent: baseChance,
+ finalChancePercent: baseChance,
+ roll,
+ triggered,
+ reason: triggered
+ ? `体型 ${defenderSize} (${baseChance}%几率) 击退成功 (掷骰 ${roll.toFixed(1)}%)`
+ : `体型 ${defenderSize} (${baseChance}%几率) 掷骰未触发击退 (掷骰 ${roll.toFixed(1)}%)`,
+ }
+}
+
+/**
+ * 8e. Slows Target by X% (减速目标).
+ *
+ * Rules:
+ * - Normal monsters: capped at 90%.
+ * - Bosses / Champions / Super Uniques: capped at 50%.
+ * - Stacks with Holy Freeze / Cold chill up to caps.
+ */
+export interface SlowTargetResult {
+ readonly requestedSlowPercent: number
+ readonly isBoss: boolean
+ readonly maxCapPercent: number
+ readonly effectiveSlowPercent: number
+ readonly reason: string
+}
+
+export function calculateSlowTarget(
+ requestedSlowPercent: number,
+ isBoss = false,
+): SlowTargetResult {
+ const req = Math.max(0, requestedSlowPercent)
+ const maxCapPercent = isBoss ? 50 : 90
+ const effectiveSlowPercent = Math.min(maxCapPercent, req)
+
+ const reason = isBoss && req > 50
+ ? `首领/精英减速上限封顶为 50% (原请求 ${req}%)`
+ : `减速目标生效: ${effectiveSlowPercent}%`
+
+ return {
+ requestedSlowPercent: req,
+ isBoss,
+ maxCapPercent,
+ effectiveSlowPercent,
+ reason,
+ }
+}
+
+/**
+ * 8f. Crowd Control: Hit Blinds Target (HBT) & Hit Causes Monster to Flee (HCMTF).
+ *
+ * Hit Blinds Target:
+ * - Melee: Chance = clamp(1, 100, 50 + (AttackerLevel + Bonus * 4 - TargetLevel) * 2)
+ * - Ranged: Chance = clamp(1, 100, (50 + (AttackerLevel + Bonus * 4 - TargetLevel) * 2) / 3)
+ * - Bosses / Champions / Uniques are IMMUNE.
+ * - Duration: (Bonus * 4 + 4) seconds.
+ *
+ * Hit Causes Monster to Flee:
+ * - Fixed 125 frames (5.0 seconds).
+ * - Bosses and Champions are IMMUNE.
+ */
+export interface HitBlindsTargetResult {
+ readonly bonus: number
+ readonly attackerLevel: number
+ readonly targetLevel: number
+ readonly isBossOrChampion: boolean
+ readonly isRanged: boolean
+ readonly chancePercent: number
+ readonly roll: number
+ readonly triggered: boolean
+ readonly durationFrames: number
+ readonly durationSeconds: number
+ readonly reason: string
+}
+
+export function calculateHitBlindsTarget(
+ bonus: number,
+ attackerLevel: number,
+ targetLevel: number,
+ isBossOrChampion = false,
+ isRanged = false,
+ randomFn: () => number = Math.random,
+): HitBlindsTargetResult {
+ if (bonus <= 0) {
+ return {
+ bonus: 0,
+ attackerLevel,
+ targetLevel,
+ isBossOrChampion,
+ isRanged,
+ chancePercent: 0,
+ roll: 0,
+ triggered: false,
+ durationFrames: 0,
+ durationSeconds: 0,
+ reason: '无致盲词缀 (Hit Blinds Target)',
+ }
+ }
+
+ if (isBossOrChampion) {
+ return {
+ bonus,
+ attackerLevel,
+ targetLevel,
+ isBossOrChampion: true,
+ isRanged,
+ chancePercent: 0,
+ roll: 0,
+ triggered: false,
+ durationFrames: 0,
+ durationSeconds: 0,
+ reason: '首领、暗金怪与冠军怪免疫致盲效果',
+ }
+ }
+
+ const rawMeleeChance = 50 + (attackerLevel + bonus * 4 - targetLevel) * 2
+ const rawChance = isRanged ? rawMeleeChance / 3 : rawMeleeChance
+ const chancePercent = Math.max(1, Math.min(100, Math.round(rawChance * 10) / 10))
+
+ const roll = Math.round(randomFn() * 10000) / 100
+ const triggered = roll < chancePercent
+
+ const durationSeconds = bonus * 4 + 4
+ const durationFrames = durationSeconds * 25
+
+ return {
+ bonus,
+ attackerLevel,
+ targetLevel,
+ isBossOrChampion: false,
+ isRanged,
+ chancePercent,
+ roll,
+ triggered,
+ durationFrames: triggered ? durationFrames : 0,
+ durationSeconds: triggered ? durationSeconds : 0,
+ reason: triggered
+ ? `致盲命中!使目标盲目 (微暗灵视) 持续 ${durationSeconds} 秒 (${chancePercent}%几率)`
+ : `致盲未命中 (${chancePercent}%几率, 掷骰 ${roll.toFixed(1)}%)`,
+ }
+}
+
+export interface HitCausesFleeResult {
+ readonly fleeChancePercent: number
+ readonly isBossOrChampion: boolean
+ readonly roll: number
+ readonly triggered: boolean
+ readonly durationFrames: number
+ readonly durationSeconds: number
+ readonly reason: string
+}
+
+export function calculateHitCausesFlee(
+ fleeChancePercent: number,
+ isBossOrChampion = false,
+ randomFn: () => number = Math.random,
+): HitCausesFleeResult {
+ const chance = Math.max(0, Math.min(100, fleeChancePercent))
+ if (chance <= 0) {
+ return {
+ fleeChancePercent: 0,
+ isBossOrChampion,
+ roll: 0,
+ triggered: false,
+ durationFrames: 0,
+ durationSeconds: 0,
+ reason: '无恐吓逃跑词缀',
+ }
+ }
+
+ if (isBossOrChampion) {
+ return {
+ fleeChancePercent: chance,
+ isBossOrChampion: true,
+ roll: 0,
+ triggered: false,
+ durationFrames: 0,
+ durationSeconds: 0,
+ reason: '首领与冠军怪物免疫恐吓逃跑效果',
+ }
+ }
+
+ const roll = Math.round(randomFn() * 10000) / 100
+ const triggered = roll < chance
+ const durationFrames = triggered ? 125 : 0
+ const durationSeconds = triggered ? 5.0 : 0
+
+ return {
+ fleeChancePercent: chance,
+ isBossOrChampion: false,
+ roll,
+ triggered,
+ durationFrames,
+ durationSeconds,
+ reason: triggered
+ ? `恐吓逃跑生效!怪物陷入恐惧逃窜 5 秒 (几率 ${chance}%)`
+ : `恐吓未触发 (几率 ${chance}%, 掷骰 ${roll.toFixed(1)}%)`,
+ }
+}
+
+/**
+ * 8g. Physical Damage Decomposition (Off-Weapon ED & Flat +Damage / Grief).
+ *
+ * Canonical Formula:
+ * BaseMin = floor(WeaponBaseMin * (1 + OnWeaponED / 100)) + FlatDamage
+ * BaseMax = floor(WeaponBaseMax * (1 + OnWeaponED / 100)) + FlatDamage
+ * TotalPhysMin = floor(BaseMin * (1 + (OffWeaponED + StatBonus) / 100))
+ * TotalPhysMax = floor(BaseMax * (1 + (OffWeaponED + StatBonus) / 100))
+ */
+export interface PhysicalDamageComponents {
+ readonly weaponBaseMin: number
+ readonly weaponBaseMax: number
+ readonly onWeaponEdPercent?: number | undefined
+ readonly flatDamage?: number | undefined
+ readonly offWeaponEdPercent?: number | undefined
+ readonly statBonusPercent?: number | undefined
+}
+
+export interface PhysicalDamageRangeResult {
+ readonly weaponBaseMin: number
+ readonly weaponBaseMax: number
+ readonly onWeaponEdPercent: number
+ readonly baseMinWithWeaponEd: number
+ readonly baseMaxWithWeaponEd: number
+ readonly flatDamage: number
+ readonly baseMinWithFlat: number
+ readonly baseMaxWithFlat: number
+ readonly offWeaponEdPercent: number
+ readonly statBonusPercent: number
+ readonly totalMultiplier: number
+ readonly finalMinDamage: number
+ readonly finalMaxDamage: number
+}
+
+export function calculatePhysicalWeaponDamage(
+ components: PhysicalDamageComponents,
+): PhysicalDamageRangeResult {
+ const wMin = Math.max(0, components.weaponBaseMin)
+ const wMax = Math.max(wMin, components.weaponBaseMax)
+ const onEd = Math.max(0, components.onWeaponEdPercent ?? 0)
+ const flat = Math.max(0, components.flatDamage ?? 0)
+ const offEd = Math.max(0, components.offWeaponEdPercent ?? 0)
+ const statBonus = Math.max(0, components.statBonusPercent ?? 0)
+
+ const baseMinWithWeaponEd = Math.floor(wMin * (1 + onEd / 100))
+ const baseMaxWithWeaponEd = Math.floor(wMax * (1 + onEd / 100))
+
+ const baseMinWithFlat = baseMinWithWeaponEd + flat
+ const baseMaxWithFlat = baseMaxWithWeaponEd + flat
+
+ const totalMultiplier = 1 + (offEd + statBonus) / 100
+ const finalMinDamage = Math.floor(baseMinWithFlat * totalMultiplier)
+ const finalMaxDamage = Math.floor(baseMaxWithFlat * totalMultiplier)
+
+ return {
+ weaponBaseMin: wMin,
+ weaponBaseMax: wMax,
+ onWeaponEdPercent: onEd,
+ baseMinWithWeaponEd,
+ baseMaxWithWeaponEd,
+ flatDamage: flat,
+ baseMinWithFlat,
+ baseMaxWithFlat,
+ offWeaponEdPercent: offEd,
+ statBonusPercent: statBonus,
+ totalMultiplier: Math.round(totalMultiplier * 1000) / 1000,
+ finalMinDamage,
+ finalMaxDamage,
+ }
+}
+
+/**
+ * 8h. Poison Bit Rate & Duration Averaging Stacking.
+ *
+ * In D2:
+ * - Poison rate is in 1/256th damage per frame.
+ * - Stacking across items:
+ * TotalBitRate = sum(BitRates)
+ * AvgDuration = sum(Durations) / N
+ * TotalDamage = floor((TotalBitRate * AvgDuration) / 256)
+ */
+export interface PoisonSource {
+ readonly damage: number
+ readonly durationSeconds?: number | undefined
+ readonly durationFrames?: number | undefined
+}
+
+export interface PoisonStackResult {
+ readonly totalBitRate: number
+ readonly averageDurationFrames: number
+ readonly averageDurationSeconds: number
+ readonly totalPoisonDamage: number
+ readonly damagePerSecond: number
+ readonly sourcesCount: number
+}
+
+export function calculatePoisonStack(sources: readonly PoisonSource[]): PoisonStackResult {
+ if (sources.length === 0) {
+ return {
+ totalBitRate: 0,
+ averageDurationFrames: 0,
+ averageDurationSeconds: 0,
+ totalPoisonDamage: 0,
+ damagePerSecond: 0,
+ sourcesCount: 0,
+ }
+ }
+
+ let sumBitRate = 0
+ let sumFrames = 0
+ let validCount = 0
+
+ for (const src of sources) {
+ const frames = src.durationFrames ?? ((src.durationSeconds ?? 0) * 25)
+ if (frames > 0 && src.damage > 0) {
+ const bitRate = (src.damage * 256) / frames
+ sumBitRate += bitRate
+ sumFrames += frames
+ validCount += 1
+ }
+ }
+
+ if (validCount === 0) {
+ return {
+ totalBitRate: 0,
+ averageDurationFrames: 0,
+ averageDurationSeconds: 0,
+ totalPoisonDamage: 0,
+ damagePerSecond: 0,
+ sourcesCount: 0,
+ }
+ }
+
+ const averageDurationFrames = Math.round(sumFrames / validCount)
+ const averageDurationSeconds = Math.round((averageDurationFrames / 25) * 10) / 10
+ const totalPoisonDamage = Math.floor((sumBitRate * averageDurationFrames) / 256)
+ const damagePerSecond = Math.round(((sumBitRate * 25) / 256) * 100) / 100
+
+ return {
+ totalBitRate: Math.round(sumBitRate),
+ averageDurationFrames,
+ averageDurationSeconds,
+ totalPoisonDamage,
+ damagePerSecond,
+ sourcesCount: validCount,
+ }
+}
+
+/**
+ * 9. Hit Recovery / Stun Check.
+ *
+ * In D2:
+ * When a hit inflicts damage > MaxHP / 16:
+ * - Damage >= MaxHP / 4: 100% chance to enter hit recovery.
+ * - Damage >= MaxHP / 8: 50% chance to enter hit recovery.
+ * - Damage >= MaxHP / 16: 25% chance to enter hit recovery.
+ * - Damage < MaxHP / 16: 0% chance.
+ *
+ * @param damageDealt - Net damage received.
+ * @param maxHp - Defender's maximum HP.
+ * @param roll0to1 - Random float in [0, 1) (defaults to Math.random()).
+ * @returns Whether hit recovery is triggered, and the trigger chance.
+ */
+export function checkHitRecovery(
+ damageDealt: number,
+ maxHp: number,
+ roll0to1 = Math.random(),
+): { readonly triggered: boolean; readonly chancePercent: number } {
+ if (maxHp <= 0 || damageDealt <= 0) {
+ return { triggered: false, chancePercent: 0 }
+ }
+ const ratio = damageDealt / maxHp
+ let chancePercent = 0
+ if (ratio >= 0.25) {
+ chancePercent = 100
+ } else if (ratio >= 0.125) {
+ chancePercent = 50
+ } else if (ratio >= 0.0625) {
+ chancePercent = 25
+ }
+
+ const triggered = chancePercent > 0 && roll0to1 * 100 < chancePercent
+ return { triggered, chancePercent }
+}
+
+/**
+ * Elemental Damage specification for an attack.
+ */
+export interface AttackDamageSpec {
+ /** Min physical damage rolled from weapon/skill/strength. */
+ readonly physicalMin: number
+ /** Max physical damage. */
+ readonly physicalMax: number
+ /** Fire damage. */
+ readonly fire?: number
+ /** Lightning damage. */
+ readonly lightning?: number
+ /** Cold damage (also chills). */
+ readonly cold?: number
+ /** Cold chill duration in frames/seconds. */
+ readonly coldChillDuration?: number
+ /** Poison damage (dealt over duration). */
+ readonly poison?: number
+ /** Magic damage (e.g. Berserk, Bone Spear). */
+ readonly magic?: number
+}
+
+/**
+ * Target's defensive specification.
+ */
+export interface DefenderDefenseSpec {
+ /** Target Level. */
+ readonly level: number
+ /** Defense Rating (DR / AC). */
+ readonly defense: number
+ /** Block chance % [0, 75]. */
+ readonly blockPercent: number
+ /** Flat physical damage reduced (DR). */
+ readonly flatDamageReduction?: number | undefined
+ /** Flat magic damage reduced (MDR). */
+ readonly flatMagicReduction?: number | undefined
+ /** Physical resistance % (also known as %DR). */
+ readonly resPhysical?: number | undefined
+ /** Fire resistance %. */
+ readonly resFire?: number | undefined
+ /** Lightning resistance %. */
+ readonly resLightning?: number | undefined
+ /** Cold resistance %. */
+ readonly resCold?: number | undefined
+ /** Poison resistance %. */
+ readonly resPoison?: number | undefined
+ /** Magic resistance %. */
+ readonly resMagic?: number | undefined
+ /** Current HP (for crushing blow). */
+ readonly currentHp: number
+ /** Max HP (for hit recovery check). */
+ readonly maxHp: number
+ /** Target classification tier. */
+ readonly tier?: TargetTier | undefined
+ /** Monster physical size ('small', 'medium', 'large'). */
+ readonly size?: MonsterSize | undefined
+ /** Natural life regeneration rate from MonStats.txt. */
+ readonly regenRate?: number | undefined
+ /** Whether target is a Pandemonium Uber (immune to PMH). */
+ readonly isUber?: boolean | undefined
+ /** Whether the defender is a boss or champion (backwards-compatibility). */
+ readonly isBoss?: boolean | undefined
+ /** Whether defender is currently running (DR=0 and 1/3 block cap 25%). */
+ readonly isRunning?: boolean | undefined
+ /** Drain effectiveness % (0–100%, 0% for skeletons/hell bosses). */
+ readonly drainEffectiveness?: number | undefined
+}
+
+/**
+ * Additional combat calculation options.
+ */
+export interface CombatOptions {
+ readonly criticalStrikePercent?: number | undefined
+ readonly deadlyStrikePercent?: number | undefined
+ readonly crushingBlowPercent?: number | undefined
+ readonly openWoundsPercent?: number | undefined
+ readonly lifeLeechPercent?: number | undefined
+ readonly manaLeechPercent?: number | undefined
+ readonly ignoreTargetDefense?: boolean | undefined
+ readonly targetDefenseReductionPercent?: number | undefined
+ readonly freezeBonus?: number | undefined
+ readonly knockbackPercent?: number | undefined
+ readonly difficulty?: GameDifficulty | undefined
+ readonly isSpell?: boolean | undefined
+ readonly isRanged?: boolean | undefined
+ // PvE Depth Options (Issue #39)
+ readonly defenderSize?: MonsterSize | undefined
+ readonly convictionPenalty?: number | undefined
+ readonly lowerResPenalty?: number | undefined
+ readonly itemMinusPhysRes?: number | undefined
+ readonly itemMinusFireRes?: number | undefined
+ readonly itemMinusLightRes?: number | undefined
+ readonly itemMinusColdRes?: number | undefined
+ readonly itemMinusPoisonRes?: number | undefined
+ readonly slowPercent?: number | undefined
+ readonly hitBlindsTargetBonus?: number | undefined
+ readonly hitCausesFleePercent?: number | undefined
+ readonly preventMonsterHeal?: boolean | undefined
+ readonly attackerType?: 'player' | 'mercenary' | 'minion' | undefined
+}
+
+/**
+ * Full breakdown of an attack calculation for logging and inspection.
+ */
+export interface CombatCalculationResult {
+ readonly attackerAr: number
+ readonly defenderDr: number
+ readonly hitChancePercent: number
+ readonly hitRoll: number
+ readonly isHit: boolean
+ readonly isBlocked: boolean
+ readonly defenderBlockPercent: number
+ readonly isCritical: boolean
+ readonly criticalTrigger: 'none' | 'critical_strike' | 'deadly_strike'
+ readonly isCrushingBlow: boolean
+ readonly crushingBlowDamage: number
+ readonly isOpenWounds: boolean
+ readonly openWoundsResult?: OpenWoundsDamageResult | undefined
+ readonly lifeLeeched: number
+ readonly manaLeeched: number
+ readonly freezeResult?: FreezeTargetResult | undefined
+ readonly knockbackTriggered: boolean
+ readonly knockbackResult?: KnockbackEvaluationResult | undefined
+ readonly slowResult?: SlowTargetResult | undefined
+ readonly blindResult?: HitBlindsTargetResult | undefined
+ readonly fleeResult?: HitCausesFleeResult | undefined
+ readonly pmhResult?: PreventMonsterHealEvaluation | undefined
+ readonly monsterRegenResult?: MonsterRegenResult | undefined
+ readonly resistanceBreakResults?: Record | undefined
+ readonly rawPhysicalDamage: number
+ readonly finalPhysicalDamage: number
+ readonly finalFireDamage: number
+ readonly finalLightningDamage: number
+ readonly finalColdDamage: number
+ readonly finalPoisonDamage: number
+ readonly finalMagicDamage: number
+ readonly totalDamage: number
+ readonly immunities: readonly string[]
+ readonly hitRecoveryTriggered: boolean
+ readonly hitRecoveryChance: number
+ readonly log: readonly string[]
+}
+
+/**
+ * Execute a complete Diablo II attack calculation with full mathematical provenance.
+ *
+ * @param attackerLevel - Attacker level.
+ * @param attackerAr - Attacker Attack Rating (0 to bypass AR check, e.g. for spells).
+ * @param damageSpec - Attack damage components.
+ * @param defender - Defender attributes and defenses.
+ * @param options - Additional proc chances, modifiers, and difficulty.
+ * @param randomFn - Random generator function returning [0, 1).
+ * @returns Detailed combat result.
+ */
+export function calculateAttack(
+ attackerLevel: number,
+ attackerAr: number,
+ damageSpec: AttackDamageSpec,
+ defender: DefenderDefenseSpec,
+ options: CombatOptions = {},
+ randomFn: () => number = Math.random,
+): CombatCalculationResult {
+ const log: string[] = []
+ const immunities: string[] = []
+ const resistanceBreakResults: Record = {}
+
+ const isBossOrChampion = defender.isBoss ?? (defender.tier === 'boss')
+ const targetTier: TargetTier = defender.tier ?? (defender.isBoss ? 'boss' : 'normal')
+ const isRunning = defender.isRunning ?? false
+ const isSpell = options.isSpell ?? false
+ const isRanged = options.isRanged ?? false
+ const difficulty = options.difficulty ?? 'normal'
+
+ // 1. To-Hit Check
+ let isHit = true
+ let hitChancePercent = 100
+ const hitRoll = Math.round(randomFn() * 10000) / 100
+
+ if (!isSpell) {
+ hitChancePercent = calculateHitChance(
+ attackerAr,
+ defender.defense,
+ attackerLevel,
+ defender.level,
+ {
+ ignoreTargetDefense: options.ignoreTargetDefense,
+ targetDefenseReductionPercent: options.targetDefenseReductionPercent,
+ isTargetRunning: isRunning,
+ isBossOrChampion,
+ },
+ )
+ isHit = hitRoll < hitChancePercent
+
+ const itdNote = options.ignoreTargetDefense
+ ? isBossOrChampion
+ ? ' [ITD 对暗金/Boss无效]'
+ : ' [ITD 生效: 目标防御降为0]'
+ : ''
+ const defRedNote = (options.targetDefenseReductionPercent ?? 0) > 0
+ ? isBossOrChampion
+ ? ` [-%Def 衰减折半: -${(options.targetDefenseReductionPercent! / 2).toFixed(1)}%]`
+ : ` [-%Def 全额生效: -${options.targetDefenseReductionPercent}%]`
+ : ''
+ const runNote = isRunning ? ' [目标奔跑: 防御视为0]' : ''
+
+ log.push(
+ `[1. 命中率判定 (AR vs DR)]: 攻击者 AR=${attackerAr} (Lv${attackerLevel}) 对抗 防御者 DR=${defender.defense} (Lv${defender.level})` +
+ `${itdNote}${defRedNote}${runNote} => 最终命中率: ${hitChancePercent.toFixed(1)}% (范围 5%–95%)。掷骰: ${hitRoll.toFixed(1)}% => ${isHit ? '✓ 命中' : '✗ 未命中 (MISS)'}`,
+ )
+ } else {
+ log.push(`[1. 法术技能检查]: 法术攻击无视护甲与命中率 (100% 必定命中)`)
+ }
+
+ if (!isHit) {
+ return {
+ attackerAr,
+ defenderDr: defender.defense,
+ hitChancePercent,
+ hitRoll,
+ isHit: false,
+ isBlocked: false,
+ defenderBlockPercent: defender.blockPercent,
+ isCritical: false,
+ criticalTrigger: 'none',
+ isCrushingBlow: false,
+ crushingBlowDamage: 0,
+ isOpenWounds: false,
+ lifeLeeched: 0,
+ manaLeeched: 0,
+ knockbackTriggered: false,
+ rawPhysicalDamage: 0,
+ finalPhysicalDamage: 0,
+ finalFireDamage: 0,
+ finalLightningDamage: 0,
+ finalColdDamage: 0,
+ finalPoisonDamage: 0,
+ finalMagicDamage: 0,
+ totalDamage: 0,
+ immunities: [],
+ hitRecoveryTriggered: false,
+ hitRecoveryChance: 0,
+ log,
+ }
+ }
+
+ // 2. Shield Blocking Check
+ let isBlocked = false
+ let defenderBlock = defender.blockPercent
+ if (isRunning) {
+ defenderBlock = Math.max(0, Math.min(25, Math.floor(defenderBlock / 3)))
+ }
+
+ if (!isSpell && defenderBlock > 0) {
+ const blockRoll = Math.round(randomFn() * 10000) / 100
+ isBlocked = blockRoll < defenderBlock
+ log.push(
+ `[2. 格挡判定]: 防御者有效格挡率: ${defenderBlock}%${isRunning ? ' (奔跑削减至1/3且上限25%)' : ''},掷骰: ${blockRoll.toFixed(1)}% => ${isBlocked ? '🛡️ 格挡成功 (伤害完全抵消 0)' : '格挡失败'}`,
+ )
+ }
+
+ if (isBlocked) {
+ return {
+ attackerAr,
+ defenderDr: defender.defense,
+ hitChancePercent,
+ hitRoll,
+ isHit: true,
+ isBlocked: true,
+ defenderBlockPercent: defenderBlock,
+ isCritical: false,
+ criticalTrigger: 'none',
+ isCrushingBlow: false,
+ crushingBlowDamage: 0,
+ isOpenWounds: false,
+ lifeLeeched: 0,
+ manaLeeched: 0,
+ knockbackTriggered: false,
+ rawPhysicalDamage: 0,
+ finalPhysicalDamage: 0,
+ finalFireDamage: 0,
+ finalLightningDamage: 0,
+ finalColdDamage: 0,
+ finalPoisonDamage: 0,
+ finalMagicDamage: 0,
+ totalDamage: 0,
+ immunities: [],
+ hitRecoveryTriggered: false,
+ hitRecoveryChance: 0,
+ log,
+ }
+ }
+
+ // 3. Critical Strike (CS) & Deadly Strike (DS) Check
+ const critResult = evaluateCriticalAndDeadlyStrike(
+ options.criticalStrikePercent ?? 0,
+ options.deadlyStrikePercent ?? 0,
+ randomFn,
+ )
+ if (!isSpell && (options.criticalStrikePercent || options.deadlyStrikePercent)) {
+ if (critResult.triggeredBy === 'critical_strike') {
+ log.push(
+ `[3. 暴击判定 (CS/DS)]: 技能致命一击 (CS ${options.criticalStrikePercent}%) 命中掷骰 ${critResult.csRoll.toFixed(1)}% => ⚡ 触发致命一击 (物理伤害 x2)`,
+ )
+ } else if (critResult.triggeredBy === 'deadly_strike') {
+ log.push(
+ `[3. 暴击判定 (CS/DS)]: CS 未触发 (掷骰 ${critResult.csRoll.toFixed(1)}%) -> 装备双倍打击 (DS ${options.deadlyStrikePercent}%) 命中掷骰 ${critResult.dsRoll.toFixed(1)}% => ⚡ 触发双倍打击 (物理伤害 x2)`,
+ )
+ } else {
+ log.push(
+ `[3. 暴击判定 (CS/DS)]: 综合双倍几率 ${critResult.combinedChancePercent.toFixed(1)}%,掷骰未触发双倍打击`,
+ )
+ }
+ }
+
+ // 4. Crushing Blow (CB) Check
+ const cbChance = options.crushingBlowPercent ?? 0
+ const cbRoll = Math.round(randomFn() * 10000) / 100
+ const isCrushingBlow = !isSpell && cbChance > 0 && cbRoll < cbChance
+ let crushingBlowDamage = 0
+ if (isCrushingBlow) {
+ const physRes = defender.resPhysical ?? 0
+ crushingBlowDamage = calculateCrushingBlowDamage(
+ defender.currentHp,
+ targetTier,
+ isRanged,
+ physRes,
+ )
+ const resNote = physRes >= 100
+ ? ' [目标物理免疫: CB 伤害为 0]'
+ : physRes > 0
+ ? ` [受物抗 ${physRes}% 削减]`
+ : physRes < 0
+ ? ` [负物抗不增伤, 封顶100%]`
+ : ''
+ log.push(
+ `[4. 压碎性打击 (CB)]: 几率 ${cbChance}% 命中 (掷骰 ${cbRoll.toFixed(1)}%)!当前生命 ${defender.currentHp} 削减 ${crushingBlowDamage} HP${resNote}`,
+ )
+ }
+
+ // 5. Open Wounds (OW) Check
+ const owChance = options.openWoundsPercent ?? 0
+ const owRoll = Math.round(randomFn() * 10000) / 100
+ const isOpenWounds = !isSpell && owChance > 0 && owRoll < owChance
+ let openWoundsResult: OpenWoundsDamageResult | undefined
+ if (isOpenWounds) {
+ openWoundsResult = calculateOpenWoundsDamage(attackerLevel, targetTier, isRanged)
+ log.push(
+ `[5. 撕开伤口 (OW)]: 几率 ${owChance}% 命中 (掷骰 ${owRoll.toFixed(1)}%)!持续流血 200 帧 (8秒),` +
+ `每秒流失 ${openWoundsResult.damagePerSecond} HP (总计 ${openWoundsResult.totalDamage200Frames} HP),并禁止目标自愈!`,
+ )
+ }
+
+ // 6. Roll Raw Physical Damage
+ const pMin = damageSpec.physicalMin
+ const pMax = Math.max(pMin, damageSpec.physicalMax)
+ let rawPhysical = pMin + Math.round(randomFn() * (pMax - pMin))
+ if (critResult.isCritical) {
+ rawPhysical *= 2
+ }
+ // Note: CB damage is physical, added directly after critical doubling
+ rawPhysical += crushingBlowDamage
+
+ // 7. Apply Resistances & Reductions
+ // 7a. Physical: Flat DR -> %DR (Resistance with -% Enemy Phys Res)
+ const physBreak = calculateResistanceReduction(
+ defender.resPhysical ?? 0,
+ 0,
+ 0,
+ options.itemMinusPhysRes ?? 0,
+ )
+ resistanceBreakResults.physical = physBreak
+ if (physBreak.isImmune) immunities.push('Physical')
+ const flatDr = defender.flatDamageReduction ?? 0
+ let finalPhysicalDamage = Math.max(0, (rawPhysical - flatDr) * physBreak.damageMultiplier)
+ finalPhysicalDamage = Math.round(finalPhysicalDamage * 10) / 10
+ log.push(
+ `[6. 物理伤害结算]: 原始物理 ${rawPhysical} (基准 [${pMin}..${pMax}]${critResult.isCritical ? ' 暴击x2' : ''}${isCrushingBlow ? ' +CB' : ''}) ` +
+ `- 减伤(DR) ${flatDr} -> 物抗 ${physBreak.finalRes}% (因子 x${physBreak.damageMultiplier.toFixed(2)}${physBreak.isImmune ? ' 物理免疫' : ''}) => 最终物理伤害: ${finalPhysicalDamage}`,
+ )
+
+ // 7b. Fire (with Conviction & Lower Resist 1/5th rule + Equipment -% Fire Res)
+ const flatMdr = defender.flatMagicReduction ?? 0
+ let finalFire = 0
+ if ((damageSpec.fire ?? 0) > 0) {
+ const fireBreak = calculateResistanceReduction(
+ defender.resFire ?? 0,
+ options.convictionPenalty ?? 0,
+ options.lowerResPenalty ?? 0,
+ options.itemMinusFireRes ?? 0,
+ )
+ resistanceBreakResults.fire = fireBreak
+ if (fireBreak.isImmune) immunities.push('Fire')
+ finalFire = Math.max(0, Math.round((Math.max(0, damageSpec.fire! - flatMdr) * fireBreak.damageMultiplier) * 10) / 10)
+
+ const breakNote = fireBreak.immunityBroken
+ ? ` [⚡ 1/5法则破免成功! 原${fireBreak.initialRes}% -> 降为${fireBreak.finalRes}%]`
+ : fireBreak.wasImmune && fireBreak.isImmune
+ ? ` [🛡️ 减抗未能破免! 原${fireBreak.initialRes}% -> ${fireBreak.finalRes}%仍>=100%]`
+ : ` -> 火抗 ${fireBreak.finalRes}%`
+
+ log.push(
+ `[7. 火焰伤害]: 基础火伤 ${damageSpec.fire} - MDR ${flatMdr}${breakNote} (因子 x${fireBreak.damageMultiplier.toFixed(2)}${fireBreak.isImmune ? ' 免疫' : ''}) => 净火伤: ${finalFire}`,
+ )
+ }
+
+ // 7c. Lightning (with Conviction & Lower Resist 1/5th rule + Equipment -% Light Res)
+ let finalLightning = 0
+ if ((damageSpec.lightning ?? 0) > 0) {
+ const lightBreak = calculateResistanceReduction(
+ defender.resLightning ?? 0,
+ options.convictionPenalty ?? 0,
+ options.lowerResPenalty ?? 0,
+ options.itemMinusLightRes ?? 0,
+ )
+ resistanceBreakResults.lightning = lightBreak
+ if (lightBreak.isImmune) immunities.push('Lightning')
+ finalLightning = Math.max(0, Math.round((Math.max(0, damageSpec.lightning! - flatMdr) * lightBreak.damageMultiplier) * 10) / 10)
+
+ const breakNote = lightBreak.immunityBroken
+ ? ` [⚡ 1/5法则破免成功! 原${lightBreak.initialRes}% -> 降为${lightBreak.finalRes}%]`
+ : lightBreak.wasImmune && lightBreak.isImmune
+ ? ` [🛡️ 减抗未能破免! 原${lightBreak.initialRes}% -> ${lightBreak.finalRes}%仍>=100%]`
+ : ` -> 电抗 ${lightBreak.finalRes}%`
+
+ log.push(
+ `[8. 闪电伤害]: 基础电伤 ${damageSpec.lightning} - MDR ${flatMdr}${breakNote} (因子 x${lightBreak.damageMultiplier.toFixed(2)}${lightBreak.isImmune ? ' 免疫' : ''}) => 净电伤: ${finalLightning}`,
+ )
+ }
+
+ // 7d. Cold (with Conviction & Lower Resist 1/5th rule + Equipment -% Cold Res)
+ let finalCold = 0
+ if ((damageSpec.cold ?? 0) > 0) {
+ const coldBreak = calculateResistanceReduction(
+ defender.resCold ?? 0,
+ options.convictionPenalty ?? 0,
+ options.lowerResPenalty ?? 0,
+ options.itemMinusColdRes ?? 0,
+ )
+ resistanceBreakResults.cold = coldBreak
+ if (coldBreak.isImmune) immunities.push('Cold')
+ finalCold = Math.max(0, Math.round((Math.max(0, damageSpec.cold! - flatMdr) * coldBreak.damageMultiplier) * 10) / 10)
+
+ const breakNote = coldBreak.immunityBroken
+ ? ` [⚡ 1/5法则破免成功! 原${coldBreak.initialRes}% -> 降为${coldBreak.finalRes}%]`
+ : coldBreak.wasImmune && coldBreak.isImmune
+ ? ` [🛡️ 减抗未能破免! 原${coldBreak.initialRes}% -> ${coldBreak.finalRes}%仍>=100%]`
+ : ` -> 冰抗 ${coldBreak.finalRes}%`
+
+ log.push(
+ `[9. 冰冷伤害]: 基础冰伤 ${damageSpec.cold} - MDR ${flatMdr}${breakNote} (因子 x${coldBreak.damageMultiplier.toFixed(2)}${coldBreak.isImmune ? ' 免疫' : ''}) => 净冰伤: ${finalCold}`,
+ )
+ }
+
+ // 7e. Poison (Conviction does NOT affect poison; Lower Resist DOES affect poison)
+ let finalPoison = 0
+ if ((damageSpec.poison ?? 0) > 0) {
+ const poisonBreak = calculateResistanceReduction(
+ defender.resPoison ?? 0,
+ 0, // Conviction has no effect on Poison
+ options.lowerResPenalty ?? 0,
+ options.itemMinusPoisonRes ?? 0,
+ )
+ resistanceBreakResults.poison = poisonBreak
+ if (poisonBreak.isImmune) immunities.push('Poison')
+ finalPoison = Math.max(0, Math.round((damageSpec.poison! * poisonBreak.damageMultiplier) * 10) / 10)
+
+ const breakNote = poisonBreak.immunityBroken
+ ? ` [⚡ 诅咒破毒免成功! 原${poisonBreak.initialRes}% -> 降为${poisonBreak.finalRes}%]`
+ : poisonBreak.wasImmune && poisonBreak.isImmune
+ ? ` [🛡️ 减抗未能破毒免! 原${poisonBreak.initialRes}% -> ${poisonBreak.finalRes}%仍>=100%]`
+ : ` -> 毒抗 ${poisonBreak.finalRes}%`
+
+ log.push(
+ `[10. 毒素伤害]: 基础毒伤 ${damageSpec.poison}${breakNote} (因子 x${poisonBreak.damageMultiplier.toFixed(2)}${poisonBreak.isImmune ? ' 免疫' : ''}) => 净毒伤: ${finalPoison}`,
+ )
+ }
+
+ // 7f. Magic
+ let finalMagic = 0
+ if ((damageSpec.magic ?? 0) > 0) {
+ const magicRes = defender.resMagic ?? 0
+ const factor = calculateResistanceFactor(magicRes)
+ if (factor.isImmune) immunities.push('Magic')
+ finalMagic = Math.max(0, Math.round((Math.max(0, damageSpec.magic! - flatMdr) * factor.factor) * 10) / 10)
+ log.push(
+ `[11. 纯魔法伤害]: 基础魔伤 ${damageSpec.magic} - MDR ${flatMdr} -> 魔抗 ${magicRes}% (因子 x${factor.factor.toFixed(2)}${factor.isImmune ? ' 免疫' : ''}) => 净魔伤: ${finalMagic}`,
+ )
+ }
+
+ // 8. Life & Mana Leech
+ const leechResult = calculateLeech(
+ finalPhysicalDamage,
+ options.lifeLeechPercent ?? 0,
+ options.manaLeechPercent ?? 0,
+ defender.drainEffectiveness ?? 100,
+ difficulty,
+ )
+ if (!isSpell && ((options.lifeLeechPercent ?? 0) > 0 || (options.manaLeechPercent ?? 0) > 0)) {
+ const drainEff = defender.drainEffectiveness ?? 100
+ log.push(
+ `[12. 偷取生命/法力]: 汲取效率 ${drainEff}%, 难度惩罚 x${leechResult.difficultyPenaltyFactor.toFixed(2)} ` +
+ `=> 实际偷取生命 +${leechResult.lifeLeeched} HP, 偷取法力 +${leechResult.manaLeeched} MP`,
+ )
+ }
+
+ // 9. Freezes Target & Knockback (Size-based & Boss Immunity)
+ let freezeResult: FreezeTargetResult | undefined
+ if (!isSpell && (options.freezeBonus ?? 0) > 0) {
+ freezeResult = calculateFreezeTarget(
+ options.freezeBonus!,
+ attackerLevel,
+ defender.level,
+ targetTier,
+ isRanged,
+ difficulty,
+ randomFn,
+ )
+ if (freezeResult.willFreeze) {
+ log.push(
+ `[13. 冻结目标 (Freezes Target)]: 冻结几率 ${freezeResult.chancePercent}% (掷骰 ${freezeResult.roll.toFixed(1)}%) => ❄️ 目标被冰冻 ${freezeResult.durationSeconds} 秒 (${freezeResult.durationFrames} 帧)!`,
+ )
+ } else if (freezeResult.isChilledOnly) {
+ log.push(
+ `[13. 冻结目标 (Freezes Target)]: 目标为 Boss/精英,免疫完全冰冻,降级为减速减速 (Chill) ${freezeResult.durationSeconds} 秒!`,
+ )
+ }
+ }
+
+ const kbChance = options.knockbackPercent ?? 0
+ const defenderSize = defender.size ?? options.defenderSize ?? 'medium'
+ const kbResult = calculateKnockbackSuccess(
+ kbChance > 0,
+ defenderSize,
+ isBossOrChampion,
+ isRanged,
+ randomFn,
+ )
+ const knockbackTriggered = !isSpell && kbResult.triggered
+ if (kbChance > 0) {
+ log.push(`[14. 击退判定 (Knockback)]: ${kbResult.reason}`)
+ }
+
+ // 10. Crowd Control: Slow Target, Blinds, Causes Flee
+ let slowResult: SlowTargetResult | undefined
+ if ((options.slowPercent ?? 0) > 0) {
+ slowResult = calculateSlowTarget(options.slowPercent!, isBossOrChampion)
+ log.push(`[15. 减速目标 (Slow)]: ${slowResult.reason}`)
+ }
+
+ let blindResult: HitBlindsTargetResult | undefined
+ if (!isSpell && (options.hitBlindsTargetBonus ?? 0) > 0) {
+ blindResult = calculateHitBlindsTarget(
+ options.hitBlindsTargetBonus!,
+ attackerLevel,
+ defender.level,
+ isBossOrChampion,
+ isRanged,
+ randomFn,
+ )
+ log.push(`[16. 使目标盲目 (HBT)]: ${blindResult.reason}`)
+ }
+
+ let fleeResult: HitCausesFleeResult | undefined
+ if (!isSpell && (options.hitCausesFleePercent ?? 0) > 0) {
+ fleeResult = calculateHitCausesFlee(
+ options.hitCausesFleePercent!,
+ isBossOrChampion,
+ randomFn,
+ )
+ log.push(`[17. 恐吓逃跑 (HCMTF)]: ${fleeResult.reason}`)
+ }
+
+ // 11. Prevent Monster Heal (PMH) & Monster Regeneration Evaluation
+ let pmhResult: PreventMonsterHealEvaluation | undefined
+ if (!isSpell && options.preventMonsterHeal) {
+ const targetKind = defender.isUber ? 'uber' : (isBossOrChampion ? 'boss' : 'normal')
+ pmhResult = evaluatePreventMonsterHeal(options.attackerType ?? 'player', targetKind)
+ log.push(`[18. 阻止怪物自愈 (PMH)]: ${pmhResult.reason}`)
+ }
+
+ const monsterRegenResult = calculateMonsterRegeneration(
+ defender.maxHp,
+ defender.regenRate ?? 0,
+ isOpenWounds,
+ pmhResult?.applied ?? false,
+ )
+
+ const totalDamage = Math.round((finalPhysicalDamage + finalFire + finalLightning + finalCold + finalPoison + finalMagic) * 10) / 10
+ log.push(
+ `[19. 最终伤害汇总]: 单次打击扣减 = ${totalDamage} HP (目标剩余生命: ${Math.max(0, Math.round((defender.currentHp - totalDamage) * 10) / 10)} / ${defender.maxHp})`,
+ )
+
+ // 12. Hit Recovery (Stun) Check
+ const recovery = checkHitRecovery(totalDamage, defender.maxHp, randomFn())
+ if (recovery.triggered) {
+ log.push(`[20. 受击硬直 (Hit Recovery)]: 单次伤害达到触发阈值 (几率 ${recovery.chancePercent}%) => 💥 触发硬直后仰!`)
+ } else if (recovery.chancePercent > 0) {
+ log.push(`[20. 受击硬直 (Hit Recovery)]: 伤害达到阈值 (几率 ${recovery.chancePercent}%),掷骰通过,未陷入硬直`)
+ }
+
+ return {
+ attackerAr,
+ defenderDr: defender.defense,
+ hitChancePercent,
+ hitRoll,
+ isHit: true,
+ isBlocked: false,
+ defenderBlockPercent: defenderBlock,
+ isCritical: critResult.isCritical,
+ criticalTrigger: critResult.triggeredBy,
+ isCrushingBlow,
+ crushingBlowDamage,
+ isOpenWounds,
+ openWoundsResult,
+ lifeLeeched: leechResult.lifeLeeched,
+ manaLeeched: leechResult.manaLeeched,
+ freezeResult,
+ knockbackTriggered,
+ knockbackResult: kbResult,
+ slowResult,
+ blindResult,
+ fleeResult,
+ pmhResult,
+ monsterRegenResult,
+ resistanceBreakResults,
+ rawPhysicalDamage: rawPhysical,
+ finalPhysicalDamage,
+ finalFireDamage: finalFire,
+ finalLightningDamage: finalLightning,
+ finalColdDamage: finalCold,
+ finalPoisonDamage: finalPoison,
+ finalMagicDamage: finalMagic,
+ totalDamage,
+ immunities,
+ hitRecoveryTriggered: recovery.triggered,
+ hitRecoveryChance: recovery.chancePercent,
+ log,
+ }
+}
+
+/**
+ * Canonical Diablo II 1.13c Experience Progression Table (Levels 1 to 99).
+ *
+ * Index corresponds to character level (Index 1 = Level 1).
+ */
+export const D2_EXPERIENCE_TABLE_99: readonly number[] = [
+ 0, // Index 0 (unused)
+ 0, // Level 1
+ 500, // Level 2
+ 1500, // Level 3
+ 3750, // Level 4
+ 7875, // Level 5
+ 14175, // Level 6
+ 22680, // Level 7
+ 32886, // Level 8
+ 44396, // Level 9
+ 57715, // Level 10
+ 72144, // Level 11
+ 90180, // Level 12
+ 112725, // Level 13
+ 140906, // Level 14
+ 176132, // Level 15
+ 220165, // Level 16
+ 275206, // Level 17
+ 344008, // Level 18
+ 430010, // Level 19
+ 537512, // Level 20
+ 671890, // Level 21
+ 839862, // Level 22
+ 1049828, // Level 23
+ 1312285, // Level 24
+ 1640356, // Level 25
+ 2050445, // Level 26
+ 2563056, // Level 27
+ 3203820, // Level 28
+ 3902664, // Level 29
+ 4663904, // Level 30
+ 5493655, // Level 31
+ 6398084, // Level 32
+ 7383912, // Level 33
+ 8458464, // Level 34
+ 9629726, // Level 35
+ 10906401, // Level 36
+ 12297977, // Level 37
+ 13814795, // Level 38
+ 15468127, // Level 39
+ 17270258, // Level 40
+ 19234581, // Level 41
+ 21375693, // Level 42
+ 23709505, // Level 43
+ 26253360, // Level 44
+ 29026162, // Level 45
+ 32048517, // Level 46
+ 35342884, // Level 47
+ 38933744, // Level 48
+ 42847781, // Level 49
+ 47114081, // Level 50
+ 51764348, // Level 51
+ 56833139, // Level 52
+ 62358122, // Level 53
+ 68380353, // Level 54
+ 74944585, // Level 55
+ 82099598, // Level 56
+ 89898562, // Level 57
+ 98399433, // Level 58
+ 107665382, // Level 59
+ 117765266, // Level 60
+ 128774140, // Level 61
+ 140773813, // Level 62
+ 153853456, // Level 63
+ 168110267, // Level 64
+ 183650191, // Level 65
+ 200588708, // Level 66
+ 219051692, // Level 67
+ 239176344, // Level 68
+ 261112215, // Level 69
+ 285022314, // Level 70
+ 311084322, // Level 71
+ 339511911, // Level 72
+ 370507983, // Level 73
+ 404293701, // Level 74
+ 441110134, // Level 75
+ 481240046, // Level 76
+ 524981650, // Level 77
+ 572660000, // Level 78
+ 624629400, // Level 79
+ 681276046, // Level 80
+ 743020890, // Level 81
+ 810322770, // Level 82
+ 883681819, // Level 83
+ 963643183, // Level 84
+ 1050801070, // Level 85
+ 1145803166, // Level 86
+ 1249355451, // Level 87
+ 1362227442, // Level 88
+ 1485257912, // Level 89
+ 1619361124, // Level 90
+ 1765533625, // Level 91
+ 1924861651, // Level 92
+ 2098529199, // Level 93
+ 2287826827, // Level 94
+ 2494161241, // Level 95
+ 2719065753, // Level 96
+ 2964211671, // Level 97
+ 3231420721, // Level 98
+ 3522678586, // Level 99
+]
diff --git a/tests/formulas.test.ts b/tests/formulas.test.ts
new file mode 100644
index 0000000..453b18e
--- /dev/null
+++ b/tests/formulas.test.ts
@@ -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)
+ })
+ })
+ })
+})
+
diff --git a/vite.config.ts b/vite.config.ts
index 475404e..dfb3ecc 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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,
},
},
},