diablo2-web/src/game/engine/combat-pipeline.ts

740 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 } : {}),
}
}