Files

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);
}