import { armorReductionPercent, type DamageSchool } from "./itemStats"; import type { CharacterStats, WotlkAttackKind } from "./wotlkStats"; export type CombatAttackKind = WotlkAttackKind; export type CombatOutcome = "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist" | "deflect"; export type CombatResistanceMode = "none" | "partial" | "binary"; export type CombatRollStage = "outcome" | "resistance" | "block" | "critical-block"; /** A roll must be in [0, 1). Values outside that range are safely clamped. */ export type CombatRollSource = (stage: CombatRollStage) => number; export interface CombatAttackerStats { readonly level: number; readonly hitPercent?: number; readonly criticalChancePercent?: number; readonly criticalDamageMultiplier?: number; readonly expertiseReductionPercent?: number; readonly armorPenetrationPercent?: number; readonly spellPenetration?: number; } export interface CombatDefenderStats { readonly level: number; readonly armor?: number; readonly dodgePercent?: number; readonly parryPercent?: number; readonly blockPercent?: number; readonly blockValue?: number; /** Integer chance passed to AzerothCore's roll_chance_i after a successful block. */ readonly criticalBlockChancePercent?: number; readonly criticalAvoidancePercent?: number; readonly damageReductionPercent?: number; readonly resistances?: Partial>>; } export interface CombatResultRules { /** Exact source packet identity and recipient ordinal for native RoM spells. */ readonly romNativeSource?: { readonly skillId: number; readonly componentId: number }; readonly romNativeTargetOrdinal?: number; /** Native periodic power already includes source target defense at application. */ readonly targetMitigationSnapshotted?: boolean; /** School or encounter immunity: the attack is reported as fully resisted. */ readonly immune?: boolean; /** Ordinary abilities use the special-attack miss table. Auto attacks may use the dual-wield penalty. */ readonly isAutoAttack?: boolean; readonly isDualWielding?: boolean; readonly canMiss?: boolean; readonly canCrit?: boolean; readonly canDodge?: boolean; readonly canParry?: boolean; readonly canBlock?: boolean; /** Source direct physical spells roll block separately after landing, including crits. */ readonly blockMode?: "post-hit"; /** Source mechanic exception (for example, bleed effects that bypass armor). */ readonly ignoreArmor?: boolean; readonly resistanceMode?: CombatResistanceMode; /** Overrides the level-derived base miss chance before the attacker's hit bonus is applied. */ readonly baseMissChancePercent?: number; } export interface ResolveCombatResultInput { readonly amount: number; readonly attackKind: CombatAttackKind; readonly damageSchool: DamageSchool; readonly attacker: CombatAttackerStats; readonly defender: CombatDefenderStats; readonly rules?: CombatResultRules; /** Defaults to Math.random. Inject a stage-aware source for replays and exact tests. */ readonly roll?: CombatRollSource; } export interface CombatAttackTable { readonly missPercent: number; readonly dodgePercent: number; readonly parryPercent: number; readonly blockPercent: number; readonly criticalPercent: number; readonly hitPercent: number; } export interface CombatResult { readonly outcome: CombatOutcome; readonly landed: boolean; readonly critical: boolean; readonly missed: boolean; readonly dodged: boolean; readonly parried: boolean; readonly blocked: boolean; readonly avoided: boolean; /** Exact source spell deflection avoids the packet rather than absorbing it. */ readonly deflected?: boolean; readonly fullyResisted: boolean; readonly partiallyResisted: boolean; readonly rawDamage: number; readonly damageBeforeMitigation: number; readonly effectiveArmor: number; readonly armorReductionPercent: number; readonly armorMitigatedAmount: number; readonly blockedAmount: number; readonly resistedAmount: number; readonly damageReductionAmount: number; readonly finalDamage: number; readonly attackTable: CombatAttackTable; readonly outcomeRoll: number; readonly resistanceRoll: number | null; readonly resistanceFraction: number; readonly postHitBlockRoll?: number; readonly postHitBlockChancePercent?: number; /** Integer in [0, 99], matching the sample consumed by roll_chance_i. */ readonly criticalBlockRoll?: number; readonly criticalBlockChancePercent?: number; readonly criticalBlocked?: boolean; } /** * Exact 100% source deflection is applied after the existing miss/full-resist * result, before any damage or proc is committed. It cannot replace avoidance. * Partial-resist and block calculations on the discarded packet are not damage. */ export function deflectLandedCombatResult(result: CombatResult): CombatResult { if (!result.landed || result.avoided || result.fullyResisted) return result; const { postHitBlockRoll: _blockRoll, postHitBlockChancePercent: _blockChance, criticalBlockRoll: _criticalBlockRoll, criticalBlockChancePercent: _criticalBlockChance, criticalBlocked: _criticalBlocked, ...retained } = result; return Object.freeze({ ...retained, outcome: "deflect", landed: false, critical: false, missed: false, dodged: false, parried: false, blocked: false, avoided: true, deflected: true, fullyResisted: false, partiallyResisted: false, damageBeforeMitigation: 0, effectiveArmor: 0, armorReductionPercent: 0, armorMitigatedAmount: 0, blockedAmount: 0, resistedAmount: 0, damageReductionAmount: 0, finalDamage: 0, resistanceFraction: 0, }); } const DEFAULT_CRITICAL_DAMAGE_MULTIPLIER = 2; const DUAL_WIELD_AUTO_ATTACK_MISS_PENALTY = 19; const PARTIAL_RESIST_STEP = 0.1; function finiteNonNegative(value: number | undefined): number { return Number.isFinite(value) ? Math.max(0, Number(value)) : 0; } function finitePercent(value: number | undefined, maximum = 100): number { return Math.min(maximum, finiteNonNegative(value)); } function safeLevel(value: number): number { return Math.max(1, Math.min(255, Math.trunc(Number.isFinite(value) ? value : 1))); } function normalizedRoll(value: number): number { if (!Number.isFinite(value)) return 0; return Math.min(1 - Number.EPSILON, Math.max(0, value)); } /** * WotLK-shaped base miss chance for attacks against NPCs. The caller may * override this for encounters or rulesets with a different combat table. */ export function baseMissChancePercent( attackKind: CombatAttackKind, attackerLevel: number, defenderLevel: number, isDualWieldAutoAttack = false, ): number { const difference = safeLevel(defenderLevel) - safeLevel(attackerLevel); if (attackKind === "spell") { const base = difference <= 2 ? 4 + difference : 17 + Math.max(0, difference - 3) * 11; return Math.max(0, Math.min(100, base)); } const base = difference <= 2 ? 5 + difference * 0.5 : 8 + Math.max(0, difference - 3) * 2; return Math.max( 0, Math.min(100, base + (isDualWieldAutoAttack ? DUAL_WIELD_AUTO_ATTACK_MISS_PENALTY : 0)), ); } function cappedAttackTable(chances: readonly number[]): readonly number[] { let remaining = 100; return chances.map((chance) => { const applied = Math.min(remaining, finitePercent(chance)); remaining -= applied; return applied; }); } export function combatAttackTable(input: ResolveCombatResultInput): CombatAttackTable { const { attackKind, attacker, defender } = input; const rules = input.rules ?? {}; const expertise = attackKind === "melee" ? finitePercent(attacker.expertiseReductionPercent) : 0; const defaultCanDodge = attackKind === "melee"; const defaultCanParry = attackKind === "melee"; const defaultCanBlock = attackKind === "melee" || attackKind === "ranged"; const baseMiss = rules.baseMissChancePercent ?? baseMissChancePercent( attackKind, attacker.level, defender.level, Boolean(rules.isAutoAttack && rules.isDualWielding && attackKind === "melee"), ); const rawChances = [ rules.canMiss === false ? 0 : Math.max(0, baseMiss - finitePercent(attacker.hitPercent)), (rules.canDodge ?? defaultCanDodge) ? Math.max(0, finitePercent(defender.dodgePercent) - expertise) : 0, (rules.canParry ?? defaultCanParry) ? Math.max(0, finitePercent(defender.parryPercent) - expertise) : 0, rules.blockMode !== "post-hit" && (rules.canBlock ?? defaultCanBlock) ? finitePercent(defender.blockPercent) : 0, rules.canCrit === false ? 0 : Math.max( 0, finitePercent(attacker.criticalChancePercent) - finitePercent(defender.criticalAvoidancePercent), ), ] as const; const [missPercent, dodgePercent, parryPercent, blockPercent, criticalPercent] = cappedAttackTable(rawChances); const hitPercent = Math.max( 0, 100 - missPercent - dodgePercent - parryPercent - blockPercent - criticalPercent, ); return Object.freeze({ missPercent, dodgePercent, parryPercent, blockPercent, criticalPercent, hitPercent, }); } function outcomeFromRoll(table: CombatAttackTable, roll: number): CombatOutcome { const percentile = normalizedRoll(roll) * 100; let threshold = table.missPercent; if (percentile < threshold) return "miss"; threshold += table.dodgePercent; if (percentile < threshold) return "dodge"; threshold += table.parryPercent; if (percentile < threshold) return "parry"; threshold += table.blockPercent; if (percentile < threshold) return "block"; threshold += table.criticalPercent; if (percentile < threshold) return "crit"; return "hit"; } function defaultResistanceMode(school: DamageSchool): CombatResistanceMode { return school === "physical" ? "none" : "partial"; } function resistanceFraction( resistance: number, attackerLevel: number, mode: CombatResistanceMode, roll: number, ): number { if (mode === "none" || resistance <= 0) return 0; const average = Math.min( 0.75, resistance / Math.max(1, resistance + safeLevel(attackerLevel) * 5) * 0.75, ); if (mode === "binary") return normalizedRoll(roll) < average ? 1 : 0; // Select between adjacent 10% partial-resist buckets while preserving the // source formula's expected mitigation over many attacks. const lower = Math.floor((average + Number.EPSILON) / PARTIAL_RESIST_STEP) * PARTIAL_RESIST_STEP; const upper = Math.min(1, lower + PARTIAL_RESIST_STEP); const upperChance = upper <= lower ? 0 : (average - lower) / (upper - lower); return normalizedRoll(roll) < upperChance ? upper : lower; } function emptyResult( outcome: Extract, rawDamage: number, table: CombatAttackTable, outcomeRoll: number, resistanceRoll: number | null, resistedAmount = 0, damageBeforeMitigation = 0, ): CombatResult { return Object.freeze({ outcome, landed: false, critical: false, missed: outcome === "miss", dodged: outcome === "dodge", parried: outcome === "parry", blocked: false, avoided: true, fullyResisted: outcome === "resist", partiallyResisted: false, rawDamage, damageBeforeMitigation, effectiveArmor: 0, armorReductionPercent: 0, armorMitigatedAmount: 0, blockedAmount: 0, resistedAmount, damageReductionAmount: 0, finalDamage: 0, attackTable: table, outcomeRoll, resistanceRoll, resistanceFraction: outcome === "resist" ? 1 : 0, }); } /** Resolves one attack into a discrete, proc-friendly combat event. */ export function resolveCombatResult(input: ResolveCombatResultInput): CombatResult { const rollSource = input.roll ?? (() => Math.random()); const outcomeRoll = normalizedRoll(rollSource("outcome")); const table = combatAttackTable(input); const rawDamage = Math.max(0, Math.round(finiteNonNegative(input.amount))); if (input.rules?.immune) { return emptyResult("resist", rawDamage, table, outcomeRoll, null, rawDamage, rawDamage); } const rolledOutcome = outcomeFromRoll(table, outcomeRoll); if (rolledOutcome === "miss" || rolledOutcome === "dodge" || rolledOutcome === "parry") { return emptyResult(rolledOutcome, rawDamage, table, outcomeRoll, null); } const critical = rolledOutcome === "crit"; const criticalMultiplier = Math.max( 1, Number.isFinite(input.attacker.criticalDamageMultiplier) ? Number(input.attacker.criticalDamageMultiplier) : DEFAULT_CRITICAL_DAMAGE_MULTIPLIER, ); const damageBeforeMitigation = Math.max( 0, Math.round(rawDamage * (critical ? criticalMultiplier : 1)), ); const armorPenetration = finitePercent(input.attacker.armorPenetrationPercent); const effectiveArmor = input.damageSchool === "physical" && !input.rules?.ignoreArmor ? finiteNonNegative(input.defender.armor) * (1 - armorPenetration / 100) : 0; const physicalReduction = input.damageSchool === "physical" && !input.rules?.ignoreArmor ? armorReductionPercent(effectiveArmor, input.attacker.level) : 0; const armorMitigatedAmount = Math.min( damageBeforeMitigation, Math.round(damageBeforeMitigation * physicalReduction / 100), ); let remaining = damageBeforeMitigation - armorMitigatedAmount; const postHitBlockChancePercent = input.rules?.blockMode === "post-hit" && input.rules.canBlock !== false && input.damageSchool === "physical" && (input.attackKind === "melee" || input.attackKind === "ranged") ? finitePercent(input.defender.blockPercent) : 0; const postHitBlockRoll = postHitBlockChancePercent > 0 ? normalizedRoll(rollSource("block")) : undefined; const blocked = rolledOutcome === "block" || (postHitBlockRoll !== undefined && postHitBlockRoll * 100 < postHitBlockChancePercent); // AzerothCore's roll_chance_i is chance > irand(0, 99), so even 0% and // 100% consume a roll when the source supplied this stat. Keep the optional // field absent as the compatibility boundary for existing callers. const hasCriticalBlockChance = input.defender.criticalBlockChancePercent !== undefined; const criticalBlockChancePercent = hasCriticalBlockChance ? (Number.isFinite(input.defender.criticalBlockChancePercent) ? Math.trunc(Number(input.defender.criticalBlockChancePercent)) : 0) : undefined; const criticalBlockRoll = blocked && hasCriticalBlockChance ? Math.floor(normalizedRoll(rollSource("critical-block")) * 100) : undefined; const criticalBlocked = criticalBlockRoll !== undefined && criticalBlockChancePercent !== undefined && criticalBlockChancePercent > criticalBlockRoll; const blockedAmount = blocked ? Math.min(remaining, Math.round( finiteNonNegative(input.defender.blockValue) * (criticalBlocked ? 2 : 1), )) : 0; remaining -= blockedAmount; const mode = input.rules?.resistanceMode ?? defaultResistanceMode(input.damageSchool); const targetResistance = finiteNonNegative(input.defender.resistances?.[input.damageSchool]); const effectiveResistance = input.damageSchool === "physical" ? 0 : Math.max(0, targetResistance - finiteNonNegative(input.attacker.spellPenetration)); const needsResistanceRoll = mode !== "none" && effectiveResistance > 0; const resistanceRoll = needsResistanceRoll ? normalizedRoll(rollSource("resistance")) : null; const resistedFraction = needsResistanceRoll ? resistanceFraction(effectiveResistance, input.attacker.level, mode, resistanceRoll!) : 0; if (resistedFraction >= 1) { return emptyResult( "resist", rawDamage, table, outcomeRoll, resistanceRoll, remaining, damageBeforeMitigation, ); } const resistedAmount = Math.min(remaining, Math.round(remaining * resistedFraction)); remaining -= resistedAmount; const damageReductionPercent = finitePercent(input.defender.damageReductionPercent, 75); const damageReductionAmount = Math.min( remaining, Math.round(remaining * damageReductionPercent / 100), ); remaining -= damageReductionAmount; return Object.freeze({ outcome: blocked && !critical ? "block" : rolledOutcome, landed: true, critical, missed: false, dodged: false, parried: false, blocked, avoided: false, fullyResisted: false, partiallyResisted: resistedAmount > 0, rawDamage, damageBeforeMitigation, effectiveArmor, armorReductionPercent: physicalReduction, armorMitigatedAmount, blockedAmount, resistedAmount, damageReductionAmount, finalDamage: remaining, attackTable: table, outcomeRoll, resistanceRoll, resistanceFraction: resistedFraction, ...(postHitBlockRoll !== undefined ? { postHitBlockRoll, postHitBlockChancePercent } : {}), ...(criticalBlockChancePercent !== undefined && criticalBlockRoll !== undefined ? { criticalBlockChancePercent, criticalBlocked, criticalBlockRoll, } : {}), }); } export function combatAttackerFromCharacterStats( stats: CharacterStats, attackKind: CombatAttackKind, ): CombatAttackerStats { const hitPercent = attackKind === "spell" ? stats.spellHitPercent : attackKind === "ranged" ? stats.rangedHitPercent : stats.meleeHitPercent; const criticalChancePercent = attackKind === "spell" ? stats.spellCritPercent : attackKind === "ranged" ? stats.rangedCritPercent : stats.meleeCritPercent; return Object.freeze({ level: stats.level, hitPercent, criticalChancePercent, expertiseReductionPercent: attackKind === "melee" ? stats.expertiseReductionPercent : 0, armorPenetrationPercent: attackKind === "spell" ? 0 : stats.armorPenetrationPercent, spellPenetration: attackKind === "spell" ? stats.spellPenetration : 0, }); } export function combatDefenderFromCharacterStats(stats: CharacterStats): CombatDefenderStats { return Object.freeze({ level: stats.level, armor: stats.armor, dodgePercent: stats.dodgePercent, parryPercent: stats.parryPercent, blockPercent: stats.blockPercent, blockValue: stats.blockValue, // CharacterStats stores resilience's all-damage reduction, which is twice // its critical-strike reduction in the 3.3.5a rules represented here. criticalAvoidancePercent: stats.resiliencePercent / 2, damageReductionPercent: stats.resiliencePercent, resistances: { arcane: stats.arcaneResistance, fire: stats.fireResistance, frost: stats.frostResistance, nature: stats.natureResistance, shadow: stats.shadowResistance, }, }); }