Files
healer-man/src/game/combatResults.ts
T
2026-10-07 13:05:04 -04:00

510 lines
18 KiB
TypeScript

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<Readonly<Record<DamageSchool, number>>>;
}
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<CombatOutcome, "miss" | "dodge" | "parry" | "resist">,
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,
},
});
}