601 lines
23 KiB
TypeScript
601 lines
23 KiB
TypeScript
import {
|
|
CLASSES,
|
|
RACES,
|
|
baseClassFor,
|
|
classById,
|
|
isRomClassId,
|
|
isRomRaceId,
|
|
type ClassId,
|
|
type GenderId,
|
|
type RaceId,
|
|
} from "../app/characterCatalog";
|
|
import { normalizeCharacterStatRuleset, type CharacterStatRuleset } from "./characterStatRuleset";
|
|
import { romDualClassBaseStats } from "./romStats";
|
|
import { applyRomNativeStatModifiers, deriveRomNativeStats, type RomNativeDerivedStats, type RomNativeStatModifiers } from "./romNativeStats";
|
|
import { romNativeGearFromSharedDirectStats } from "./romNativeStatsTypes";
|
|
import { ROM_SECONDARY_UNLOCK_LEVEL } from "../app/romDualClass";
|
|
import baseStatsData from "./generated/wow335BaseStats.json";
|
|
import combatRatingsData from "./generated/wow335CombatRatings.json";
|
|
import {
|
|
armorReductionPercent,
|
|
type DamageSchool,
|
|
type ItemStats,
|
|
type WeaponStats,
|
|
} from "./itemStats";
|
|
|
|
export interface PrimaryAttributes {
|
|
readonly strength: number;
|
|
readonly agility: number;
|
|
readonly stamina: number;
|
|
readonly intellect: number;
|
|
readonly spirit: number;
|
|
}
|
|
|
|
export interface CharacterStatPercentModifiers {
|
|
readonly strength?: number;
|
|
readonly agility?: number;
|
|
readonly stamina?: number;
|
|
readonly intellect?: number;
|
|
readonly spirit?: number;
|
|
readonly maxHealth?: number;
|
|
readonly maxMana?: number;
|
|
}
|
|
|
|
export interface CharacterStats extends PrimaryAttributes {
|
|
/** Source-unit RoM channels; values are never interpreted as WoW ratings. */
|
|
readonly romNative?: RomNativeDerivedStats;
|
|
/** Shared combat only: ATK/MDMG pass-through and DEF uses the WotLK armor curve. */
|
|
readonly combatStatModel?: "rom-native-normalized";
|
|
readonly level: number;
|
|
readonly health: number;
|
|
readonly mana: number;
|
|
readonly healthPer5: number;
|
|
readonly manaPer5: number;
|
|
readonly attackPower: number;
|
|
readonly rangedAttackPower: number;
|
|
readonly spellPower: number;
|
|
readonly meleeCritPercent: number;
|
|
readonly rangedCritPercent: number;
|
|
readonly spellCritPercent: number;
|
|
readonly meleeHitPercent: number;
|
|
readonly rangedHitPercent: number;
|
|
readonly spellHitPercent: number;
|
|
readonly meleeHastePercent: number;
|
|
readonly rangedHastePercent: number;
|
|
readonly spellHastePercent: number;
|
|
readonly expertise: number;
|
|
readonly expertiseReductionPercent: number;
|
|
readonly armorPenetrationPercent: number;
|
|
readonly spellPenetration: number;
|
|
readonly armor: number;
|
|
readonly armorReductionPercent: number;
|
|
readonly defenseSkill: number;
|
|
readonly dodgePercent: number;
|
|
readonly parryPercent: number;
|
|
readonly blockPercent: number;
|
|
readonly blockValue: number;
|
|
readonly resiliencePercent: number;
|
|
readonly arcaneResistance: number;
|
|
readonly fireResistance: number;
|
|
readonly frostResistance: number;
|
|
readonly natureResistance: number;
|
|
readonly shadowResistance: number;
|
|
readonly meleeWeapon: WeaponStats | null;
|
|
readonly rangedWeapon: WeaponStats | null;
|
|
}
|
|
|
|
interface BaseStatsData {
|
|
readonly attributes: Readonly<Record<string, readonly (readonly number[] | null)[]>>;
|
|
readonly resources: Readonly<Record<string, readonly (readonly number[] | null)[]>>;
|
|
}
|
|
|
|
export type WotlkRatingKind =
|
|
| "defense"
|
|
| "dodge"
|
|
| "parry"
|
|
| "block"
|
|
| "meleeHit"
|
|
| "rangedHit"
|
|
| "spellHit"
|
|
| "meleeCrit"
|
|
| "rangedCrit"
|
|
| "spellCrit"
|
|
| "resilience"
|
|
| "meleeHaste"
|
|
| "rangedHaste"
|
|
| "spellHaste"
|
|
| "expertise"
|
|
| "armorPenetration";
|
|
|
|
interface CombatRatingsData {
|
|
readonly maxLevel: number;
|
|
readonly ratios: Readonly<Record<WotlkRatingKind, readonly number[]>>;
|
|
readonly scalars: Readonly<Record<string, Readonly<Record<WotlkRatingKind, number>>>>;
|
|
}
|
|
|
|
const BASE_STATS = baseStatsData as BaseStatsData;
|
|
const COMBAT_RATINGS = combatRatingsData as CombatRatingsData;
|
|
/** Healer Man's larger combat pool; imported Wrath costs keep their pre-boost baseline. */
|
|
export const MANA_POOL_MULTIPLIER = 3;
|
|
const CLASS_DBC = Object.fromEntries(
|
|
CLASSES.map((entry) => [entry.id, classById(baseClassFor(entry.id)).dbcId]),
|
|
) as Record<ClassId, number>;
|
|
const RACE_DBC = Object.fromEntries(RACES.map((entry) => [entry.id, entry.dbcId])) as Record<RaceId, number>;
|
|
|
|
const classUsesMana = (classId: ClassId): boolean => classById(classId).resourceSummary.includes("Mana");
|
|
|
|
/** The five playable RoM pairs use Wrath base curves alongside their hybrid heirlooms. */
|
|
const ROM_HYBRID_BASE_RACE: Partial<Readonly<Record<ClassId, RaceId>>> = Object.freeze({
|
|
"rom-knight": "human",
|
|
"rom-priest": "human",
|
|
"rom-scout": "night-elf",
|
|
"rom-rogue": "human",
|
|
// Dwarf Mage was not a 3.3.5 race/class combination; Human supplies its class curve.
|
|
"rom-mage": "human",
|
|
});
|
|
|
|
function romHybridBase(
|
|
classId: ClassId,
|
|
secondaryClassId: ClassId | null | undefined,
|
|
level: number,
|
|
): { readonly attributes: PrimaryAttributes; readonly resources: { readonly health: number; readonly mana: number } } | null {
|
|
const race = ROM_HYBRID_BASE_RACE[classId];
|
|
if (!race) return null;
|
|
const primaryAttributes = baseAttributesFor(classId, level, race);
|
|
const primaryResources = baseResourcesFor(classId, level);
|
|
const secondary = level >= 10 && secondaryClassId && isRomClassId(secondaryClassId)
|
|
? secondaryClassId : null;
|
|
const secondaryAttributes = secondary
|
|
? baseAttributesFor(secondary, level, ROM_HYBRID_BASE_RACE[secondary] ?? "human") : null;
|
|
const secondaryResources = secondary ? baseResourcesFor(secondary, level) : null;
|
|
const attributes = Object.fromEntries((Object.keys(primaryAttributes) as (keyof PrimaryAttributes)[]).map((key) => [
|
|
key, primaryAttributes[key] + (secondaryAttributes?.[key] ?? 0) * .1,
|
|
])) as unknown as PrimaryAttributes;
|
|
return {
|
|
attributes,
|
|
resources: {
|
|
health: primaryResources.health + (secondaryResources?.health ?? 0) * .1,
|
|
mana: primaryResources.mana + (secondaryResources?.mana ?? 0) * .1,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Automatic racial auras from Ascension's effective Spell.dbc that can change
|
|
* a character's starting attributes or resource pools.
|
|
*/
|
|
const RACIAL_STAT_PASSIVES: Partial<Readonly<Record<
|
|
RaceId,
|
|
Readonly<CharacterStatPercentModifiers & { readonly baseHealth?: number }>
|
|
>>> = Object.freeze({
|
|
// The Human Spirit (20598): Spirit increased by 3%.
|
|
human: Object.freeze({ spirit: 3 }),
|
|
// Endurance (20550): Base Health increased by 5%.
|
|
tauren: Object.freeze({ baseHealth: 5 }),
|
|
// Expansive Mind (20591): Intellect increased by 5%.
|
|
gnome: Object.freeze({ intellect: 5 }),
|
|
});
|
|
|
|
function safeLevel(level: number): number {
|
|
return Math.max(1, Math.min(80, Math.trunc(Number.isFinite(level) ? level : 1)));
|
|
}
|
|
|
|
function percentAdjusted(value: number, ...percentages: readonly (number | undefined)[]): number {
|
|
const total = percentages.reduce((sum: number, percentage) => (
|
|
sum + (Number.isFinite(percentage) ? Number(percentage) : 0)
|
|
), 0);
|
|
return Math.max(0, Math.round(value * (1 + total / 100)));
|
|
}
|
|
|
|
function nearestLevelRow(
|
|
rows: readonly (readonly number[] | null)[] | undefined,
|
|
level: number,
|
|
): readonly number[] | null {
|
|
if (!rows?.length) return null;
|
|
const target = safeLevel(level) - 1;
|
|
if (rows[target]) return rows[target];
|
|
for (let distance = 1; distance < rows.length; distance += 1) {
|
|
if (rows[target - distance]) return rows[target - distance];
|
|
if (rows[target + distance]) return rows[target + distance];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function baseAttributesFor(
|
|
classId: ClassId,
|
|
level: number,
|
|
raceId: RaceId = "human",
|
|
): PrimaryAttributes {
|
|
const classDbc = CLASS_DBC[classId];
|
|
const exactRows = BASE_STATS.attributes[`${RACE_DBC[raceId]}:${classDbc}`];
|
|
const fallbackRows = BASE_STATS.attributes[`1:${classDbc}`]
|
|
?? Object.entries(BASE_STATS.attributes).find(([key]) => key.endsWith(`:${classDbc}`))?.[1];
|
|
const row = nearestLevelRow(exactRows, level) ?? nearestLevelRow(fallbackRows, level) ?? [20, 20, 20, 20, 20];
|
|
return {
|
|
strength: row[0] ?? 20,
|
|
agility: row[1] ?? 20,
|
|
stamina: row[2] ?? 20,
|
|
intellect: row[3] ?? 20,
|
|
spirit: row[4] ?? 20,
|
|
};
|
|
}
|
|
|
|
export function baseResourcesFor(classId: ClassId, level: number): {
|
|
readonly health: number;
|
|
readonly mana: number;
|
|
} {
|
|
const row = nearestLevelRow(BASE_STATS.resources[String(CLASS_DBC[classId])], level) ?? [100, 0];
|
|
return { health: row[0] ?? 100, mana: row[1] ?? 0 };
|
|
}
|
|
|
|
/** WotLK grants one resource point for the first 20, then 10 health/15 mana. */
|
|
export function healthFromTotalStamina(stamina: number): number {
|
|
const value = Math.max(0, stamina);
|
|
return Math.round(Math.min(20, value) + Math.max(0, value - 20) * 10);
|
|
}
|
|
|
|
export function manaFromTotalIntellect(intellect: number): number {
|
|
const value = Math.max(0, intellect);
|
|
return Math.round(Math.min(20, value) + Math.max(0, value - 20) * 15);
|
|
}
|
|
|
|
/** Exact 3.3.5a client DBC conversion, expressed as percentage/skill per rating point. */
|
|
export function ratingMultiplier(
|
|
kind: WotlkRatingKind,
|
|
level: number,
|
|
classId: ClassId = "warrior",
|
|
): number {
|
|
const safe = safeLevel(Math.min(level, COMBAT_RATINGS.maxLevel));
|
|
const ratio = COMBAT_RATINGS.ratios[kind]?.[safe - 1];
|
|
const scalar = COMBAT_RATINGS.scalars[String(CLASS_DBC[classId])]?.[kind];
|
|
if (!Number.isFinite(ratio) || !Number.isFinite(scalar) || ratio <= 0) return 1;
|
|
return scalar / ratio;
|
|
}
|
|
|
|
export function ratingPerPercent(
|
|
kind: WotlkRatingKind,
|
|
level: number,
|
|
classId: ClassId = "warrior",
|
|
): number {
|
|
return 1 / Math.max(0.000_001, ratingMultiplier(kind, level, classId));
|
|
}
|
|
|
|
export function ratingPercent(
|
|
rating: number,
|
|
kind: WotlkRatingKind,
|
|
level: number,
|
|
classId: ClassId = "warrior",
|
|
): number {
|
|
return Math.max(0, rating) * ratingMultiplier(kind, level, classId);
|
|
}
|
|
|
|
export function ratingPerExpertise(level: number, classId: ClassId = "warrior"): number {
|
|
return ratingPerPercent("expertise", level, classId);
|
|
}
|
|
|
|
export function ratingPerDefenseSkill(level: number, classId: ClassId = "warrior"): number {
|
|
return ratingPerPercent("defense", level, classId);
|
|
}
|
|
|
|
function strengthAttackPower(classId: ClassId, strength: number): number {
|
|
const baseClassId = baseClassFor(classId);
|
|
return Math.max(0, strength) * (
|
|
baseClassId === "warrior" || baseClassId === "paladin" || baseClassId === "death-knight" ? 2 : 1
|
|
);
|
|
}
|
|
|
|
function agilityAttackPower(classId: ClassId, agility: number): number {
|
|
return ["hunter", "rogue", "shaman", "druid"].includes(baseClassFor(classId))
|
|
? Math.max(0, agility)
|
|
: 0;
|
|
}
|
|
|
|
function spiritManaPer5(classId: ClassId, level: number, intellect: number, spirit: number): number {
|
|
if (!classUsesMana(classId)) return 0;
|
|
const coefficient = 0.0035 + safeLevel(level) / 80 * 0.0021;
|
|
return 5 * Math.sqrt(Math.max(1, intellect)) * Math.max(0, spirit) * coefficient;
|
|
}
|
|
|
|
function nativeCharacterStats(
|
|
classId: Extract<ClassId, `rom-${string}`>,
|
|
level: number,
|
|
gear: ItemStats,
|
|
raceId: RaceId,
|
|
modifiers: CharacterStatPercentModifiers,
|
|
secondaryClassId: ClassId | null | undefined,
|
|
gender: GenderId,
|
|
): CharacterStats {
|
|
const nativeBase = deriveRomNativeStats({
|
|
classId,
|
|
level,
|
|
raceId: isRomRaceId(raceId) ? raceId : "rom-human",
|
|
gender,
|
|
secondaryClassId: safeLevel(level) >= ROM_SECONDARY_UNLOCK_LEVEL
|
|
&& secondaryClassId && isRomClassId(secondaryClassId) ? secondaryClassId : null,
|
|
// Native payload wins wholesale; direct legacy ItemStats calls receive
|
|
// only the verified flat-primary/HP/MP shared-stat fallback.
|
|
gear: gear.romNative ?? romNativeGearFromSharedDirectStats(gear),
|
|
});
|
|
const percentFactor = (value: number | undefined) => Math.max(0, 1 + (Number.isFinite(value) ? value! : 0) / 100);
|
|
const rates: NonNullable<RomNativeStatModifiers["rates"]> = {
|
|
strength: percentFactor(modifiers.strength),
|
|
stamina: percentFactor(modifiers.stamina),
|
|
agility: percentFactor(modifiers.agility),
|
|
intellect: percentFactor(modifiers.intellect),
|
|
health: percentFactor(modifiers.maxHealth),
|
|
mana: percentFactor(modifiers.maxMana),
|
|
};
|
|
const native = applyRomNativeStatModifiers(nativeBase, { rates });
|
|
const safe = safeLevel(level);
|
|
const physicalDefense = native.physicalDefense;
|
|
return Object.freeze({
|
|
level: safe,
|
|
strength: native.attributes.strength,
|
|
agility: native.attributes.agility,
|
|
stamina: native.attributes.stamina,
|
|
intellect: native.attributes.intellect,
|
|
// RoM Wisdom remains in the native sidecar; it is not WoW Spirit.
|
|
spirit: 0,
|
|
health: native.health.max,
|
|
mana: native.usesMana ? native.mana.max : 0,
|
|
healthPer5: 0,
|
|
manaPer5: 0,
|
|
attackPower: native.physicalAttack,
|
|
rangedAttackPower: native.physicalAttack,
|
|
spellPower: native.magicalDamage,
|
|
meleeCritPercent: 0,
|
|
rangedCritPercent: 0,
|
|
spellCritPercent: 0,
|
|
meleeHitPercent: 0,
|
|
rangedHitPercent: 0,
|
|
spellHitPercent: 0,
|
|
meleeHastePercent: 0,
|
|
rangedHastePercent: 0,
|
|
spellHastePercent: 0,
|
|
expertise: 0,
|
|
expertiseReductionPercent: 0,
|
|
armorPenetrationPercent: 0,
|
|
spellPenetration: 0,
|
|
armor: physicalDefense,
|
|
// Shared dungeon combat deliberately applies the existing WotLK armor curve to native DEF.
|
|
armorReductionPercent: armorReductionPercent(physicalDefense, safe),
|
|
defenseSkill: 0,
|
|
dodgePercent: 0,
|
|
parryPercent: 0,
|
|
blockPercent: 0,
|
|
blockValue: 0,
|
|
resiliencePercent: 0,
|
|
arcaneResistance: 0,
|
|
fireResistance: 0,
|
|
frostResistance: 0,
|
|
natureResistance: 0,
|
|
shadowResistance: 0,
|
|
meleeWeapon: gear.meleeWeapon,
|
|
rangedWeapon: gear.rangedWeapon,
|
|
romNative: native,
|
|
combatStatModel: "rom-native-normalized",
|
|
});
|
|
}
|
|
|
|
export function deriveCharacterStats(
|
|
classId: ClassId,
|
|
level: number,
|
|
gear: ItemStats,
|
|
raceId: RaceId = "human",
|
|
modifiers: CharacterStatPercentModifiers = {},
|
|
secondaryClassId?: ClassId | null,
|
|
gender: GenderId = "male",
|
|
statRuleset: CharacterStatRuleset = "wow",
|
|
): CharacterStats {
|
|
const safe = safeLevel(level);
|
|
if (normalizeCharacterStatRuleset(statRuleset, classId) === "rom" && isRomClassId(classId)) {
|
|
return nativeCharacterStats(classId, safe, gear, raceId, modifiers, secondaryClassId, gender);
|
|
}
|
|
const hybridBase = romHybridBase(classId, secondaryClassId, safe);
|
|
const romBase = hybridBase ? null : romDualClassBaseStats(classId, secondaryClassId, safe);
|
|
const base = hybridBase?.attributes ?? (romBase ? {
|
|
strength: romBase.strength,
|
|
agility: romBase.agility,
|
|
stamina: romBase.stamina,
|
|
intellect: romBase.intellect,
|
|
spirit: romBase.wisdom,
|
|
} : baseAttributesFor(classId, safe, raceId));
|
|
const resources = hybridBase?.resources ?? (romBase ? { health: romBase.health, mana: romBase.mana } : baseResourcesFor(classId, safe));
|
|
const racial = RACIAL_STAT_PASSIVES[raceId];
|
|
const strength = percentAdjusted(base.strength + gear.strength, racial?.strength, modifiers.strength);
|
|
const agility = percentAdjusted(base.agility + gear.agility, racial?.agility, modifiers.agility);
|
|
const stamina = percentAdjusted(base.stamina + gear.stamina, racial?.stamina, modifiers.stamina);
|
|
const intellect = percentAdjusted(base.intellect + gear.intellect, racial?.intellect, modifiers.intellect);
|
|
const spirit = percentAdjusted(base.spirit + gear.spirit, racial?.spirit, modifiers.spirit);
|
|
const health = percentAdjusted(
|
|
percentAdjusted(resources.health, racial?.baseHealth)
|
|
+ healthFromTotalStamina(stamina)
|
|
+ gear.health,
|
|
modifiers.maxHealth,
|
|
);
|
|
const mana = classUsesMana(classId)
|
|
? MANA_POOL_MULTIPLIER * percentAdjusted(
|
|
resources.mana
|
|
+ (romBase ? intellect + spirit * 5 : manaFromTotalIntellect(intellect))
|
|
+ gear.mana,
|
|
modifiers.maxMana,
|
|
)
|
|
: 0;
|
|
const defenseFromRating = gear.defenseRating / Math.max(0.01, ratingPerDefenseSkill(safe, classId));
|
|
const defenseAvoidance = defenseFromRating * 0.04;
|
|
const meleeCritFromRating = ratingPercent(gear.criticalStrikeRating, "meleeCrit", safe, classId);
|
|
const rangedCritFromRating = ratingPercent(gear.criticalStrikeRating, "rangedCrit", safe, classId);
|
|
const spellCritFromRating = ratingPercent(gear.criticalStrikeRating, "spellCrit", safe, classId);
|
|
const meleeHastePercent = ratingPercent(gear.hasteRating, "meleeHaste", safe, classId);
|
|
const rangedHastePercent = ratingPercent(gear.hasteRating, "rangedHaste", safe, classId);
|
|
const spellHastePercent = ratingPercent(gear.hasteRating, "spellHaste", safe, classId);
|
|
const expertise = gear.expertiseRating / Math.max(0.01, ratingPerExpertise(safe, classId));
|
|
const armor = gear.armor + agility * 2;
|
|
const baseCrit = 5 + agility / (40 + safe * 2.1);
|
|
const baseSpellCrit = 5 + intellect / (55 + safe * 2.4);
|
|
return Object.freeze({
|
|
level: safe,
|
|
strength,
|
|
agility,
|
|
stamina,
|
|
intellect,
|
|
spirit,
|
|
health,
|
|
mana,
|
|
healthPer5: gear.healthPer5 + spirit * 0.2,
|
|
manaPer5: gear.mp5 + spiritManaPer5(classId, safe, intellect, spirit),
|
|
attackPower: Math.round(
|
|
strengthAttackPower(classId, strength)
|
|
+ agilityAttackPower(classId, agility)
|
|
+ gear.attackPower,
|
|
),
|
|
rangedAttackPower: Math.round(
|
|
(baseClassFor(classId) === "hunter" ? safe * 2 + agility : 0)
|
|
+ gear.rangedAttackPower,
|
|
),
|
|
spellPower: gear.spellPower + (isRomClassId(classId) ? Math.round(intellect * .35 + spirit * .15) : 0),
|
|
meleeCritPercent: baseCrit + meleeCritFromRating,
|
|
rangedCritPercent: baseCrit + rangedCritFromRating,
|
|
spellCritPercent: baseSpellCrit + spellCritFromRating,
|
|
meleeHitPercent: ratingPercent(gear.hitRating, "meleeHit", safe, classId),
|
|
rangedHitPercent: ratingPercent(gear.hitRating, "rangedHit", safe, classId),
|
|
spellHitPercent: ratingPercent(gear.hitRating, "spellHit", safe, classId),
|
|
meleeHastePercent,
|
|
rangedHastePercent,
|
|
spellHastePercent,
|
|
expertise,
|
|
expertiseReductionPercent: expertise * 0.25,
|
|
armorPenetrationPercent: Math.min(
|
|
100,
|
|
ratingPercent(gear.armorPenetrationRating, "armorPenetration", safe, classId),
|
|
),
|
|
spellPenetration: gear.spellPenetration,
|
|
armor,
|
|
armorReductionPercent: armorReductionPercent(armor, safe),
|
|
defenseSkill: safe * 5 + defenseFromRating,
|
|
dodgePercent: 3 + agility / (30 + safe * 2.5)
|
|
+ ratingPercent(gear.dodgeRating, "dodge", safe, classId)
|
|
+ defenseAvoidance,
|
|
parryPercent: 5
|
|
+ ratingPercent(gear.parryRating, "parry", safe, classId)
|
|
+ defenseAvoidance,
|
|
blockPercent: (gear.blockValue > 0 ? 5 : 0)
|
|
+ ratingPercent(gear.blockRating, "block", safe, classId)
|
|
+ defenseAvoidance,
|
|
blockValue: gear.blockValue + Math.floor(Math.max(0, strength - 20) / 2),
|
|
// In 3.3.5a resilience's all-player-damage reduction is twice its crit reduction.
|
|
resiliencePercent: ratingPercent(gear.resilienceRating, "resilience", safe, classId) * 2,
|
|
arcaneResistance: gear.arcaneResistance,
|
|
fireResistance: gear.fireResistance,
|
|
frostResistance: gear.frostResistance,
|
|
natureResistance: gear.natureResistance,
|
|
shadowResistance: gear.shadowResistance,
|
|
meleeWeapon: gear.meleeWeapon,
|
|
rangedWeapon: gear.rangedWeapon,
|
|
});
|
|
}
|
|
|
|
export type WotlkAttackKind = "melee" | "ranged" | "spell";
|
|
|
|
export function equipmentAttackPower(
|
|
classId: ClassId,
|
|
gear: ItemStats,
|
|
kind: WotlkAttackKind,
|
|
): number {
|
|
if (kind === "spell") return gear.spellPower;
|
|
if (kind === "ranged") {
|
|
return gear.rangedAttackPower + (baseClassFor(classId) === "hunter" ? gear.agility : 0);
|
|
}
|
|
return gear.attackPower
|
|
+ strengthAttackPower(classId, gear.strength)
|
|
+ agilityAttackPower(classId, gear.agility);
|
|
}
|
|
|
|
export function expectedOffenseMultiplier(
|
|
stats: CharacterStats,
|
|
kind: WotlkAttackKind,
|
|
): number {
|
|
const crit = kind === "spell"
|
|
? stats.spellCritPercent
|
|
: kind === "ranged"
|
|
? stats.rangedCritPercent
|
|
: stats.meleeCritPercent;
|
|
const hit = kind === "spell"
|
|
? stats.spellHitPercent
|
|
: kind === "ranged"
|
|
? stats.rangedHitPercent
|
|
: stats.meleeHitPercent;
|
|
const expertise = kind === "melee" ? stats.expertiseReductionPercent : 0;
|
|
const armorPenetration = kind === "spell" ? 0 : stats.armorPenetrationPercent * 0.25;
|
|
return (1 + crit / 100) * (1 + hit / 100) * (1 + expertise / 100) * (1 + armorPenetration / 100);
|
|
}
|
|
|
|
export function hastedDuration(durationMs: number, hastePercent: number, minimumMs = 0): number {
|
|
if (durationMs <= 0) return 0;
|
|
return Math.max(minimumMs, Math.round(durationMs / (1 + Math.max(0, hastePercent) / 100)));
|
|
}
|
|
|
|
function resistanceForSchool(stats: CharacterStats, school: DamageSchool): number {
|
|
if (school === "arcane") return stats.arcaneResistance;
|
|
if (school === "fire") return stats.fireResistance;
|
|
if (school === "frost") return stats.frostResistance;
|
|
if (school === "nature") return stats.natureResistance;
|
|
if (school === "shadow") return stats.shadowResistance;
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Deterministic expected mitigation keeps combat tests repeatable while making
|
|
* armor, defense, dodge, parry, block, resilience, and resistances functional.
|
|
*/
|
|
export function damageAfterWotlkMitigation(
|
|
amount: number,
|
|
stats: CharacterStats,
|
|
school: DamageSchool = "physical",
|
|
attackerLevel = stats.level,
|
|
): number {
|
|
let remaining = Math.max(0, Number.isFinite(amount) ? amount : 0);
|
|
if (school === "physical") {
|
|
remaining *= 1 - armorReductionPercent(stats.armor, attackerLevel) / 100;
|
|
const avoidance = Math.min(80, stats.dodgePercent + stats.parryPercent);
|
|
remaining *= 1 - avoidance / 100;
|
|
remaining -= Math.min(remaining, stats.blockValue) * Math.min(100, stats.blockPercent) / 100;
|
|
} else {
|
|
const resistance = resistanceForSchool(stats, school);
|
|
const resisted = resistance / Math.max(1, resistance + Math.max(1, attackerLevel) * 5) * 0.75;
|
|
remaining *= 1 - Math.min(0.75, resisted);
|
|
}
|
|
remaining *= 1 - Math.min(0.75, stats.resiliencePercent / 100);
|
|
return remaining <= 0 ? 0 : Math.max(1, Math.round(remaining));
|
|
}
|
|
|
|
/**
|
|
* Runtime delta used by the existing encounter tuning: an ungeared character
|
|
* keeps the established baseline, while every defensive equipment stat uses
|
|
* its WotLK conversion and immediately changes damage taken.
|
|
*/
|
|
export function damageAfterEquipmentMitigation(
|
|
amount: number,
|
|
gear: ItemStats,
|
|
level: number,
|
|
school: DamageSchool = "physical",
|
|
attackerLevel = level,
|
|
): number {
|
|
const safe = safeLevel(level);
|
|
const defenseAvoidance = gear.defenseRating / Math.max(0.01, ratingPerDefenseSkill(safe)) * 0.04;
|
|
const equipmentStats: CharacterStats = {
|
|
...deriveCharacterStats("warrior", safe, gear),
|
|
armor: gear.armor + gear.agility * 2,
|
|
dodgePercent: gear.agility / (30 + safe * 2.5)
|
|
+ ratingPercent(gear.dodgeRating, "dodge", safe)
|
|
+ defenseAvoidance,
|
|
parryPercent: ratingPercent(gear.parryRating, "parry", safe) + defenseAvoidance,
|
|
blockPercent: gear.blockValue > 0
|
|
? 5 + ratingPercent(gear.blockRating, "block", safe) + defenseAvoidance
|
|
: 0,
|
|
blockValue: gear.blockValue + Math.floor(gear.strength / 2),
|
|
resiliencePercent: ratingPercent(gear.resilienceRating, "resilience", safe) * 2,
|
|
};
|
|
return damageAfterWotlkMitigation(amount, equipmentStats, school, attackerLevel);
|
|
}
|