1916 lines
63 KiB
TypeScript
1916 lines
63 KiB
TypeScript
/**
|
||
* 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
|
||
]
|