426 lines
14 KiB
TypeScript
426 lines
14 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";
|
|
export type CombatResistanceMode = "none" | "partial" | "binary";
|
|
export type CombatRollStage = "outcome" | "resistance";
|
|
|
|
/** 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;
|
|
readonly criticalAvoidancePercent?: number;
|
|
readonly damageReductionPercent?: number;
|
|
readonly resistances?: Partial<Readonly<Record<DamageSchool, number>>>;
|
|
}
|
|
|
|
export interface CombatResultRules {
|
|
/** 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;
|
|
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;
|
|
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;
|
|
}
|
|
|
|
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.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"
|
|
? finiteNonNegative(input.defender.armor) * (1 - armorPenetration / 100)
|
|
: 0;
|
|
const physicalReduction = input.damageSchool === "physical"
|
|
? armorReductionPercent(effectiveArmor, input.attacker.level)
|
|
: 0;
|
|
const armorMitigatedAmount = Math.min(
|
|
damageBeforeMitigation,
|
|
Math.round(damageBeforeMitigation * physicalReduction / 100),
|
|
);
|
|
let remaining = damageBeforeMitigation - armorMitigatedAmount;
|
|
|
|
const blocked = rolledOutcome === "block";
|
|
const blockedAmount = blocked
|
|
? Math.min(remaining, Math.round(finiteNonNegative(input.defender.blockValue)))
|
|
: 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: 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,
|
|
});
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|
|
}
|