/** * Diablo II: Lord of Destruction v1.13c `RollToHit` & `SUnitDmg` Combat Pipeline. * * Implements: * - `RollToHit` (`AR` vs `Defense` + `Alvl` vs `Dlvl`, clamped to `[5, 95]`, `autohit` for Smite/Guided Arrow/Spells). * - Shield Block, Assassin `Weapon Block` (`263`), and Amazon `Dodge` (`13`), `Avoid` (`18`), `Evade` (`29`). * - Weapon `srcdam` (`128 = 100%`), off-weapon `%ED`, Critical Strike / Deadly Strike (`2x`), and Physical-to-Elemental/Magic conversion. * - Elemental Masteries (`Fire Mastery` 61, `Lightning Mastery` 63, `Cold Mastery` 65, `passive_*_mastery`). * - Conviction (`123`) & Lower Resist (`91`) Immunity-Breaking (`1/5` efficiency when base resistance >= 100%, * followed by `-Enemy Res` at 100% effectiveness only after immunity is broken below 100%). * - 1.13c Binary Overrides: * 1. **Blessed Hammer (`112`)**: Does NOT ignore Magic Resistance of Undead or Demons in 1.13c! * (Undead/Demons with `magicresist >= 100` take `0` damage; Undead with `< 100` magic resist still * receive `+50%` Undead bonus and `+50%` of active Concentration aura `%ED`.) * 2. **Corpse Explosion (`74`)**: Scales at **`70%–120%`** (`Param1=70, Param2=120`) of base monster HP, * split `50%` Physical + `50%` Fire. * 3. **Venom (`278`)**: Clamps total poison duration of all weapon/item poison sources to **`10` frames (`0.4s`)** * while summing their per-frame poison bitrates (`<< 8`) with Venom's bitrate. */ import { FIXED_ONE, UnitStatList } from './stat-list.ts' import { StateBus } from './state-bus.ts' import { calculateLowerResistReduction, calculateIronMaidenReturnPct } from './missile-engine.ts' export interface CombatUnitContext { readonly id: string readonly name: string readonly statList: UnitStatList readonly stateBus: StateBus readonly isUndead?: boolean readonly isDemon?: boolean readonly isMoving?: boolean readonly isDualClaw?: boolean readonly hasShield?: boolean readonly weaponMinPhys?: number readonly weaponMaxPhys?: number readonly weaponPoisonBitrate256?: number readonly weaponPoisonFrames?: number readonly unitType?: 'player' | 'mercenary' | 'party' | 'party_mercenary' | 'minion' | 'monster' readonly isBoss?: boolean readonly isChampion?: boolean readonly rank?: 'normal' | 'champion' | 'unique' | 'minion' } export interface ResistanceResult { readonly baseRes: number readonly convictionAndLRApplied: number readonly resAfterConvictionLR: number readonly isImmune: boolean readonly effectiveRes: number } /** * Compute effective resistance under 1.13c Conviction (`123`) + Lower Resist (`91`) * `1/5` immunity-breaking rule and `-Enemy Resistance` (`Cold Mastery` / facets). */ export function computeEffectiveResistance(params: { baseRes: number convictionPierce?: number lowerResistPierce?: number passiveEnemyPierce?: number canBreakImmunity?: boolean }): ResistanceResult { const baseRes = Math.trunc(params.baseRes) const rawConvictionAndLR = Math.max(0, Math.trunc((params.convictionPierce ?? 0) + (params.lowerResistPierce ?? 0))) const canBreak = params.canBreakImmunity ?? true // 1.13c Rule: When base resistance >= 100%, Conviction and Lower Resist apply at 1/5 effectiveness! const convictionAndLRApplied = baseRes >= 100 ? canBreak ? Math.trunc(rawConvictionAndLR / 5) : 0 : rawConvictionAndLR const resAfterConvictionLR = baseRes - convictionAndLRApplied if (resAfterConvictionLR >= 100) { return { baseRes, convictionAndLRApplied, resAfterConvictionLR, isImmune: true, effectiveRes: 100, } } const passivePierce = Math.max(0, Math.trunc(params.passiveEnemyPierce ?? 0)) const effectiveRes = Math.max(-100, Math.min(99, resAfterConvictionLR - passivePierce)) return { baseRes, convictionAndLRApplied, resAfterConvictionLR, isImmune: false, effectiveRes, } } /** * Compute 1.13c `RollToHit` chance (`[5, 95]`, or `100` for `autohit` skills such as Smite / Guided Arrow / Spells). */ export function computeToHitChance(params: { attackerAr: number defenderDef: number attackerLvl: number defenderLvl: number skillArBonusPct?: number targetDefPctMod?: number // e.g. -50% from Battle Cry / Cloak of Shadows / Inner Sight autoHit?: boolean }): number { if (params.autoHit) return 100 const arBonusPct = params.skillArBonusPct ?? 0 const effAr = Math.max(1, Math.trunc((params.attackerAr * (100 + arBonusPct)) / 100)) const defMod = params.targetDefPctMod ?? 0 const effDef = Math.max(0, Math.trunc((params.defenderDef * Math.max(0, 100 + defMod)) / 100)) const alvl = Math.max(1, params.attackerLvl) const dlvl = Math.max(1, params.defenderLvl) const raw = Math.trunc((2 * effAr * 100 * alvl) / (Math.max(1, effAr + effDef) * (alvl + dlvl))) return Math.max(5, Math.min(95, raw)) } /** * Evaluate shield block, Assassin Weapon Block (`263`), and Amazon Dodge (`13`), Avoid (`18`), Evade (`29`). */ export function evaluateAvoidanceAndBlock(params: { defender: CombatUnitContext attackKind: 'melee' | 'missile' | 'spell' unblockable?: boolean roll100?: number // deterministic roll in [0..99], default 50 }): { avoided: boolean; reason: 'none' | 'block' | 'weapon_block' | 'dodge' | 'avoid' | 'evade' } { if (params.unblockable) return { avoided: false, reason: 'none' } const roll = params.roll100 ?? 50 const defStats = params.defender.statList // 1. Shield Block (applies to physical melee/missile) if (params.attackKind !== 'spell' && params.defender.hasShield) { const blockChance = Math.min(75, Math.max(0, defStats.getAccruedStat('toblock'))) if (roll < blockChance) { return { avoided: true, reason: 'block' } } } // 2. Assassin Weapon Block (`skillId = 263`, blocks melee, missiles, and most spells while stationary/attacking with dual claws) if (params.defender.isDualClaw && !params.defender.isMoving) { const wbChance = Math.min(65, Math.max(0, defStats.getAccruedStat('weaponblock'))) if (roll < wbChance) { return { avoided: true, reason: 'weapon_block' } } } // 3. Amazon Evade (`skillId = 29`, while moving) if (params.defender.isMoving) { const evadeChance = Math.min(75, Math.max(0, defStats.getAccruedStat('passive_evade'))) if (roll < evadeChance) { return { avoided: true, reason: 'evade' } } } else { // 4. Amazon Dodge (`skillId = 13`, melee while stationary) if (params.attackKind === 'melee') { const dodgeChance = Math.min(75, Math.max(0, defStats.getAccruedStat('passive_dodge'))) if (roll < dodgeChance) { return { avoided: true, reason: 'dodge' } } } // 5. Amazon Avoid (`skillId = 18`, missile/ranged while stationary) if (params.attackKind === 'missile') { const avoidChance = Math.min(75, Math.max(0, defStats.getAccruedStat('passive_avoid'))) if (roll < avoidChance) { return { avoided: true, reason: 'avoid' } } } } return { avoided: false, reason: 'none' } } /** * 1.13c Venom (`skillId = 278`) Poison Duration Clamp: * When `STATE_VENOM` is active on a weapon attack, clamps total poison duration of ALL * weapon/charm poison sources to **10 frames (`0.4s`)** while summing their per-frame * poison bitrates (`<< 8`) with Venom's bitrate! */ export function resolveVenomPoisonApplication(params: { venomActive: boolean venomBitrate256: number otherPoisonSources: readonly { bitrate256: number; frames: number }[] }): { bitrate256: number; durationFrames: number; totalDamage256: number; totalDamage: number } { if (params.venomActive) { const clampedDuration = 10 // 1.13c Venom fixed 10-frame clamp (0.4s) let totalBitrate256 = Math.max(0, Math.trunc(params.venomBitrate256)) for (const src of params.otherPoisonSources) { if (src.bitrate256 > 0 && src.frames > 0) { totalBitrate256 += Math.trunc(src.bitrate256) } } const totalDamage256 = totalBitrate256 * clampedDuration return { bitrate256: totalBitrate256, durationFrames: clampedDuration, totalDamage256, totalDamage: Math.trunc(totalDamage256 / FIXED_ONE), } } let sumBitrate256 = 0 let sumFrames = 0 let count = 0 for (const src of params.otherPoisonSources) { if (src.bitrate256 > 0 && src.frames > 0) { sumBitrate256 += Math.trunc(src.bitrate256) sumFrames += Math.trunc(src.frames) count += 1 } } const avgFrames = count > 0 ? Math.trunc(sumFrames / count) : 0 const totalDamage256 = sumBitrate256 * avgFrames return { bitrate256: sumBitrate256, durationFrames: avgFrames, totalDamage256, totalDamage: Math.trunc(totalDamage256 / FIXED_ONE), } } /** * 1.13c Corpse Explosion (`skillId = 74`) Damage Calculator: * In 1.13c (`Patch_D2.mpq` `Skills.txt` & `patch.txt` line 126), Corpse Explosion * scales at **70%–120%** (`Param1=70`, `Param2=120`) of base monster HP (up from 60%–100% in 1.10), * split **50% Physical + 50% Fire** (`Param5=50`). */ export function computeCorpseExplosion113c(params: { corpseBaseHp: number rollPct?: number // percentage in [70..120], defaults to midpoint 95% targetPhysRes?: number targetFireRes?: number convictionPierce?: number lowerResistPierce?: number amplifyDamagePierce?: number // e.g. 100 for Amplify Damage, 50 for Decrepify }): { minRollPct: number maxRollPct: number chosenPct: number rawTotalDamage: number rawPhysDamage: number rawFireDamage: number finalPhysDamage: number finalFireDamage: number finalTotalDamage: number } { const minRollPct = 70 const maxRollPct = 120 const chosenPct = Math.max(minRollPct, Math.min(maxRollPct, params.rollPct ?? 95)) const rawTotalDamage = Math.trunc((params.corpseBaseHp * chosenPct) / 100) const rawFireDamage = Math.trunc((rawTotalDamage * 50) / 100) const rawPhysDamage = rawTotalDamage - rawFireDamage const physRes = computeEffectiveResistance({ baseRes: params.targetPhysRes ?? 0, convictionPierce: params.amplifyDamagePierce ?? 0, }) const fireRes = computeEffectiveResistance({ baseRes: params.targetFireRes ?? 0, convictionPierce: params.convictionPierce ?? 0, lowerResistPierce: params.lowerResistPierce ?? 0, }) const finalPhysDamage = physRes.isImmune ? 0 : Math.max(0, Math.trunc((rawPhysDamage * (100 - physRes.effectiveRes)) / 100)) const finalFireDamage = fireRes.isImmune ? 0 : Math.max(0, Math.trunc((rawFireDamage * (100 - fireRes.effectiveRes)) / 100)) return { minRollPct, maxRollPct, chosenPct, rawTotalDamage, rawPhysDamage, rawFireDamage, finalPhysDamage, finalFireDamage, finalTotalDamage: finalPhysDamage + finalFireDamage, } } export interface SUnitDmgPacket { readonly skillId: number readonly attackKind?: 'melee' | 'missile' | 'spell' readonly srcDam?: number // 128 = 100% weapon damage readonly physDamagePct?: number // skill off-weapon %ED readonly flatPhysMin256?: number readonly flatPhysMax256?: number readonly elemType?: string // 'fire' | 'ltng' | 'cold' | 'pois' | 'mag' | 'stun' readonly elemMin256?: number readonly elemMax256?: number readonly elemDurationFrames?: number readonly physToElemPct?: number // e.g. Berserk 100% magic, Magic Arrow / Fire Arrow % readonly concentrationBonusPct?: number // active Concentration aura %ED for Blessed Hammer (50% efficiency) readonly convictionPierce?: number readonly lowerResistPierce?: number readonly passiveEnemyPierce?: number readonly autoHit?: boolean readonly unblockable?: boolean readonly roll100?: number readonly isPoison?: boolean readonly isOpenWounds?: boolean readonly returnFire?: boolean readonly isEnchant?: boolean } export interface SUnitDmgOutcome { readonly hit: boolean readonly avoidedReason: 'none' | 'miss' | 'block' | 'weapon_block' | 'dodge' | 'avoid' | 'evade' readonly immuneToElem: boolean readonly immuneToPhys: boolean readonly physDamage256: number readonly elemDamage256: number readonly totalDamage256: number readonly totalDamage: number readonly targetHpBefore256: number readonly targetHpAfter256: number readonly targetKilled: boolean readonly attackerStatesApplied?: readonly string[] readonly attackerColdDamage256?: number readonly reflectedPhys256?: number readonly attackerHealed256?: number readonly spawnedMissiles?: readonly string[] } /** * Execute the complete 1.13c `SUnitDmg` damage pipeline from `attacker` onto `defender`. */ export function executeSUnitDmg( attacker: CombatUnitContext, defender: CombatUnitContext, packet: SUnitDmgPacket, ): SUnitDmgOutcome { const hpBefore256 = defender.statList.getHp256() const attackKind = packet.attackKind ?? (packet.srcDam && packet.srcDam > 0 ? 'melee' : 'spell') const roll = packet.roll100 ?? 25 const attackerStatesApplied: string[] = [] let attackerColdDamage256 = 0 let reflectedPhys256 = 0 const spawnedMissiles: string[] = [] // If melee attack, trigger 'attackedinmelee' proactively on attack initiation if (attackKind === 'melee') { const initialPhys = packet.flatPhysMax256 ?? ((attacker.weaponMaxPhys ?? 45) * FIXED_ONE) const reactiveAttack = defender.stateBus.triggerReactiveEvents({ event: 'attackedinmelee', incomingPhys256: initialPhys, attackerStateBus: attacker.stateBus, }) if (reactiveAttack.attackerStatesApplied.length > 0) { attackerStatesApplied.push(...reactiveAttack.attackerStatesApplied) } if (reactiveAttack.attackerColdDamage256 > 0) { attackerColdDamage256 += reactiveAttack.attackerColdDamage256 const attackerHp = attacker.statList.getHp256() attacker.statList.setHp256(Math.max(0, attackerHp - reactiveAttack.attackerColdDamage256)) } } // 1. RollToHit check for weapon attacks (unless autoHit, e.g. Smite 97 / Guided Arrow 22 / Spells) const isAutoHit = packet.autoHit ?? (packet.skillId === 97 || packet.skillId === 22 || attackKind === 'spell') if (!isAutoHit) { const toHitChance = computeToHitChance({ attackerAr: attacker.statList.getAccruedStat('tohit'), defenderDef: defender.statList.getAccruedStat('armorclass'), attackerLvl: attacker.statList.getAccruedStat('level'), defenderLvl: defender.statList.getAccruedStat('level'), }) if (roll >= toHitChance) { return { hit: false, avoidedReason: 'miss', immuneToElem: false, immuneToPhys: false, physDamage256: 0, elemDamage256: 0, totalDamage256: 0, totalDamage: 0, targetHpBefore256: hpBefore256, targetHpAfter256: hpBefore256, targetKilled: false, ...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}), ...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}), } } } // 2. Avoidance & Block (Smite `97` is unblockable by shield block) const avoidCheck = evaluateAvoidanceAndBlock({ defender, attackKind, unblockable: packet.unblockable ?? packet.skillId === 97, roll100: roll, }) if (avoidCheck.avoided) { return { hit: false, avoidedReason: avoidCheck.reason, immuneToElem: false, immuneToPhys: false, physDamage256: 0, elemDamage256: 0, totalDamage256: 0, totalDamage: 0, targetHpBefore256: hpBefore256, targetHpAfter256: hpBefore256, targetKilled: false, ...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}), ...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}), } } // 3. Weapon `srcdam` (128 = 100%) + Physical Damage calculation const srcDam = packet.srcDam ?? (attackKind !== 'spell' ? 128 : 0) let rawPhys256 = 0 if (srcDam > 0 || (packet.flatPhysMin256 ?? 0) > 0 || (packet.flatPhysMax256 ?? 0) > 0) { const weapMin256 = (attacker.weaponMinPhys ?? 25) * FIXED_ONE const weapMax256 = (attacker.weaponMaxPhys ?? 45) * FIXED_ONE const avgWeap256 = Math.trunc(((weapMin256 + weapMax256) / 2) * srcDam / 128) const flatAvg256 = Math.trunc(((packet.flatPhysMin256 ?? 0) + (packet.flatPhysMax256 ?? 0)) / 2) const dmgPctBonus = attacker.statList.getModifierBonus('damagepercent') const curseDmgMod = (dmgPctBonus === 0 && attacker.stateBus.hasState('weaken') ? -33 : 0) + (dmgPctBonus === 0 && attacker.stateBus.hasState('decrepify') ? -50 : 0) const edPct = (packet.physDamagePct ?? 0) + dmgPctBonus + curseDmgMod + attacker.statList.getModifierBonus('skill_damage_percent') rawPhys256 = Math.max(0, Math.trunc(((avgWeap256 + flatAvg256) * (100 + edPct)) / 100)) } // 4. Physical-to-Elemental/Magic Conversion (e.g. Berserk 100% Magic, Magic Arrow, Fire Arrow, Cold Arrow) let rawElem256 = Math.trunc(((packet.elemMin256 ?? 0) + (packet.elemMax256 ?? 0)) / 2) const convPct = Math.max(0, Math.min(100, packet.physToElemPct ?? (packet.skillId === 152 ? 100 : 0))) if (convPct > 0 && rawPhys256 > 0) { const converted256 = Math.trunc((rawPhys256 * convPct) / 100) rawPhys256 -= converted256 rawElem256 += converted256 } // 5. Elemental Mastery & 1.13c Blessed Hammer / Venom / Static Field / Enchant rules const elemType = (packet.elemType ?? (packet.skillId === 152 || packet.skillId === 112 ? 'mag' : 'fire')).toLowerCase() const isEnchantFire = Boolean(packet.isEnchant || packet.skillId === 52 || attacker.stateBus.hasState('enchant')) if (isEnchantFire && attackKind === 'missile' && elemType === 'fire') { // 1.13c Param3 = 33: Ranged attacks (bow/crossbow/throwing) receive only 33% of Enchant fire damage bonus rawElem256 = Math.trunc((rawElem256 * 33) / 100) } if (rawElem256 > 0) { let masteryPct = 0 // Ranged Enchant attacks do NOT get the second Fire Mastery multiplier const skipFireMasterySecondMultiplier = isEnchantFire && attackKind === 'missile' if (elemType === 'fire' && !skipFireMasterySecondMultiplier) { masteryPct = attacker.statList.getAccruedStat('passive_fire_mastery') } else if (elemType === 'ltng') { masteryPct = attacker.statList.getAccruedStat('passive_ltng_mastery') } else if (elemType === 'cold') { masteryPct = attacker.statList.getAccruedStat('passive_cold_mastery') } else if (elemType === 'pois') { masteryPct = attacker.statList.getAccruedStat('passive_pois_mastery') } if (masteryPct > 0) { rawElem256 = Math.trunc((rawElem256 * (100 + masteryPct)) / 100) } } // Blessed Hammer (`skillId = 112`): // - Concentration aura adds `50%` (`Param1 = 4 -> 4/8`) of its `%ED` to Blessed Hammer. // - `+50%` damage bonus vs Undead (`dParam1 = 50`). // - CRITICAL 1.13c RULE (`patch.txt` line 112): Blessed Hammer does NOT ignore Magic Resistance // of Undead or Demons! Magic Resistance (`magicresist`) applies normally to all targets. if (packet.skillId === 112) { const concPct = packet.concentrationBonusPct ?? attacker.statList.getModifierBonus('concentration_damage_pct') if (concPct > 0) { rawElem256 = Math.trunc((rawElem256 * (100 + Math.trunc(concPct / 2))) / 100) } if (defender.isUndead) { rawElem256 = Math.trunc((rawElem256 * 150) / 100) } } // Venom (`skillId = 278` or `STATE_VENOM` active on weapon attack) if ( attackKind !== 'spell' && (packet.skillId === 278 || attacker.stateBus.hasState('venom')) ) { const venomRes = resolveVenomPoisonApplication({ venomActive: true, venomBitrate256: attacker.statList.getModifierBonus('venom_bitrate_256') || (35 * FIXED_ONE), otherPoisonSources: (attacker.weaponPoisonBitrate256 ?? 0) > 0 ? [{ bitrate256: attacker.weaponPoisonBitrate256!, frames: attacker.weaponPoisonFrames ?? 100 }] : [], }) rawElem256 += venomRes.totalDamage256 } // 6. Resistances & 1/5 Immunity Breaking const ampPierceBonus = defender.statList.getModifierBonus('amplify_phys_pierce') const curseAmpPierce = ampPierceBonus > 0 ? ampPierceBonus : defender.stateBus.hasState('amplifydamage') ? 100 : defender.stateBus.hasState('decrepify') ? 50 : 0 const physResRec = computeEffectiveResistance({ baseRes: defender.statList.getAccruedStat('damageresist'), convictionPierce: curseAmpPierce, }) const resStatKey = elemType === 'fire' ? 'fireresist' : elemType === 'ltng' ? 'lightresist' : elemType === 'cold' ? 'coldresist' : elemType === 'pois' ? 'poisonresist' : 'magicresist' const isElemPiercableByConviction = elemType === 'fire' || elemType === 'ltng' || elemType === 'cold' const isElemPiercableByLR = elemType === 'fire' || elemType === 'ltng' || elemType === 'cold' || elemType === 'pois' const lrPierceBonus = defender.statList.getModifierBonus('lowerresist_pierce') const curseLrPierce = lrPierceBonus > 0 ? lrPierceBonus : defender.stateBus.hasState('lowerresist') ? calculateLowerResistReduction(defender.stateBus.getState('lowerresist')?.slvl ?? 1) : 0 const convictionPierce = isElemPiercableByConviction ? (packet.convictionPierce ?? defender.statList.getModifierBonus('conviction_pierce')) : 0 const lowerResistPierce = isElemPiercableByLR ? (packet.lowerResistPierce ?? curseLrPierce) : 0 const passivePierce = packet.passiveEnemyPierce ?? (elemType === 'cold' ? attacker.statList.getAccruedStat('passive_cold_pierce') : elemType === 'fire' ? attacker.statList.getAccruedStat('passive_fire_pierce') : elemType === 'ltng' ? attacker.statList.getAccruedStat('passive_ltng_pierce') : elemType === 'pois' ? attacker.statList.getAccruedStat('passive_pois_pierce') : 0) const elemResRec = computeEffectiveResistance({ baseRes: defender.statList.getAccruedStat(resStatKey), convictionPierce, lowerResistPierce, passiveEnemyPierce: passivePierce, // Magic resistance cannot be broken by Conviction/Lower Resist canBreakImmunity: isElemPiercableByLR, }) // 6. Reactive `auraeventfunc` shields (`Energy Shield`, `Bone Armor`, `Cyclone Armor`) absorb BEFORE resistances in 1.13c const reactive = defender.stateBus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: rawPhys256, incomingElem256: rawElem256, attackerStateBus: attacker.stateBus, elemType, isPoison: elemType === 'pois' || Boolean(packet.isPoison), isOpenWounds: Boolean(packet.isOpenWounds), }) const remRawPhys256 = Math.max(0, rawPhys256 - reactive.absorbedPhys256) const remRawElem256 = Math.max(0, rawElem256 - reactive.absorbedElem256) // 1.13c Fire Golem Fire Absorption: 100% healed by fire damage taken (absorbs even if naturally immune) const isFireGolemAbsorb = elemType === 'fire' && defender.statList.getAccruedStat('fire_golem_absorb') > 0 if (isFireGolemAbsorb && remRawElem256 > 0) { const curHp = defender.statList.getHp256() const maxHp = defender.statList.getMaxHp256() defender.statList.setHp256(Math.min(maxHp, curHp + remRawElem256)) } // 7. Resistances & 1/5 Immunity Breaking applied to unabsorbed damage let finalPhys256 = physResRec.isImmune ? 0 : Math.max(0, Math.trunc((remRawPhys256 * (100 - physResRec.effectiveRes)) / 100)) let finalElem256 = isFireGolemAbsorb || elemResRec.isImmune ? 0 : Math.max(0, Math.trunc((remRawElem256 * (100 - elemResRec.effectiveRes)) / 100)) // Life Tap: on physical damage dealt, attacker heals 50% of physical damage dealt let attackerHealed256 = 0 const hasLifeTap = defender.stateBus.hasState('lifetap') || defender.statList.getModifierBonus('lifetap_leech_pct') > 0 if (hasLifeTap && finalPhys256 > 0) { attackerHealed256 = Math.trunc(finalPhys256 * 50 / 100) const curHp = attacker.statList.getHp256() attacker.statList.setHp256(curHp + attackerHealed256) } // Static Field (`skillId = 42`): reduces target HP by 25% mitigated by Lightning Resistance if (packet.skillId === 42 && !elemResRec.isImmune) { const rawStatic256 = Math.trunc((hpBefore256 * 25) / 100) finalElem256 = Math.max(0, Math.trunc((rawStatic256 * (100 - elemResRec.effectiveRes)) / 100)) } // 8. Elemental Absorb (% and flat) if (finalElem256 > 0 && !isFireGolemAbsorb) { const absorbPct = Math.min(40, Math.max(0, defender.statList.getAccruedStat(`absorb_${elemType}_percent`))) const absorbFlat256 = Math.max(0, defender.statList.getAccruedStat(`absorb_${elemType}`)) * FIXED_ONE if (absorbPct > 0 || absorbFlat256 > 0) { const pctAbsorbed = Math.trunc((finalElem256 * absorbPct) / 100) const totalAbsorb = Math.min(finalElem256, pctAbsorbed + absorbFlat256) // Absorb heals before damage is applied, effectively subtracting 2x absorb from net damage finalElem256 = Math.max(0, finalElem256 - totalAbsorb * 2) } } const totalDamage256 = finalPhys256 + finalElem256 const currentDefenderHp256 = defender.statList.getHp256() const hpAfter256 = Math.max(0, currentDefenderHp256 - totalDamage256) defender.statList.setHp256(hpAfter256) // 9. Reactive events post-damage (damagedinmelee for melee, hitbymissile for missile) if (attackKind === 'melee') { // Clay Golem: attacker in melee is also slowed! const claySlowPct = defender.statList.getAccruedStat('clay_golem_slow') if (claySlowPct > 0) { attacker.stateBus.applyState({ stateNameOrId: 'slowed', slvl: 1, durationFrames: 150, stats: { item_slow: claySlowPct }, }) attackerStatesApplied.push('slowed') } // Iron Golem Thorns reflection const thornsReturnPct = defender.statList.getAccruedStat('thorns_return_pct') if (thornsReturnPct > 0 && finalPhys256 > 0) { const rawThorns256 = Math.trunc((finalPhys256 * thornsReturnPct) / 100) const attackerPhysRes = computeEffectiveResistance({ baseRes: attacker.statList.getAccruedStat('damageresist'), }) const reflected256 = attackerPhysRes.isImmune ? 0 : Math.max(0, Math.trunc((rawThorns256 * (100 - attackerPhysRes.effectiveRes)) / 100)) if (reflected256 > 0) { reflectedPhys256 += reflected256 const curHp = attacker.statList.getHp256() attacker.statList.setHp256(Math.max(0, curHp - reflected256)) } } const reactiveDamage = defender.stateBus.triggerReactiveEvents({ event: 'damagedinmelee', incomingPhys256: finalPhys256, incomingElem256: finalElem256, elemType, attackerStateBus: attacker.stateBus, }) if (reactiveDamage.attackerStatesApplied.length > 0) { attackerStatesApplied.push(...reactiveDamage.attackerStatesApplied) } if (reactiveDamage.attackerColdDamage256 > 0) { attackerColdDamage256 += reactiveDamage.attackerColdDamage256 const curHp = attacker.statList.getHp256() attacker.statList.setHp256(Math.max(0, curHp - reactiveDamage.attackerColdDamage256)) } if (reactiveDamage.reflectedPhys256 > 0) { reflectedPhys256 += reactiveDamage.reflectedPhys256 const curHp = attacker.statList.getHp256() attacker.statList.setHp256(Math.max(0, curHp - reactiveDamage.reflectedPhys256)) } // Iron Maiden: reflect physical melee damage back to cursed attacker const hasIronMaiden = attacker.stateBus.hasState('ironmaiden') || attacker.statList.getModifierBonus('ironmaiden_return_pct') > 0 if (hasIronMaiden && finalPhys256 > 0) { const imSlvl = attacker.stateBus.getState('ironmaiden')?.slvl ?? 1 const vsPlayer = defender.unitType === 'player' && attacker.unitType === 'player' const returnPct = attacker.statList.getModifierBonus('ironmaiden_return_pct') || calculateIronMaidenReturnPct(imSlvl, vsPlayer) const rawReflected256 = Math.trunc((finalPhys256 * returnPct) / 100) const attackerPhysRes = computeEffectiveResistance({ baseRes: attacker.statList.getAccruedStat('damageresist'), }) const imReflected256 = attackerPhysRes.isImmune ? 0 : Math.max(0, Math.trunc((rawReflected256 * (100 - attackerPhysRes.effectiveRes)) / 100)) if (imReflected256 > 0) { reflectedPhys256 += imReflected256 const curHp = attacker.statList.getHp256() attacker.statList.setHp256(Math.max(0, curHp - imReflected256)) } } } else if (attackKind === 'missile') { const reactiveMissile = defender.stateBus.triggerReactiveEvents({ event: 'hitbymissile', incomingPhys256: finalPhys256, incomingElem256: finalElem256, elemType, ...(packet.returnFire !== undefined ? { returnFire: packet.returnFire } : {}), attackerStateBus: attacker.stateBus, }) if (reactiveMissile.spawnedMissiles.length > 0) { spawnedMissiles.push(...reactiveMissile.spawnedMissiles) } if (reactiveMissile.attackerStatesApplied.length > 0) { attackerStatesApplied.push(...reactiveMissile.attackerStatesApplied) } } return { hit: true, avoidedReason: 'none', immuneToElem: elemResRec.isImmune && rawElem256 > 0, immuneToPhys: physResRec.isImmune && rawPhys256 > 0, physDamage256: finalPhys256, elemDamage256: finalElem256, totalDamage256, totalDamage: Math.ceil(totalDamage256 / FIXED_ONE), targetHpBefore256: hpBefore256, targetHpAfter256: hpAfter256, targetKilled: hpAfter256 === 0 && hpBefore256 > 0, ...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}), ...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}), ...(reflectedPhys256 > 0 ? { reflectedPhys256 } : {}), ...(attackerHealed256 > 0 ? { attackerHealed256 } : {}), ...(spawnedMissiles.length > 0 ? { spawnedMissiles } : {}), } }