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) +

+
+ 🎮 返回战役漫步 (acts.html) + 📁 资源检视器 +
+
+ +
+ + + + +
+ +
+ +
+ + +
+ + + + + + + + +
+ + +
+
+ 📐 实时战斗数学公式推导与日志 (Combat Math Provenance) +
+ DPS: 0.0 + +
+
+
+
+
+ + + +
+ + + + 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, }, }, },