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

555 lines
21 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'
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'
}
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
}
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
}
/**
* 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
// 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,
}
}
}
// 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,
}
}
// 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 edPct =
(packet.physDamagePct ?? 0) +
attacker.statList.getModifierBonus('damagepercent') +
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 rules
const elemType = (packet.elemType ?? (packet.skillId === 152 || packet.skillId === 112 ? 'mag' : 'fire')).toLowerCase()
if (rawElem256 > 0) {
let masteryPct = 0
if (elemType === 'fire') 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 physResRec = computeEffectiveResistance({
baseRes: defender.statList.getAccruedStat('damageresist'),
convictionPierce: defender.statList.getModifierBonus('amplify_phys_pierce'),
})
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 convictionPierce = isElemPiercableByConviction
? (packet.convictionPierce ?? defender.statList.getModifierBonus('conviction_pierce'))
: 0
const lowerResistPierce = isElemPiercableByLR
? (packet.lowerResistPierce ?? defender.statList.getModifierBonus('lowerresist_pierce'))
: 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)
// 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 = elemResRec.isImmune
? 0
: Math.max(0, Math.trunc((remRawElem256 * (100 - elemResRec.effectiveRes)) / 100))
// 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) {
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 hpAfter256 = Math.max(0, hpBefore256 - totalDamage256)
defender.statList.setHp256(hpAfter256)
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,
}
}