import { CLASSES, RACES, baseClassFor, classById, isRomClassId, type ClassId, type RaceId, } from "../app/characterCatalog"; import { romDualClassBaseStats } from "./romStats"; 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 { 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>; readonly resources: Readonly>; } 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>; readonly scalars: Readonly>>>; } 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; const RACE_DBC = Object.fromEntries(RACES.map((entry) => [entry.id, entry.dbcId])) as Record; const classUsesMana = (classId: ClassId): boolean => classById(classId).resourceSummary.includes("Mana"); /** * 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 >>> = 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; } export function deriveCharacterStats( classId: ClassId, level: number, gear: ItemStats, raceId: RaceId = "human", modifiers: CharacterStatPercentModifiers = {}, secondaryClassId?: ClassId | null, ): CharacterStats { const safe = safeLevel(level); const romBase = romDualClassBaseStats(classId, secondaryClassId, safe); const base = romBase ? { strength: romBase.strength, agility: romBase.agility, stamina: romBase.stamina, intellect: romBase.intellect, spirit: romBase.wisdom, } : baseAttributesFor(classId, safe, raceId); const 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); }