diablo2-web/src/game/formulas.ts

1916 lines
63 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 Combat Mathematics & Formulas.
*
* Implements canonical combat mathematics according to D2MOO, Diablo II 1.10–1.14c
* game logic, and authoritative community documentation (The Phrozen Keep, The Amazon Basin, Arreat Summit):
*
* 1. Attack Rating (AR) vs Defense Rating (DR/AC) to-hit formula with:
* - Ignore Target's Defense (ITD) - only works on normal monsters, fails on bosses/champions/players.
* - -% Target Defense - 100% on normal monsters, halved (50% effectiveness) against bosses/champions.
* - Running defender defense penalty (DR = 0 when running).
* - Clamped to [5%, 95%].
* 2. Shield blocking formula with Dexterity and Character Level scaling:
* - Capped at 75% while standing/walking/attacking.
* - Reduced to 1/3 and capped at 25% while running.
* 3. Two distinct Critical Strike types:
* - Critical Strike (CS) from skills (Amazon/Barbarian/Assassin).
* - Deadly Strike (DS) from equipment.
* - Mutually exclusive sequential roll (CS first, then DS; no 4x damage; physical damage only).
* 4. Crushing Blow (CB):
* - Normal Monster: 25% (melee) / 12.5% (ranged) current HP.
* - Boss / Champion / Super Unique: 12.5% (melee) / 6.25% (ranged) current HP.
* - Player / Merc: 10% (melee) / 5% (ranged) current HP.
* - Physical resistance interaction: reduced by 0%–99% DR; immune at >=100% DR;
* NOT amplified by negative DR (multiplier capped at 1.0).
* 5. Open Wounds (OW):
* - Fixed 200 frames (8.0 seconds at 25 fps).
* - Level-dependent 1/256th bit rate across 5 level brackets (1–15, 16–30, 31–45, 46–60, 61–99).
* - Bosses/Super Uniques take 50% damage; players take 25% (melee) / 12.5% (ranged).
* - Disables natural monster life regeneration while bleeding.
* 6. Life & Mana Steal (Leech):
* - Applied only to final physical damage.
* - Subject to target's Drain Effectiveness (e.g. Skeletons/Hell Mephisto = 0%).
* - Subject to game difficulty penalty (Normal 100%, Nightmare 50%, Hell 33.3%).
* 7. Control effects:
* - Freezes Target +X (with level difference, boss chill fallback, difficulty duration reduction).
* - Knockback (size and boss immunity).
* 8. Canonical Damage Mitigation Sequence:
* - Flat DR/MDR -> Percentage Resistance (%DR & Resists) -> Absorb.
* 9. Hit Recovery (FHR) trigger based on net damage vs Max HP thresholds.
* 10. Canonical 99-level experience progression table.
*/
export type TargetTier = 'normal' | 'boss' | 'player'
export type GameDifficulty = 'normal' | 'nightmare' | 'hell'
/**
* Options for To-Hit Chance calculation.
*/
export interface HitChanceOptions {
/** Ignore Target's Defense (ITD) affix (e.g. from Jah rune, Lightsaber). */
readonly ignoreTargetDefense?: boolean | undefined
/** -X% Target's Defense (e.g. Eth rune -25%, Conviction). */
readonly targetDefenseReductionPercent?: number | undefined
/** Whether the defender is currently running (sets defender DR to 0). */
readonly isTargetRunning?: boolean | undefined
/** Whether the defender is a boss, champion, unique, or player. */
readonly isBossOrChampion?: boolean | undefined
}
/**
* 1. To-Hit Chance (Attack Rating vs Defense Rating).
*
* Canonical formula:
* Chance = clamp(5%, 95%, 100% * (2 * AR / (AR + EffectiveDR)) * (AttackerLevel / (AttackerLevel + DefenderLevel)))
*
* Modifiers:
* - Running: Effective DR = 0.
* - ITD: Effective DR = 0 if defender is normal monster; fails on bosses/champions/players.
* - -% Target Defense: Effective on normal monsters; 50% effectiveness against bosses/champions.
*
* @param attackerAr - Attacker's Attack Rating (AR).
* @param defenderDr - Defender's Defense Rating (DR / AC).
* @param attackerLevel - Attacker's level.
* @param defenderLevel - Defender's level.
* @param options - Defensive reduction and posture options.
* @returns To-hit probability in percentage [5, 95].
*/
export function calculateHitChance(
attackerAr: number,
defenderDr: number,
attackerLevel: number,
defenderLevel: number,
options: HitChanceOptions = {},
): number {
const ar = Math.max(0, attackerAr)
let dr = Math.max(0, defenderDr)
const attLvl = Math.max(1, attackerLevel)
const defLvl = Math.max(1, defenderLevel)
if (options.isTargetRunning) {
dr = 0
} else if (options.ignoreTargetDefense && !options.isBossOrChampion) {
dr = 0
} else if (options.targetDefenseReductionPercent && options.targetDefenseReductionPercent > 0) {
const factor = options.isBossOrChampion
? options.targetDefenseReductionPercent / 2
: options.targetDefenseReductionPercent
dr = Math.max(0, dr * (1 - factor / 100))
}
if (ar + dr <= 0) return 5
const arFactor = (2 * ar) / (ar + dr)
const lvlFactor = attLvl / (attLvl + defLvl)
const rawChance = 100 * arFactor * lvlFactor
return Math.max(5, Math.min(95, Math.round(rawChance * 100) / 100))
}
/**
* 2. Player Shield Blocking Chance.
*
* Canonical formula:
* Total Block % = min(75%, floor(BaseBlock * (Dexterity - 15) / (2 * CharacterLevel)))
*
* When running, block chance is reduced to 1/3 and capped at 25%.
*
* @param baseBlock - Shield's base block chance + item bonuses.
* @param dexterity - Character's total Dexterity.
* @param characterLevel - Character level (1–99).
* @param isRunning - Whether the character is currently running.
* @returns Block chance in percentage [0, 75] (or [0, 25] if running).
*/
export function calculatePlayerBlockChance(
baseBlock: number,
dexterity: number,
characterLevel: number,
isRunning = false,
): number {
if (baseBlock <= 0 || dexterity <= 15 || characterLevel <= 0) return 0
const rawBlock = Math.floor((baseBlock * (dexterity - 15)) / (2 * characterLevel))
if (isRunning) {
return Math.max(0, Math.min(25, Math.floor(rawBlock / 3)))
}
return Math.max(0, Math.min(75, rawBlock))
}
/**
* 3. Combined Critical Strike / Deadly Strike chance.
*
* In D2:
* Critical Strike (CS) and Deadly Strike (DS) each double physical damage,
* but cannot proc simultaneously to quadruple damage.
* Combined probability:
* P = CS + DS * (1 - CS)
*
* @param criticalStrikePercent - Critical Strike % from skills [0, 100].
* @param deadlyStrikePercent - Deadly Strike % from equipment [0, 100].
* @returns Combined double-damage probability % [0, 100].
*/
export function calculateCriticalChance(
criticalStrikePercent: number,
deadlyStrikePercent: number,
): number {
const cs = Math.max(0, Math.min(100, criticalStrikePercent)) / 100
const ds = Math.max(0, Math.min(100, deadlyStrikePercent)) / 100
const combined = cs + ds * (1 - cs)
return Math.round(combined * 10000) / 100
}
/**
* Detailed outcome of sequential CS/DS evaluation.
*/
export interface CriticalEvaluationResult {
readonly isCritical: boolean
readonly triggeredBy: 'none' | 'critical_strike' | 'deadly_strike'
readonly csRoll: number
readonly dsRoll: number
readonly combinedChancePercent: number
}
/**
* Evaluates Critical Strike (skill) and Deadly Strike (item) in strict canonical sequence:
* Roll CS first; if it fails, roll DS. If either procs, physical damage is doubled.
*
* @param criticalStrikePercent - Critical Strike % from skills.
* @param deadlyStrikePercent - Deadly Strike % from equipment.
* @param randomFn - Random generator returning [0, 1).
*/
export function evaluateCriticalAndDeadlyStrike(
criticalStrikePercent: number,
deadlyStrikePercent: number,
randomFn: () => number = Math.random,
): CriticalEvaluationResult {
const cs = Math.max(0, Math.min(100, criticalStrikePercent))
const ds = Math.max(0, Math.min(100, deadlyStrikePercent))
const combinedChancePercent = calculateCriticalChance(cs, ds)
const csRoll = Math.round(randomFn() * 10000) / 100
if (cs > 0 && csRoll < cs) {
return {
isCritical: true,
triggeredBy: 'critical_strike',
csRoll,
dsRoll: 0,
combinedChancePercent,
}
}
const dsRoll = Math.round(randomFn() * 10000) / 100
if (ds > 0 && dsRoll < ds) {
return {
isCritical: true,
triggeredBy: 'deadly_strike',
csRoll,
dsRoll,
combinedChancePercent,
}
}
return {
isCritical: false,
triggeredBy: 'none',
csRoll,
dsRoll,
combinedChancePercent,
}
}
/**
* 4. Crushing Blow Damage.
*
* Base ratio of current HP truncated:
* - Normal Monster: Melee 25% (1/4), Ranged 12.5% (1/8).
* - Boss / Champion / Super Unique: Melee 12.5% (1/8), Ranged 6.25% (1/16).
* - Player / Mercenary: Melee 10% (1/10), Ranged 5% (1/20).
*
* Physical Resistance (DR%) Interaction:
* - If Physical Resistance >= 100% (Physical Immune): CB deals 0 damage.
* - If Physical Resistance is in [0%, 99%]: CB damage is reduced by DR%.
* - If Physical Resistance < 0%: CB damage is NOT amplified (factor capped at 1.0).
* - Flat DR does NOT reduce CB.
*
* @param currentHp - Target's current HP before attack.
* @param targetTierOrBoss - Target classification ('normal', 'boss', 'player', or boolean for backwards-compatibility).
* @param isRanged - Whether the attack is ranged.
* @param targetPhysicalResistance - Target's physical resistance % (DR%).
* @returns Final Crushing Blow damage applied to HP.
*/
export function calculateCrushingBlowDamage(
currentHp: number,
targetTierOrBoss: boolean | TargetTier = false,
isRanged = false,
targetPhysicalResistance = 0,
): number {
if (currentHp <= 0) return 0
if (targetPhysicalResistance >= 100) return 0
let ratio = 0.25
const isPlayer = targetTierOrBoss === 'player'
const isBoss = targetTierOrBoss === true || targetTierOrBoss === 'boss'
if (isPlayer) {
ratio = isRanged ? 0.05 : 0.10
} else if (isBoss) {
ratio = isRanged ? 0.0625 : 0.125
} else {
ratio = isRanged ? 0.125 : 0.25
}
const baseDmg = Math.floor(currentHp * ratio)
const resFactor = targetPhysicalResistance > 0
? Math.max(0, (100 - targetPhysicalResistance) / 100)
: 1.0
return Math.floor(baseDmg * resFactor)
}
/**
* 5. Open Wounds (OW) Damage.
*
* Canonical 1.10+ formula:
* Lasts 200 frames (8.0 seconds at 25 fps).
* Life drain per frame in 1/256th point units based on attacker level (Clvl):
* Clvl 1–15: (9 * Clvl + 31)
* Clvl 16–30: (18 * Clvl - 104)
* Clvl 31–45: (27 * Clvl - 374)
* Clvl 46–60: (36 * Clvl - 779)
* Clvl >60: (45 * Clvl - 1319)
*
* Modifiers:
* - Boss / Champion / Super Unique: 1/2 (x0.5) for both melee and ranged.
* - Player: 1/4 (x0.25) for melee, 1/8 (x0.125) for ranged.
* - Normal monster: unmodified (x1.0).
*
* Crucial property: Disables natural monster life regeneration while active.
*/
export interface OpenWoundsDamageResult {
readonly attackerLevel: number
readonly lifeDrainPerFrame: number // in 1/256th point units
readonly damagePerFrame: number // actual HP per frame
readonly damagePerSecond: number // 25 fps
readonly totalDamage200Frames: number
readonly durationFrames: number // 200
readonly durationSeconds: number // 8.0
readonly modifierFactor: number
readonly isBoss: boolean
readonly isPlayer: boolean
}
export function calculateOpenWoundsDamage(
attackerLevel: number,
targetTierOrBoss: boolean | TargetTier = 'normal',
isRanged = false,
): OpenWoundsDamageResult {
const lvl = Math.max(1, Math.min(99, Math.floor(attackerLevel)))
let basePerFrame = 0
if (lvl <= 15) {
basePerFrame = 9 * lvl + 31
} else if (lvl <= 30) {
basePerFrame = 18 * lvl - 104
} else if (lvl <= 45) {
basePerFrame = 27 * lvl - 374
} else if (lvl <= 60) {
basePerFrame = 36 * lvl - 779
} else {
basePerFrame = 45 * lvl - 1319
}
let modifierFactor = 1.0
const isBoss = targetTierOrBoss === true || targetTierOrBoss === 'boss'
const isPlayer = targetTierOrBoss === 'player'
if (isBoss) {
modifierFactor = 0.5
} else if (isPlayer) {
modifierFactor = isRanged ? 0.125 : 0.25
}
const damagePerFrame = (basePerFrame * modifierFactor) / 256
const damagePerSecond = Math.round(damagePerFrame * 25 * 100) / 100
const totalDamage200Frames = Math.floor(damagePerFrame * 200)
return {
attackerLevel: lvl,
lifeDrainPerFrame: basePerFrame,
damagePerFrame,
damagePerSecond,
totalDamage200Frames,
durationFrames: 200,
durationSeconds: 8.0,
modifierFactor,
isBoss,
isPlayer,
}
}
/**
* 6. Life & Mana Steal (Leech).
*
* Rules:
* - Only applies to the final physical damage dealt to the target.
* - Multiplied by target's Drain Effectiveness (from MonStats.txt, 0% to 100%).
* - Multiplied by game difficulty penalty:
* Normal: 100% (x1.0)
* Nightmare: 50% (x0.5)
* Hell: 33.3% (x1/3)
*/
export interface LeechResult {
readonly lifeLeeched: number
readonly manaLeeched: number
readonly difficultyPenaltyFactor: number
readonly drainEffectivenessFactor: number
readonly rawLifeAttempted: number
readonly rawManaAttempted: number
}
export function calculateLeech(
finalPhysicalDamage: number,
lifeLeechPercent: number,
manaLeechPercent: number,
drainEffectivenessPercent = 100,
difficulty: GameDifficulty = 'normal',
): LeechResult {
if (finalPhysicalDamage <= 0) {
return {
lifeLeeched: 0,
manaLeeched: 0,
difficultyPenaltyFactor: 1.0,
drainEffectivenessFactor: Math.max(0, drainEffectivenessPercent) / 100,
rawLifeAttempted: 0,
rawManaAttempted: 0,
}
}
let diffPenalty = 1.0
if (difficulty === 'nightmare') {
diffPenalty = 0.5
} else if (difficulty === 'hell') {
diffPenalty = 1 / 3
}
const drainFactor = Math.max(0, drainEffectivenessPercent) / 100
const rawLife = finalPhysicalDamage * (Math.max(0, lifeLeechPercent) / 100)
const rawMana = finalPhysicalDamage * (Math.max(0, manaLeechPercent) / 100)
const lifeLeeched = Math.floor(rawLife * drainFactor * diffPenalty)
const manaLeeched = Math.floor(rawMana * drainFactor * diffPenalty)
return {
lifeLeeched,
manaLeeched,
difficultyPenaltyFactor: diffPenalty,
drainEffectivenessFactor: drainFactor,
rawLifeAttempted: Math.round(rawLife * 10) / 10,
rawManaAttempted: Math.round(rawMana * 10) / 10,
}
}
/**
* 7. Freezes Target +X calculation.
*
* Canonical formulas:
* Melee: Chance = clamp(0, 100, 5 * (4 * B + AttackerLevel - DefenderLevel) + 30)
* Ranged: Chance = clamp(0, 100, 5 * (4 * B + (AttackerLevel - 6) - DefenderLevel) / 3)
*
* Target rules:
* - Bosses, Champions, Super Uniques and Players cannot be frozen; they are chilled instead.
* - Duration = (Chance - Roll) * 2 + 25 frames [clamped 25, 250 frames].
* - Duration is halved in Nightmare, quartered in Hell.
*/
export interface FreezeTargetResult {
readonly chancePercent: number
readonly roll: number
readonly willFreeze: boolean
readonly isChilledOnly: boolean
readonly durationFrames: number
readonly durationSeconds: number
}
export function calculateFreezeTarget(
freezeBonus: number,
attackerLevel: number,
defenderLevel: number,
targetTierOrBoss: boolean | TargetTier = 'normal',
isRanged = false,
difficulty: GameDifficulty = 'normal',
randomFn: () => number = Math.random,
): FreezeTargetResult {
if (freezeBonus <= 0) {
return {
chancePercent: 0,
roll: 0,
willFreeze: false,
isChilledOnly: false,
durationFrames: 0,
durationSeconds: 0,
}
}
const B = freezeBonus
const al = attackerLevel
const dl = defenderLevel
let rawChance = 0
if (!isRanged) {
rawChance = 5 * (4 * B + al - dl) + 30
} else {
rawChance = (5 * (4 * B + (al - 6) - dl)) / 3
}
const chancePercent = Math.max(0, Math.min(100, Math.round(rawChance * 10) / 10))
const roll = Math.round(randomFn() * 10000) / 100
const success = roll < chancePercent
if (!success) {
return {
chancePercent,
roll,
willFreeze: false,
isChilledOnly: false,
durationFrames: 0,
durationSeconds: 0,
}
}
const isBossOrPlayer = targetTierOrBoss === true || targetTierOrBoss === 'boss' || targetTierOrBoss === 'player'
if (isBossOrPlayer) {
return {
chancePercent,
roll,
willFreeze: false,
isChilledOnly: true,
durationFrames: 75,
durationSeconds: 3.0,
}
}
let duration = Math.min(250, Math.max(25, Math.floor((chancePercent - roll) * 2 + 25)))
if (difficulty === 'nightmare') {
duration = Math.floor(duration / 2)
} else if (difficulty === 'hell') {
duration = Math.floor(duration / 4)
}
return {
chancePercent,
roll,
willFreeze: true,
isChilledOnly: false,
durationFrames: duration,
durationSeconds: Math.round((duration / 25) * 10) / 10,
}
}
/**
* 8. Resistance Damage Multiplier.
*
* In D2:
* - Resistance >= 100% means Immune (0 damage).
* - Resistance between 0% and 99% reduces damage: Damage * (1 - Res / 100).
* - Resistance < 0% amplifies damage: Damage * (1 + |Res| / 100), capped at -100% (2x damage).
*
* @param resistancePercent - Target's resistance percentage.
* @returns Multiplier factor (0 for immune, >1 for negative resistance) and immunity flag.
*/
export function calculateResistanceFactor(resistancePercent: number): {
readonly factor: number
readonly isImmune: boolean
} {
if (resistancePercent >= 100) {
return { factor: 0, isImmune: true }
}
const clampedRes = Math.max(-100, resistancePercent)
const factor = (100 - clampedRes) / 100
return { factor, isImmune: false }
}
/**
* 8b. Resistance Reduction & 1/5th Rule for Breaking Immunities.
*
* Canonical Diablo II 1.10+ rules:
* - If target's initial resistance >= 100% (Immune):
* - Conviction Aura and Lower Resist Curse have their effectiveness reduced to 1/5th (20%).
* - In integer arithmetic: floor(Penalty / 5).
* - If (InitialRes - EffectiveConviction - EffectiveLowerRes) >= 100%:
* - Immunity is NOT broken.
* - Direct equipment reduction (-% Enemy Resistance, e.g. Rainbow Facet, Griffon's Eye) CANNOT apply!
* - Target remains immune (0 damage).
* - If (InitialRes - EffectiveConviction - EffectiveLowerRes) < 100%:
* - Immunity IS BROKEN!
* - Direct equipment reduction (-% Enemy Resistance) now applies at 100% FULL effectiveness!
* - If target's initial resistance < 100% (Non-Immune):
* - Conviction, Lower Resist, and equipment reduction all apply at 100% full strength.
* - Resistance is capped at a lower bound of -100% (2x damage multiplier).
*/
export interface ResistanceReductionResult {
readonly initialRes: number
readonly wasImmune: boolean
readonly isImmune: boolean
readonly immunityBroken: boolean
readonly convictionPenalty: number
readonly effectiveConviction: number
readonly lowerResPenalty: number
readonly effectiveLowerRes: number
readonly itemMinusRes: number
readonly effectiveItemMinusRes: number
readonly resAfterSkills: number
readonly finalRes: number
readonly damageMultiplier: number
}
export function calculateResistanceReduction(
enemyRes: number,
convictionPenalty = 0,
lowerResPenalty = 0,
itemMinusRes = 0,
): ResistanceReductionResult {
const initialRes = enemyRes
const wasImmune = initialRes >= 100
let effectiveConviction = Math.max(0, convictionPenalty)
let effectiveLowerRes = Math.max(0, lowerResPenalty)
if (wasImmune) {
// 1/5th Rule: skills only have 20% effectiveness against immune monsters
effectiveConviction = Math.floor(effectiveConviction / 5)
effectiveLowerRes = Math.floor(effectiveLowerRes / 5)
}
const resAfterSkills = initialRes - (effectiveConviction + effectiveLowerRes)
const immunityBroken = wasImmune && resAfterSkills < 100
let effectiveItemMinusRes = 0
let isImmune = false
let finalRes = resAfterSkills
if (resAfterSkills >= 100) {
// Immunity persists: equipment -% enemy resistance fails completely
isImmune = true
effectiveItemMinusRes = 0
finalRes = resAfterSkills
} else {
// Non-immune or broken immunity: equipment -% resistance applies at 100% full effect
isImmune = false
effectiveItemMinusRes = Math.max(0, itemMinusRes)
finalRes = Math.max(-100, resAfterSkills - effectiveItemMinusRes)
}
const damageMultiplier = isImmune ? 0 : Math.round(((100 - finalRes) / 100) * 10000) / 10000
return {
initialRes,
wasImmune,
isImmune,
immunityBroken,
convictionPenalty: Math.max(0, convictionPenalty),
effectiveConviction,
lowerResPenalty: Math.max(0, lowerResPenalty),
effectiveLowerRes,
itemMinusRes: Math.max(0, itemMinusRes),
effectiveItemMinusRes,
resAfterSkills,
finalRes,
damageMultiplier,
}
}
/**
* 8c. Monster Life Regeneration & Prevent Monster Heal (PMH).
*
* Canonical D2 mechanics:
* - Natural regeneration per frame:
* Regen/frame = (MaxHP * RegenRate) / 4096
* (RegenRate is from MonStats.txt, typically 1 to 50).
* - At 25 fps:
* Regen/sec = (MaxHP * RegenRate * 25) / 4096
* - Natural regeneration is completely suppressed to 0 if:
* - Target is under Open Wounds (OW) bleeding (lasts 200 frames / 8 seconds).
* - Target is inflicted with Prevent Monster Heal (PMH) (lasts 120,000 frames / 80 minutes).
*/
export interface MonsterRegenResult {
readonly maxHp: number
readonly regenRate: number
readonly rawRegenPerFrame: number
readonly rawRegenPerSecond: number
readonly effectiveRegenPerFrame: number
readonly effectiveRegenPerSecond: number
readonly isBlockedByOW: boolean
readonly isBlockedByPMH: boolean
}
export function calculateMonsterRegeneration(
maxHp: number,
regenRate: number,
hasOW = false,
hasPMH = false,
): MonsterRegenResult {
const hp = Math.max(0, maxHp)
const rate = Math.max(0, regenRate)
const rawRegenPerFrame = (hp * rate) / 4096
const rawRegenPerSecond = Math.round(rawRegenPerFrame * 25 * 100) / 100
const isBlocked = hasOW || hasPMH
const effectiveRegenPerFrame = isBlocked ? 0 : rawRegenPerFrame
const effectiveRegenPerSecond = isBlocked ? 0 : rawRegenPerSecond
return {
maxHp: hp,
regenRate: rate,
rawRegenPerFrame,
rawRegenPerSecond,
effectiveRegenPerFrame,
effectiveRegenPerSecond,
isBlockedByOW: hasOW,
isBlockedByPMH: hasPMH,
}
}
export interface PreventMonsterHealEvaluation {
readonly applied: boolean
readonly durationFrames: number // 120,000 frames = 4800s = 80min
readonly durationSeconds: number
readonly reason: string
}
export function evaluatePreventMonsterHeal(
attackerType: 'player' | 'mercenary' | 'minion' = 'player',
targetTierOrUber: 'normal' | 'champion' | 'boss' | 'uber' = 'normal',
): PreventMonsterHealEvaluation {
if (attackerType !== 'player') {
return {
applied: false,
durationFrames: 0,
durationSeconds: 0,
reason: 'PMH 仅能由玩家本体近战或远程攻击命中触发 (雇佣兵与仆从佩戴无效)',
}
}
if (targetTierOrUber === 'uber') {
return {
applied: false,
durationFrames: 0,
durationSeconds: 0,
reason: '超级地狱火炬 Boss (Pandemonium Ubers) 免疫阻止怪物自愈 (PMH)',
}
}
return {
applied: true,
durationFrames: 120000,
durationSeconds: 4800,
reason: '阻止怪物自愈 (PMH) 命中生效,持续 120,000 帧 (80 分钟)',
}
}
/**
* 8d. Knockback vs Monster Size & Boss Immunity.
*
* Canonical D2 rules:
* - Bosses (Andariel, Duriel, Mephisto, Diablo, Baal, etc.) and Siege Beasts:
* 100% IMMUNE to knockback (0% chance).
* - Non-bosses with Knockback affix:
* - Melee: Small 100%, Medium 50%, Large 25%.
* - Ranged: Small 50%, Medium 25%, Large 12.5%.
*/
export type MonsterSize = 'small' | 'medium' | 'large'
export interface KnockbackEvaluationResult {
readonly hasKnockbackAffix: boolean
readonly isBossOrImmune: boolean
readonly size: MonsterSize
readonly isRanged: boolean
readonly baseSizeChancePercent: number
readonly finalChancePercent: number
readonly roll: number
readonly triggered: boolean
readonly reason: string
}
export function calculateKnockbackSuccess(
hasKnockback: boolean,
defenderSize: MonsterSize = 'medium',
isBossOrImmune = false,
isRanged = false,
randomFn: () => number = Math.random,
): KnockbackEvaluationResult {
if (!hasKnockback) {
return {
hasKnockbackAffix: false,
isBossOrImmune,
size: defenderSize,
isRanged,
baseSizeChancePercent: 0,
finalChancePercent: 0,
roll: 0,
triggered: false,
reason: '攻击未附带击退词缀',
}
}
if (isBossOrImmune) {
return {
hasKnockbackAffix: true,
isBossOrImmune: true,
size: defenderSize,
isRanged,
baseSizeChancePercent: 0,
finalChancePercent: 0,
roll: 0,
triggered: false,
reason: '关底首领 (Boss) 与攻城巨兽完全豁免击退效果',
}
}
let baseChance = 50
if (defenderSize === 'small') {
baseChance = isRanged ? 50 : 100
} else if (defenderSize === 'medium') {
baseChance = isRanged ? 25 : 50
} else {
baseChance = isRanged ? 12.5 : 25
}
const roll = Math.round(randomFn() * 10000) / 100
const triggered = roll < baseChance
return {
hasKnockbackAffix: true,
isBossOrImmune: false,
size: defenderSize,
isRanged,
baseSizeChancePercent: baseChance,
finalChancePercent: baseChance,
roll,
triggered,
reason: triggered
? `体型 ${defenderSize} (${baseChance}%几率) 击退成功 (掷骰 ${roll.toFixed(1)}%)`
: `体型 ${defenderSize} (${baseChance}%几率) 掷骰未触发击退 (掷骰 ${roll.toFixed(1)}%)`,
}
}
/**
* 8e. Slows Target by X% (减速目标).
*
* Rules:
* - Normal monsters: capped at 90%.
* - Bosses / Champions / Super Uniques: capped at 50%.
* - Stacks with Holy Freeze / Cold chill up to caps.
*/
export interface SlowTargetResult {
readonly requestedSlowPercent: number
readonly isBoss: boolean
readonly maxCapPercent: number
readonly effectiveSlowPercent: number
readonly reason: string
}
export function calculateSlowTarget(
requestedSlowPercent: number,
isBoss = false,
): SlowTargetResult {
const req = Math.max(0, requestedSlowPercent)
const maxCapPercent = isBoss ? 50 : 90
const effectiveSlowPercent = Math.min(maxCapPercent, req)
const reason = isBoss && req > 50
? `首领/精英减速上限封顶为 50% (原请求 ${req}%)`
: `减速目标生效: ${effectiveSlowPercent}%`
return {
requestedSlowPercent: req,
isBoss,
maxCapPercent,
effectiveSlowPercent,
reason,
}
}
/**
* 8f. Crowd Control: Hit Blinds Target (HBT) & Hit Causes Monster to Flee (HCMTF).
*
* Hit Blinds Target:
* - Melee: Chance = clamp(1, 100, 50 + (AttackerLevel + Bonus * 4 - TargetLevel) * 2)
* - Ranged: Chance = clamp(1, 100, (50 + (AttackerLevel + Bonus * 4 - TargetLevel) * 2) / 3)
* - Bosses / Champions / Uniques are IMMUNE.
* - Duration: (Bonus * 4 + 4) seconds.
*
* Hit Causes Monster to Flee:
* - Fixed 125 frames (5.0 seconds).
* - Bosses and Champions are IMMUNE.
*/
export interface HitBlindsTargetResult {
readonly bonus: number
readonly attackerLevel: number
readonly targetLevel: number
readonly isBossOrChampion: boolean
readonly isRanged: boolean
readonly chancePercent: number
readonly roll: number
readonly triggered: boolean
readonly durationFrames: number
readonly durationSeconds: number
readonly reason: string
}
export function calculateHitBlindsTarget(
bonus: number,
attackerLevel: number,
targetLevel: number,
isBossOrChampion = false,
isRanged = false,
randomFn: () => number = Math.random,
): HitBlindsTargetResult {
if (bonus <= 0) {
return {
bonus: 0,
attackerLevel,
targetLevel,
isBossOrChampion,
isRanged,
chancePercent: 0,
roll: 0,
triggered: false,
durationFrames: 0,
durationSeconds: 0,
reason: '无致盲词缀 (Hit Blinds Target)',
}
}
if (isBossOrChampion) {
return {
bonus,
attackerLevel,
targetLevel,
isBossOrChampion: true,
isRanged,
chancePercent: 0,
roll: 0,
triggered: false,
durationFrames: 0,
durationSeconds: 0,
reason: '首领、暗金怪与冠军怪免疫致盲效果',
}
}
const rawMeleeChance = 50 + (attackerLevel + bonus * 4 - targetLevel) * 2
const rawChance = isRanged ? rawMeleeChance / 3 : rawMeleeChance
const chancePercent = Math.max(1, Math.min(100, Math.round(rawChance * 10) / 10))
const roll = Math.round(randomFn() * 10000) / 100
const triggered = roll < chancePercent
const durationSeconds = bonus * 4 + 4
const durationFrames = durationSeconds * 25
return {
bonus,
attackerLevel,
targetLevel,
isBossOrChampion: false,
isRanged,
chancePercent,
roll,
triggered,
durationFrames: triggered ? durationFrames : 0,
durationSeconds: triggered ? durationSeconds : 0,
reason: triggered
? `致盲命中!使目标盲目 (微暗灵视) 持续 ${durationSeconds} 秒 (${chancePercent}%几率)`
: `致盲未命中 (${chancePercent}%几率, 掷骰 ${roll.toFixed(1)}%)`,
}
}
export interface HitCausesFleeResult {
readonly fleeChancePercent: number
readonly isBossOrChampion: boolean
readonly roll: number
readonly triggered: boolean
readonly durationFrames: number
readonly durationSeconds: number
readonly reason: string
}
export function calculateHitCausesFlee(
fleeChancePercent: number,
isBossOrChampion = false,
randomFn: () => number = Math.random,
): HitCausesFleeResult {
const chance = Math.max(0, Math.min(100, fleeChancePercent))
if (chance <= 0) {
return {
fleeChancePercent: 0,
isBossOrChampion,
roll: 0,
triggered: false,
durationFrames: 0,
durationSeconds: 0,
reason: '无恐吓逃跑词缀',
}
}
if (isBossOrChampion) {
return {
fleeChancePercent: chance,
isBossOrChampion: true,
roll: 0,
triggered: false,
durationFrames: 0,
durationSeconds: 0,
reason: '首领与冠军怪物免疫恐吓逃跑效果',
}
}
const roll = Math.round(randomFn() * 10000) / 100
const triggered = roll < chance
const durationFrames = triggered ? 125 : 0
const durationSeconds = triggered ? 5.0 : 0
return {
fleeChancePercent: chance,
isBossOrChampion: false,
roll,
triggered,
durationFrames,
durationSeconds,
reason: triggered
? `恐吓逃跑生效!怪物陷入恐惧逃窜 5 秒 (几率 ${chance}%)`
: `恐吓未触发 (几率 ${chance}%, 掷骰 ${roll.toFixed(1)}%)`,
}
}
/**
* 8g. Physical Damage Decomposition (Off-Weapon ED & Flat +Damage / Grief).
*
* Canonical Formula:
* BaseMin = floor(WeaponBaseMin * (1 + OnWeaponED / 100)) + FlatDamage
* BaseMax = floor(WeaponBaseMax * (1 + OnWeaponED / 100)) + FlatDamage
* TotalPhysMin = floor(BaseMin * (1 + (OffWeaponED + StatBonus) / 100))
* TotalPhysMax = floor(BaseMax * (1 + (OffWeaponED + StatBonus) / 100))
*/
export interface PhysicalDamageComponents {
readonly weaponBaseMin: number
readonly weaponBaseMax: number
readonly onWeaponEdPercent?: number | undefined
readonly flatDamage?: number | undefined
readonly offWeaponEdPercent?: number | undefined
readonly statBonusPercent?: number | undefined
}
export interface PhysicalDamageRangeResult {
readonly weaponBaseMin: number
readonly weaponBaseMax: number
readonly onWeaponEdPercent: number
readonly baseMinWithWeaponEd: number
readonly baseMaxWithWeaponEd: number
readonly flatDamage: number
readonly baseMinWithFlat: number
readonly baseMaxWithFlat: number
readonly offWeaponEdPercent: number
readonly statBonusPercent: number
readonly totalMultiplier: number
readonly finalMinDamage: number
readonly finalMaxDamage: number
}
export function calculatePhysicalWeaponDamage(
components: PhysicalDamageComponents,
): PhysicalDamageRangeResult {
const wMin = Math.max(0, components.weaponBaseMin)
const wMax = Math.max(wMin, components.weaponBaseMax)
const onEd = Math.max(0, components.onWeaponEdPercent ?? 0)
const flat = Math.max(0, components.flatDamage ?? 0)
const offEd = Math.max(0, components.offWeaponEdPercent ?? 0)
const statBonus = Math.max(0, components.statBonusPercent ?? 0)
const baseMinWithWeaponEd = Math.floor(wMin * (1 + onEd / 100))
const baseMaxWithWeaponEd = Math.floor(wMax * (1 + onEd / 100))
const baseMinWithFlat = baseMinWithWeaponEd + flat
const baseMaxWithFlat = baseMaxWithWeaponEd + flat
const totalMultiplier = 1 + (offEd + statBonus) / 100
const finalMinDamage = Math.floor(baseMinWithFlat * totalMultiplier)
const finalMaxDamage = Math.floor(baseMaxWithFlat * totalMultiplier)
return {
weaponBaseMin: wMin,
weaponBaseMax: wMax,
onWeaponEdPercent: onEd,
baseMinWithWeaponEd,
baseMaxWithWeaponEd,
flatDamage: flat,
baseMinWithFlat,
baseMaxWithFlat,
offWeaponEdPercent: offEd,
statBonusPercent: statBonus,
totalMultiplier: Math.round(totalMultiplier * 1000) / 1000,
finalMinDamage,
finalMaxDamage,
}
}
/**
* 8h. Poison Bit Rate & Duration Averaging Stacking.
*
* In D2:
* - Poison rate is in 1/256th damage per frame.
* - Stacking across items:
* TotalBitRate = sum(BitRates)
* AvgDuration = sum(Durations) / N
* TotalDamage = floor((TotalBitRate * AvgDuration) / 256)
*/
export interface PoisonSource {
readonly damage: number
readonly durationSeconds?: number | undefined
readonly durationFrames?: number | undefined
}
export interface PoisonStackResult {
readonly totalBitRate: number
readonly averageDurationFrames: number
readonly averageDurationSeconds: number
readonly totalPoisonDamage: number
readonly damagePerSecond: number
readonly sourcesCount: number
}
export function calculatePoisonStack(sources: readonly PoisonSource[]): PoisonStackResult {
if (sources.length === 0) {
return {
totalBitRate: 0,
averageDurationFrames: 0,
averageDurationSeconds: 0,
totalPoisonDamage: 0,
damagePerSecond: 0,
sourcesCount: 0,
}
}
let sumBitRate = 0
let sumFrames = 0
let validCount = 0
for (const src of sources) {
const frames = src.durationFrames ?? ((src.durationSeconds ?? 0) * 25)
if (frames > 0 && src.damage > 0) {
const bitRate = (src.damage * 256) / frames
sumBitRate += bitRate
sumFrames += frames
validCount += 1
}
}
if (validCount === 0) {
return {
totalBitRate: 0,
averageDurationFrames: 0,
averageDurationSeconds: 0,
totalPoisonDamage: 0,
damagePerSecond: 0,
sourcesCount: 0,
}
}
const averageDurationFrames = Math.round(sumFrames / validCount)
const averageDurationSeconds = Math.round((averageDurationFrames / 25) * 10) / 10
const totalPoisonDamage = Math.floor((sumBitRate * averageDurationFrames) / 256)
const damagePerSecond = Math.round(((sumBitRate * 25) / 256) * 100) / 100
return {
totalBitRate: Math.round(sumBitRate),
averageDurationFrames,
averageDurationSeconds,
totalPoisonDamage,
damagePerSecond,
sourcesCount: validCount,
}
}
/**
* 9. Hit Recovery / Stun Check.
*
* In D2:
* When a hit inflicts damage > MaxHP / 16:
* - Damage >= MaxHP / 4: 100% chance to enter hit recovery.
* - Damage >= MaxHP / 8: 50% chance to enter hit recovery.
* - Damage >= MaxHP / 16: 25% chance to enter hit recovery.
* - Damage < MaxHP / 16: 0% chance.
*
* @param damageDealt - Net damage received.
* @param maxHp - Defender's maximum HP.
* @param roll0to1 - Random float in [0, 1) (defaults to Math.random()).
* @returns Whether hit recovery is triggered, and the trigger chance.
*/
export function checkHitRecovery(
damageDealt: number,
maxHp: number,
roll0to1 = Math.random(),
): { readonly triggered: boolean; readonly chancePercent: number } {
if (maxHp <= 0 || damageDealt <= 0) {
return { triggered: false, chancePercent: 0 }
}
const ratio = damageDealt / maxHp
let chancePercent = 0
if (ratio >= 0.25) {
chancePercent = 100
} else if (ratio >= 0.125) {
chancePercent = 50
} else if (ratio >= 0.0625) {
chancePercent = 25
}
const triggered = chancePercent > 0 && roll0to1 * 100 < chancePercent
return { triggered, chancePercent }
}
/**
* Elemental Damage specification for an attack.
*/
export interface AttackDamageSpec {
/** Min physical damage rolled from weapon/skill/strength. */
readonly physicalMin: number
/** Max physical damage. */
readonly physicalMax: number
/** Fire damage. */
readonly fire?: number
/** Lightning damage. */
readonly lightning?: number
/** Cold damage (also chills). */
readonly cold?: number
/** Cold chill duration in frames/seconds. */
readonly coldChillDuration?: number
/** Poison damage (dealt over duration). */
readonly poison?: number
/** Magic damage (e.g. Berserk, Bone Spear). */
readonly magic?: number
}
/**
* Target's defensive specification.
*/
export interface DefenderDefenseSpec {
/** Target Level. */
readonly level: number
/** Defense Rating (DR / AC). */
readonly defense: number
/** Block chance % [0, 75]. */
readonly blockPercent: number
/** Flat physical damage reduced (DR). */
readonly flatDamageReduction?: number | undefined
/** Flat magic damage reduced (MDR). */
readonly flatMagicReduction?: number | undefined
/** Physical resistance % (also known as %DR). */
readonly resPhysical?: number | undefined
/** Fire resistance %. */
readonly resFire?: number | undefined
/** Lightning resistance %. */
readonly resLightning?: number | undefined
/** Cold resistance %. */
readonly resCold?: number | undefined
/** Poison resistance %. */
readonly resPoison?: number | undefined
/** Magic resistance %. */
readonly resMagic?: number | undefined
/** Current HP (for crushing blow). */
readonly currentHp: number
/** Max HP (for hit recovery check). */
readonly maxHp: number
/** Target classification tier. */
readonly tier?: TargetTier | undefined
/** Monster physical size ('small', 'medium', 'large'). */
readonly size?: MonsterSize | undefined
/** Natural life regeneration rate from MonStats.txt. */
readonly regenRate?: number | undefined
/** Whether target is a Pandemonium Uber (immune to PMH). */
readonly isUber?: boolean | undefined
/** Whether the defender is a boss or champion (backwards-compatibility). */
readonly isBoss?: boolean | undefined
/** Whether defender is currently running (DR=0 and 1/3 block cap 25%). */
readonly isRunning?: boolean | undefined
/** Drain effectiveness % (0–100%, 0% for skeletons/hell bosses). */
readonly drainEffectiveness?: number | undefined
}
/**
* Additional combat calculation options.
*/
export interface CombatOptions {
readonly criticalStrikePercent?: number | undefined
readonly deadlyStrikePercent?: number | undefined
readonly crushingBlowPercent?: number | undefined
readonly openWoundsPercent?: number | undefined
readonly lifeLeechPercent?: number | undefined
readonly manaLeechPercent?: number | undefined
readonly ignoreTargetDefense?: boolean | undefined
readonly targetDefenseReductionPercent?: number | undefined
readonly freezeBonus?: number | undefined
readonly knockbackPercent?: number | undefined
readonly difficulty?: GameDifficulty | undefined
readonly isSpell?: boolean | undefined
readonly isRanged?: boolean | undefined
// PvE Depth Options (Issue #39)
readonly defenderSize?: MonsterSize | undefined
readonly convictionPenalty?: number | undefined
readonly lowerResPenalty?: number | undefined
readonly itemMinusPhysRes?: number | undefined
readonly itemMinusFireRes?: number | undefined
readonly itemMinusLightRes?: number | undefined
readonly itemMinusColdRes?: number | undefined
readonly itemMinusPoisonRes?: number | undefined
readonly slowPercent?: number | undefined
readonly hitBlindsTargetBonus?: number | undefined
readonly hitCausesFleePercent?: number | undefined
readonly preventMonsterHeal?: boolean | undefined
readonly attackerType?: 'player' | 'mercenary' | 'minion' | undefined
}
/**
* Full breakdown of an attack calculation for logging and inspection.
*/
export interface CombatCalculationResult {
readonly attackerAr: number
readonly defenderDr: number
readonly hitChancePercent: number
readonly hitRoll: number
readonly isHit: boolean
readonly isBlocked: boolean
readonly defenderBlockPercent: number
readonly isCritical: boolean
readonly criticalTrigger: 'none' | 'critical_strike' | 'deadly_strike'
readonly isCrushingBlow: boolean
readonly crushingBlowDamage: number
readonly isOpenWounds: boolean
readonly openWoundsResult?: OpenWoundsDamageResult | undefined
readonly lifeLeeched: number
readonly manaLeeched: number
readonly freezeResult?: FreezeTargetResult | undefined
readonly knockbackTriggered: boolean
readonly knockbackResult?: KnockbackEvaluationResult | undefined
readonly slowResult?: SlowTargetResult | undefined
readonly blindResult?: HitBlindsTargetResult | undefined
readonly fleeResult?: HitCausesFleeResult | undefined
readonly pmhResult?: PreventMonsterHealEvaluation | undefined
readonly monsterRegenResult?: MonsterRegenResult | undefined
readonly resistanceBreakResults?: Record<string, ResistanceReductionResult> | undefined
readonly rawPhysicalDamage: number
readonly finalPhysicalDamage: number
readonly finalFireDamage: number
readonly finalLightningDamage: number
readonly finalColdDamage: number
readonly finalPoisonDamage: number
readonly finalMagicDamage: number
readonly totalDamage: number
readonly immunities: readonly string[]
readonly hitRecoveryTriggered: boolean
readonly hitRecoveryChance: number
readonly log: readonly string[]
}
/**
* Execute a complete Diablo II attack calculation with full mathematical provenance.
*
* @param attackerLevel - Attacker level.
* @param attackerAr - Attacker Attack Rating (0 to bypass AR check, e.g. for spells).
* @param damageSpec - Attack damage components.
* @param defender - Defender attributes and defenses.
* @param options - Additional proc chances, modifiers, and difficulty.
* @param randomFn - Random generator function returning [0, 1).
* @returns Detailed combat result.
*/
export function calculateAttack(
attackerLevel: number,
attackerAr: number,
damageSpec: AttackDamageSpec,
defender: DefenderDefenseSpec,
options: CombatOptions = {},
randomFn: () => number = Math.random,
): CombatCalculationResult {
const log: string[] = []
const immunities: string[] = []
const resistanceBreakResults: Record<string, ResistanceReductionResult> = {}
const isBossOrChampion = defender.isBoss ?? (defender.tier === 'boss')
const targetTier: TargetTier = defender.tier ?? (defender.isBoss ? 'boss' : 'normal')
const isRunning = defender.isRunning ?? false
const isSpell = options.isSpell ?? false
const isRanged = options.isRanged ?? false
const difficulty = options.difficulty ?? 'normal'
// 1. To-Hit Check
let isHit = true
let hitChancePercent = 100
const hitRoll = Math.round(randomFn() * 10000) / 100
if (!isSpell) {
hitChancePercent = calculateHitChance(
attackerAr,
defender.defense,
attackerLevel,
defender.level,
{
ignoreTargetDefense: options.ignoreTargetDefense,
targetDefenseReductionPercent: options.targetDefenseReductionPercent,
isTargetRunning: isRunning,
isBossOrChampion,
},
)
isHit = hitRoll < hitChancePercent
const itdNote = options.ignoreTargetDefense
? isBossOrChampion
? ' [ITD 对暗金/Boss无效]'
: ' [ITD 生效: 目标防御降为0]'
: ''
const defRedNote = (options.targetDefenseReductionPercent ?? 0) > 0
? isBossOrChampion
? ` [-%Def 衰减折半: -${(options.targetDefenseReductionPercent! / 2).toFixed(1)}%]`
: ` [-%Def 全额生效: -${options.targetDefenseReductionPercent}%]`
: ''
const runNote = isRunning ? ' [目标奔跑: 防御视为0]' : ''
log.push(
`[1. 命中率判定 (AR vs DR)]: 攻击者 AR=${attackerAr} (Lv${attackerLevel}) 对抗 防御者 DR=${defender.defense} (Lv${defender.level})` +
`${itdNote}${defRedNote}${runNote} => 最终命中率: ${hitChancePercent.toFixed(1)}% (范围 5%–95%)。掷骰: ${hitRoll.toFixed(1)}% => ${isHit ? '✓ 命中' : '✗ 未命中 (MISS)'}`,
)
} else {
log.push(`[1. 法术技能检查]: 法术攻击无视护甲与命中率 (100% 必定命中)`)
}
if (!isHit) {
return {
attackerAr,
defenderDr: defender.defense,
hitChancePercent,
hitRoll,
isHit: false,
isBlocked: false,
defenderBlockPercent: defender.blockPercent,
isCritical: false,
criticalTrigger: 'none',
isCrushingBlow: false,
crushingBlowDamage: 0,
isOpenWounds: false,
lifeLeeched: 0,
manaLeeched: 0,
knockbackTriggered: false,
rawPhysicalDamage: 0,
finalPhysicalDamage: 0,
finalFireDamage: 0,
finalLightningDamage: 0,
finalColdDamage: 0,
finalPoisonDamage: 0,
finalMagicDamage: 0,
totalDamage: 0,
immunities: [],
hitRecoveryTriggered: false,
hitRecoveryChance: 0,
log,
}
}
// 2. Shield Blocking Check
let isBlocked = false
let defenderBlock = defender.blockPercent
if (isRunning) {
defenderBlock = Math.max(0, Math.min(25, Math.floor(defenderBlock / 3)))
}
if (!isSpell && defenderBlock > 0) {
const blockRoll = Math.round(randomFn() * 10000) / 100
isBlocked = blockRoll < defenderBlock
log.push(
`[2. 格挡判定]: 防御者有效格挡率: ${defenderBlock}%${isRunning ? ' (奔跑削减至1/3且上限25%)' : ''},掷骰: ${blockRoll.toFixed(1)}% => ${isBlocked ? '🛡️ 格挡成功 (伤害完全抵消 0)' : '格挡失败'}`,
)
}
if (isBlocked) {
return {
attackerAr,
defenderDr: defender.defense,
hitChancePercent,
hitRoll,
isHit: true,
isBlocked: true,
defenderBlockPercent: defenderBlock,
isCritical: false,
criticalTrigger: 'none',
isCrushingBlow: false,
crushingBlowDamage: 0,
isOpenWounds: false,
lifeLeeched: 0,
manaLeeched: 0,
knockbackTriggered: false,
rawPhysicalDamage: 0,
finalPhysicalDamage: 0,
finalFireDamage: 0,
finalLightningDamage: 0,
finalColdDamage: 0,
finalPoisonDamage: 0,
finalMagicDamage: 0,
totalDamage: 0,
immunities: [],
hitRecoveryTriggered: false,
hitRecoveryChance: 0,
log,
}
}
// 3. Critical Strike (CS) & Deadly Strike (DS) Check
const critResult = evaluateCriticalAndDeadlyStrike(
options.criticalStrikePercent ?? 0,
options.deadlyStrikePercent ?? 0,
randomFn,
)
if (!isSpell && (options.criticalStrikePercent || options.deadlyStrikePercent)) {
if (critResult.triggeredBy === 'critical_strike') {
log.push(
`[3. 暴击判定 (CS/DS)]: 技能致命一击 (CS ${options.criticalStrikePercent}%) 命中掷骰 ${critResult.csRoll.toFixed(1)}% => ⚡ 触发致命一击 (物理伤害 x2)`,
)
} else if (critResult.triggeredBy === 'deadly_strike') {
log.push(
`[3. 暴击判定 (CS/DS)]: CS 未触发 (掷骰 ${critResult.csRoll.toFixed(1)}%) -> 装备双倍打击 (DS ${options.deadlyStrikePercent}%) 命中掷骰 ${critResult.dsRoll.toFixed(1)}% => ⚡ 触发双倍打击 (物理伤害 x2)`,
)
} else {
log.push(
`[3. 暴击判定 (CS/DS)]: 综合双倍几率 ${critResult.combinedChancePercent.toFixed(1)}%,掷骰未触发双倍打击`,
)
}
}
// 4. Crushing Blow (CB) Check
const cbChance = options.crushingBlowPercent ?? 0
const cbRoll = Math.round(randomFn() * 10000) / 100
const isCrushingBlow = !isSpell && cbChance > 0 && cbRoll < cbChance
let crushingBlowDamage = 0
if (isCrushingBlow) {
const physRes = defender.resPhysical ?? 0
crushingBlowDamage = calculateCrushingBlowDamage(
defender.currentHp,
targetTier,
isRanged,
physRes,
)
const resNote = physRes >= 100
? ' [目标物理免疫: CB 伤害为 0]'
: physRes > 0
? ` [受物抗 ${physRes}% 削减]`
: physRes < 0
? ` [负物抗不增伤, 封顶100%]`
: ''
log.push(
`[4. 压碎性打击 (CB)]: 几率 ${cbChance}% 命中 (掷骰 ${cbRoll.toFixed(1)}%)!当前生命 ${defender.currentHp} 削减 ${crushingBlowDamage} HP${resNote}`,
)
}
// 5. Open Wounds (OW) Check
const owChance = options.openWoundsPercent ?? 0
const owRoll = Math.round(randomFn() * 10000) / 100
const isOpenWounds = !isSpell && owChance > 0 && owRoll < owChance
let openWoundsResult: OpenWoundsDamageResult | undefined
if (isOpenWounds) {
openWoundsResult = calculateOpenWoundsDamage(attackerLevel, targetTier, isRanged)
log.push(
`[5. 撕开伤口 (OW)]: 几率 ${owChance}% 命中 (掷骰 ${owRoll.toFixed(1)}%)!持续流血 200 帧 (8秒),` +
`每秒流失 ${openWoundsResult.damagePerSecond} HP (总计 ${openWoundsResult.totalDamage200Frames} HP),并禁止目标自愈!`,
)
}
// 6. Roll Raw Physical Damage
const pMin = damageSpec.physicalMin
const pMax = Math.max(pMin, damageSpec.physicalMax)
let rawPhysical = pMin + Math.round(randomFn() * (pMax - pMin))
if (critResult.isCritical) {
rawPhysical *= 2
}
// Note: CB damage is physical, added directly after critical doubling
rawPhysical += crushingBlowDamage
// 7. Apply Resistances & Reductions
// 7a. Physical: Flat DR -> %DR (Resistance with -% Enemy Phys Res)
const physBreak = calculateResistanceReduction(
defender.resPhysical ?? 0,
0,
0,
options.itemMinusPhysRes ?? 0,
)
resistanceBreakResults.physical = physBreak
if (physBreak.isImmune) immunities.push('Physical')
const flatDr = defender.flatDamageReduction ?? 0
let finalPhysicalDamage = Math.max(0, (rawPhysical - flatDr) * physBreak.damageMultiplier)
finalPhysicalDamage = Math.round(finalPhysicalDamage * 10) / 10
log.push(
`[6. 物理伤害结算]: 原始物理 ${rawPhysical} (基准 [${pMin}..${pMax}]${critResult.isCritical ? ' 暴击x2' : ''}${isCrushingBlow ? ' +CB' : ''}) ` +
`- 减伤(DR) ${flatDr} -> 物抗 ${physBreak.finalRes}% (因子 x${physBreak.damageMultiplier.toFixed(2)}${physBreak.isImmune ? ' 物理免疫' : ''}) => 最终物理伤害: ${finalPhysicalDamage}`,
)
// 7b. Fire (with Conviction & Lower Resist 1/5th rule + Equipment -% Fire Res)
const flatMdr = defender.flatMagicReduction ?? 0
let finalFire = 0
if ((damageSpec.fire ?? 0) > 0) {
const fireBreak = calculateResistanceReduction(
defender.resFire ?? 0,
options.convictionPenalty ?? 0,
options.lowerResPenalty ?? 0,
options.itemMinusFireRes ?? 0,
)
resistanceBreakResults.fire = fireBreak
if (fireBreak.isImmune) immunities.push('Fire')
finalFire = Math.max(0, Math.round((Math.max(0, damageSpec.fire! - flatMdr) * fireBreak.damageMultiplier) * 10) / 10)
const breakNote = fireBreak.immunityBroken
? ` [⚡ 1/5法则破免成功! 原${fireBreak.initialRes}% -> 降为${fireBreak.finalRes}%]`
: fireBreak.wasImmune && fireBreak.isImmune
? ` [🛡️ 减抗未能破免! 原${fireBreak.initialRes}% -> ${fireBreak.finalRes}%仍>=100%]`
: ` -> 火抗 ${fireBreak.finalRes}%`
log.push(
`[7. 火焰伤害]: 基础火伤 ${damageSpec.fire} - MDR ${flatMdr}${breakNote} (因子 x${fireBreak.damageMultiplier.toFixed(2)}${fireBreak.isImmune ? ' 免疫' : ''}) => 净火伤: ${finalFire}`,
)
}
// 7c. Lightning (with Conviction & Lower Resist 1/5th rule + Equipment -% Light Res)
let finalLightning = 0
if ((damageSpec.lightning ?? 0) > 0) {
const lightBreak = calculateResistanceReduction(
defender.resLightning ?? 0,
options.convictionPenalty ?? 0,
options.lowerResPenalty ?? 0,
options.itemMinusLightRes ?? 0,
)
resistanceBreakResults.lightning = lightBreak
if (lightBreak.isImmune) immunities.push('Lightning')
finalLightning = Math.max(0, Math.round((Math.max(0, damageSpec.lightning! - flatMdr) * lightBreak.damageMultiplier) * 10) / 10)
const breakNote = lightBreak.immunityBroken
? ` [⚡ 1/5法则破免成功! 原${lightBreak.initialRes}% -> 降为${lightBreak.finalRes}%]`
: lightBreak.wasImmune && lightBreak.isImmune
? ` [🛡️ 减抗未能破免! 原${lightBreak.initialRes}% -> ${lightBreak.finalRes}%仍>=100%]`
: ` -> 电抗 ${lightBreak.finalRes}%`
log.push(
`[8. 闪电伤害]: 基础电伤 ${damageSpec.lightning} - MDR ${flatMdr}${breakNote} (因子 x${lightBreak.damageMultiplier.toFixed(2)}${lightBreak.isImmune ? ' 免疫' : ''}) => 净电伤: ${finalLightning}`,
)
}
// 7d. Cold (with Conviction & Lower Resist 1/5th rule + Equipment -% Cold Res)
let finalCold = 0
if ((damageSpec.cold ?? 0) > 0) {
const coldBreak = calculateResistanceReduction(
defender.resCold ?? 0,
options.convictionPenalty ?? 0,
options.lowerResPenalty ?? 0,
options.itemMinusColdRes ?? 0,
)
resistanceBreakResults.cold = coldBreak
if (coldBreak.isImmune) immunities.push('Cold')
finalCold = Math.max(0, Math.round((Math.max(0, damageSpec.cold! - flatMdr) * coldBreak.damageMultiplier) * 10) / 10)
const breakNote = coldBreak.immunityBroken
? ` [⚡ 1/5法则破免成功! 原${coldBreak.initialRes}% -> 降为${coldBreak.finalRes}%]`
: coldBreak.wasImmune && coldBreak.isImmune
? ` [🛡️ 减抗未能破免! 原${coldBreak.initialRes}% -> ${coldBreak.finalRes}%仍>=100%]`
: ` -> 冰抗 ${coldBreak.finalRes}%`
log.push(
`[9. 冰冷伤害]: 基础冰伤 ${damageSpec.cold} - MDR ${flatMdr}${breakNote} (因子 x${coldBreak.damageMultiplier.toFixed(2)}${coldBreak.isImmune ? ' 免疫' : ''}) => 净冰伤: ${finalCold}`,
)
}
// 7e. Poison (Conviction does NOT affect poison; Lower Resist DOES affect poison)
let finalPoison = 0
if ((damageSpec.poison ?? 0) > 0) {
const poisonBreak = calculateResistanceReduction(
defender.resPoison ?? 0,
0, // Conviction has no effect on Poison
options.lowerResPenalty ?? 0,
options.itemMinusPoisonRes ?? 0,
)
resistanceBreakResults.poison = poisonBreak
if (poisonBreak.isImmune) immunities.push('Poison')
finalPoison = Math.max(0, Math.round((damageSpec.poison! * poisonBreak.damageMultiplier) * 10) / 10)
const breakNote = poisonBreak.immunityBroken
? ` [⚡ 诅咒破毒免成功! 原${poisonBreak.initialRes}% -> 降为${poisonBreak.finalRes}%]`
: poisonBreak.wasImmune && poisonBreak.isImmune
? ` [🛡️ 减抗未能破毒免! 原${poisonBreak.initialRes}% -> ${poisonBreak.finalRes}%仍>=100%]`
: ` -> 毒抗 ${poisonBreak.finalRes}%`
log.push(
`[10. 毒素伤害]: 基础毒伤 ${damageSpec.poison}${breakNote} (因子 x${poisonBreak.damageMultiplier.toFixed(2)}${poisonBreak.isImmune ? ' 免疫' : ''}) => 净毒伤: ${finalPoison}`,
)
}
// 7f. Magic
let finalMagic = 0
if ((damageSpec.magic ?? 0) > 0) {
const magicRes = defender.resMagic ?? 0
const factor = calculateResistanceFactor(magicRes)
if (factor.isImmune) immunities.push('Magic')
finalMagic = Math.max(0, Math.round((Math.max(0, damageSpec.magic! - flatMdr) * factor.factor) * 10) / 10)
log.push(
`[11. 纯魔法伤害]: 基础魔伤 ${damageSpec.magic} - MDR ${flatMdr} -> 魔抗 ${magicRes}% (因子 x${factor.factor.toFixed(2)}${factor.isImmune ? ' 免疫' : ''}) => 净魔伤: ${finalMagic}`,
)
}
// 8. Life & Mana Leech
const leechResult = calculateLeech(
finalPhysicalDamage,
options.lifeLeechPercent ?? 0,
options.manaLeechPercent ?? 0,
defender.drainEffectiveness ?? 100,
difficulty,
)
if (!isSpell && ((options.lifeLeechPercent ?? 0) > 0 || (options.manaLeechPercent ?? 0) > 0)) {
const drainEff = defender.drainEffectiveness ?? 100
log.push(
`[12. 偷取生命/法力]: 汲取效率 ${drainEff}%, 难度惩罚 x${leechResult.difficultyPenaltyFactor.toFixed(2)} ` +
`=> 实际偷取生命 +${leechResult.lifeLeeched} HP, 偷取法力 +${leechResult.manaLeeched} MP`,
)
}
// 9. Freezes Target & Knockback (Size-based & Boss Immunity)
let freezeResult: FreezeTargetResult | undefined
if (!isSpell && (options.freezeBonus ?? 0) > 0) {
freezeResult = calculateFreezeTarget(
options.freezeBonus!,
attackerLevel,
defender.level,
targetTier,
isRanged,
difficulty,
randomFn,
)
if (freezeResult.willFreeze) {
log.push(
`[13. 冻结目标 (Freezes Target)]: 冻结几率 ${freezeResult.chancePercent}% (掷骰 ${freezeResult.roll.toFixed(1)}%) => ❄️ 目标被冰冻 ${freezeResult.durationSeconds} 秒 (${freezeResult.durationFrames} 帧)!`,
)
} else if (freezeResult.isChilledOnly) {
log.push(
`[13. 冻结目标 (Freezes Target)]: 目标为 Boss/精英,免疫完全冰冻,降级为减速减速 (Chill) ${freezeResult.durationSeconds} 秒!`,
)
}
}
const kbChance = options.knockbackPercent ?? 0
const defenderSize = defender.size ?? options.defenderSize ?? 'medium'
const kbResult = calculateKnockbackSuccess(
kbChance > 0,
defenderSize,
isBossOrChampion,
isRanged,
randomFn,
)
const knockbackTriggered = !isSpell && kbResult.triggered
if (kbChance > 0) {
log.push(`[14. 击退判定 (Knockback)]: ${kbResult.reason}`)
}
// 10. Crowd Control: Slow Target, Blinds, Causes Flee
let slowResult: SlowTargetResult | undefined
if ((options.slowPercent ?? 0) > 0) {
slowResult = calculateSlowTarget(options.slowPercent!, isBossOrChampion)
log.push(`[15. 减速目标 (Slow)]: ${slowResult.reason}`)
}
let blindResult: HitBlindsTargetResult | undefined
if (!isSpell && (options.hitBlindsTargetBonus ?? 0) > 0) {
blindResult = calculateHitBlindsTarget(
options.hitBlindsTargetBonus!,
attackerLevel,
defender.level,
isBossOrChampion,
isRanged,
randomFn,
)
log.push(`[16. 使目标盲目 (HBT)]: ${blindResult.reason}`)
}
let fleeResult: HitCausesFleeResult | undefined
if (!isSpell && (options.hitCausesFleePercent ?? 0) > 0) {
fleeResult = calculateHitCausesFlee(
options.hitCausesFleePercent!,
isBossOrChampion,
randomFn,
)
log.push(`[17. 恐吓逃跑 (HCMTF)]: ${fleeResult.reason}`)
}
// 11. Prevent Monster Heal (PMH) & Monster Regeneration Evaluation
let pmhResult: PreventMonsterHealEvaluation | undefined
if (!isSpell && options.preventMonsterHeal) {
const targetKind = defender.isUber ? 'uber' : (isBossOrChampion ? 'boss' : 'normal')
pmhResult = evaluatePreventMonsterHeal(options.attackerType ?? 'player', targetKind)
log.push(`[18. 阻止怪物自愈 (PMH)]: ${pmhResult.reason}`)
}
const monsterRegenResult = calculateMonsterRegeneration(
defender.maxHp,
defender.regenRate ?? 0,
isOpenWounds,
pmhResult?.applied ?? false,
)
const totalDamage = Math.round((finalPhysicalDamage + finalFire + finalLightning + finalCold + finalPoison + finalMagic) * 10) / 10
log.push(
`[19. 最终伤害汇总]: 单次打击扣减 = ${totalDamage} HP (目标剩余生命: ${Math.max(0, Math.round((defender.currentHp - totalDamage) * 10) / 10)} / ${defender.maxHp})`,
)
// 12. Hit Recovery (Stun) Check
const recovery = checkHitRecovery(totalDamage, defender.maxHp, randomFn())
if (recovery.triggered) {
log.push(`[20. 受击硬直 (Hit Recovery)]: 单次伤害达到触发阈值 (几率 ${recovery.chancePercent}%) => 💥 触发硬直后仰!`)
} else if (recovery.chancePercent > 0) {
log.push(`[20. 受击硬直 (Hit Recovery)]: 伤害达到阈值 (几率 ${recovery.chancePercent}%),掷骰通过,未陷入硬直`)
}
return {
attackerAr,
defenderDr: defender.defense,
hitChancePercent,
hitRoll,
isHit: true,
isBlocked: false,
defenderBlockPercent: defenderBlock,
isCritical: critResult.isCritical,
criticalTrigger: critResult.triggeredBy,
isCrushingBlow,
crushingBlowDamage,
isOpenWounds,
openWoundsResult,
lifeLeeched: leechResult.lifeLeeched,
manaLeeched: leechResult.manaLeeched,
freezeResult,
knockbackTriggered,
knockbackResult: kbResult,
slowResult,
blindResult,
fleeResult,
pmhResult,
monsterRegenResult,
resistanceBreakResults,
rawPhysicalDamage: rawPhysical,
finalPhysicalDamage,
finalFireDamage: finalFire,
finalLightningDamage: finalLightning,
finalColdDamage: finalCold,
finalPoisonDamage: finalPoison,
finalMagicDamage: finalMagic,
totalDamage,
immunities,
hitRecoveryTriggered: recovery.triggered,
hitRecoveryChance: recovery.chancePercent,
log,
}
}
/**
* Canonical Diablo II 1.13c Experience Progression Table (Levels 1 to 99).
*
* Index corresponds to character level (Index 1 = Level 1).
*/
export const D2_EXPERIENCE_TABLE_99: readonly number[] = [
0, // Index 0 (unused)
0, // Level 1
500, // Level 2
1500, // Level 3
3750, // Level 4
7875, // Level 5
14175, // Level 6
22680, // Level 7
32886, // Level 8
44396, // Level 9
57715, // Level 10
72144, // Level 11
90180, // Level 12
112725, // Level 13
140906, // Level 14
176132, // Level 15
220165, // Level 16
275206, // Level 17
344008, // Level 18
430010, // Level 19
537512, // Level 20
671890, // Level 21
839862, // Level 22
1049828, // Level 23
1312285, // Level 24
1640356, // Level 25
2050445, // Level 26
2563056, // Level 27
3203820, // Level 28
3902664, // Level 29
4663904, // Level 30
5493655, // Level 31
6398084, // Level 32
7383912, // Level 33
8458464, // Level 34
9629726, // Level 35
10906401, // Level 36
12297977, // Level 37
13814795, // Level 38
15468127, // Level 39
17270258, // Level 40
19234581, // Level 41
21375693, // Level 42
23709505, // Level 43
26253360, // Level 44
29026162, // Level 45
32048517, // Level 46
35342884, // Level 47
38933744, // Level 48
42847781, // Level 49
47114081, // Level 50
51764348, // Level 51
56833139, // Level 52
62358122, // Level 53
68380353, // Level 54
74944585, // Level 55
82099598, // Level 56
89898562, // Level 57
98399433, // Level 58
107665382, // Level 59
117765266, // Level 60
128774140, // Level 61
140773813, // Level 62
153853456, // Level 63
168110267, // Level 64
183650191, // Level 65
200588708, // Level 66
219051692, // Level 67
239176344, // Level 68
261112215, // Level 69
285022314, // Level 70
311084322, // Level 71
339511911, // Level 72
370507983, // Level 73
404293701, // Level 74
441110134, // Level 75
481240046, // Level 76
524981650, // Level 77
572660000, // Level 78
624629400, // Level 79
681276046, // Level 80
743020890, // Level 81
810322770, // Level 82
883681819, // Level 83
963643183, // Level 84
1050801070, // Level 85
1145803166, // Level 86
1249355451, // Level 87
1362227442, // Level 88
1485257912, // Level 89
1619361124, // Level 90
1765533625, // Level 91
1924861651, // Level 92
2098529199, // Level 93
2287826827, // Level 94
2494161241, // Level 95
2719065753, // Level 96
2964211671, // Level 97
3231420721, // Level 98
3522678586, // Level 99
]