740 lines
29 KiB
TypeScript
740 lines
29 KiB
TypeScript
/**
|
||
* 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 } : {}),
|
||
}
|
||
}
|