Files
healer-man/src/game/itemStats.ts

350 lines
12 KiB
TypeScript

import generatedLootCatalog from "./generated/lootCatalog.json";
import { normalizeRomNativeGearStats, type RomNativeGearStats } from "./romNativeStatsTypes";
export type ItemQuality = "common" | "uncommon" | "rare" | "epic" | "legendary" | "heirloom";
export type DamageSchool = "physical" | "holy" | "fire" | "nature" | "frost" | "shadow" | "arcane";
export interface WeaponStats {
readonly damageMin: number;
readonly damageMax: number;
readonly damageSchool: DamageSchool;
readonly speedMs: number;
}
/**
* The complete practical WotLK 3.3.5a item-stat vocabulary. The two weapon
* fields let a character retain both a melee weapon and a ranged weapon.
*/
export interface ItemStats {
readonly health: number;
readonly mana: number;
readonly strength: number;
readonly agility: number;
readonly stamina: number;
readonly intellect: number;
readonly spirit: number;
readonly armor: number;
readonly attackPower: number;
readonly rangedAttackPower: number;
readonly spellPower: number;
readonly criticalStrikeRating: number;
readonly hitRating: number;
readonly hasteRating: number;
readonly expertiseRating: number;
readonly armorPenetrationRating: number;
readonly defenseRating: number;
readonly dodgeRating: number;
readonly parryRating: number;
readonly blockRating: number;
readonly blockValue: number;
readonly resilienceRating: number;
readonly spellPenetration: number;
readonly mp5: number;
readonly healthPer5: number;
readonly arcaneResistance: number;
readonly fireResistance: number;
readonly frostResistance: number;
readonly natureResistance: number;
readonly shadowResistance: number;
readonly meleeWeapon: WeaponStats | null;
readonly rangedWeapon: WeaponStats | null;
/** Optional, explicitly RoM-native gear values; never projected into WotLK fields. */
readonly romNative?: RomNativeGearStats;
}
export const ITEM_STAT_KEYS = [
"health",
"mana",
"strength",
"agility",
"stamina",
"intellect",
"spirit",
"armor",
"attackPower",
"rangedAttackPower",
"spellPower",
"criticalStrikeRating",
"hitRating",
"hasteRating",
"expertiseRating",
"armorPenetrationRating",
"defenseRating",
"dodgeRating",
"parryRating",
"blockRating",
"blockValue",
"resilienceRating",
"spellPenetration",
"mp5",
"healthPer5",
"arcaneResistance",
"fireResistance",
"frostResistance",
"natureResistance",
"shadowResistance",
] as const satisfies readonly (keyof ItemStats)[];
export type NumericItemStatKey = (typeof ITEM_STAT_KEYS)[number];
export type SourceItemStats = Partial<Readonly<Record<NumericItemStatKey, number>>>;
export interface ItemStatSource {
readonly itemId: number;
readonly quality: ItemQuality;
readonly inventorySlot: string;
readonly armorType: string | null;
readonly weaponType: string | null;
readonly itemLevel: number;
readonly sourceItemLevel?: number;
readonly sourceStats?: SourceItemStats;
readonly sourceWeapon?: WeaponStats | null;
readonly romNative?: RomNativeGearStats;
}
const ZERO_NUMERIC_STATS = Object.fromEntries(
ITEM_STAT_KEYS.map((key) => [key, 0]),
) as unknown as Readonly<Record<NumericItemStatKey, number>>;
export const EMPTY_ITEM_STATS: ItemStats = Object.freeze({
...ZERO_NUMERIC_STATS,
meleeWeapon: null,
rangedWeapon: null,
});
export const HEALTH_PER_STAMINA = 10;
export const MANA_PER_INTELLECT = 15;
export const MAX_PERSISTED_ITEM_LEVEL = 255;
const QUALITY_MULTIPLIER: Readonly<Record<ItemQuality, number>> = Object.freeze({
common: 0.8,
uncommon: 1,
rare: 1.25,
epic: 1.55,
legendary: 2,
heirloom: 1.65,
});
const SLOT_WEIGHT: Readonly<Record<string, number>> = Object.freeze({
head: 1,
neck: 0.55,
shoulder: 0.75,
back: 0.45,
chest: 1.2,
wrist: 0.55,
hands: 0.7,
waist: 0.65,
legs: 1.05,
feet: 0.75,
finger: 0.55,
trinket: 0.75,
"main-hand": 1,
"one-hand": 1,
"two-hand": 1.6,
"off-hand": 0.85,
ranged: 0.9,
});
const ARMOR_MULTIPLIER: Readonly<Record<string, number>> = Object.freeze({
cloth: 1,
leather: 1.35,
mail: 1.7,
plate: 2.1,
shield: 3.2,
});
interface GeneratedCatalogShape {
readonly bosses?: readonly {
readonly items?: readonly {
readonly itemId?: number;
readonly sourceItemLevel?: number;
readonly sourceStats?: SourceItemStats;
readonly sourceWeapon?: WeaponStats | null;
}[];
}[];
}
const AUTHORED_SOURCES = new Map<number, {
readonly sourceItemLevel: number;
readonly sourceStats: SourceItemStats;
readonly sourceWeapon: WeaponStats | null;
}>();
for (const boss of (generatedLootCatalog as GeneratedCatalogShape).bosses ?? []) {
for (const item of boss.items ?? []) {
if (!Number.isFinite(item.itemId) || !Number.isFinite(item.sourceItemLevel)) continue;
AUTHORED_SOURCES.set(item.itemId!, {
sourceItemLevel: item.sourceItemLevel!,
sourceStats: item.sourceStats ?? {},
sourceWeapon: item.sourceWeapon ?? null,
});
}
}
function finiteStat(value: unknown): number {
return Number.isFinite(value) ? Math.max(0, Number(value)) : 0;
}
function scaledStat(value: unknown, ratio: number, key: NumericItemStatKey): number {
const sourceValue = finiteStat(value);
if (sourceValue <= 0) return 0;
if (key === "healthPer5") return Math.max(0.01, Math.round(sourceValue * ratio * 100) / 100);
return Math.max(1, Math.round(sourceValue * ratio));
}
function scaledWeapon(weapon: WeaponStats | null | undefined, ratio: number): WeaponStats | null {
if (!weapon || weapon.damageMax <= 0 || weapon.speedMs <= 0) return null;
return Object.freeze({
damageMin: Math.max(1, Math.round(finiteStat(weapon.damageMin) * ratio * 10) / 10),
damageMax: Math.max(1, Math.round(finiteStat(weapon.damageMax) * ratio * 10) / 10),
damageSchool: weapon.damageSchool,
speedMs: Math.max(100, Math.round(weapon.speedMs)),
});
}
function fallbackSourceStats(source: ItemStatSource): {
readonly stats: SourceItemStats;
readonly weapon: WeaponStats | null;
} {
const level = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(source.sourceItemLevel ?? source.itemLevel)));
const quality = QUALITY_MULTIPLIER[source.quality];
const slotWeight = SLOT_WEIGHT[source.inventorySlot] ?? 0.65;
const budget = Math.max(1, level * quality * slotWeight);
const armorMultiplier = source.armorType ? ARMOR_MULTIPLIER[source.armorType] ?? 0 : 0;
const stats: SourceItemStats = {
stamina: Math.max(1, Math.round(budget * 0.32)),
criticalStrikeRating: Math.max(1, Math.round(budget * 0.18)),
...(armorMultiplier > 0
? { armor: Math.max(1, Math.round(level * quality * slotWeight * armorMultiplier)) }
: {}),
};
const weapon = source.weaponType
? {
damageMin: Math.max(1, Math.round(budget * 0.85)),
damageMax: Math.max(2, Math.round(budget * 1.35)),
damageSchool: "physical" as const,
speedMs: source.weaponType === "dagger" ? 1800 : source.weaponType === "staff" ? 2900 : 2400,
}
: null;
return { stats, weapon };
}
/**
* Scales an authored 3.3.5a item template while preserving its original stat
* distribution and weapon speed. Old or synthetic items receive a WotLK-shaped
* fallback instead of the removed generic Power/Stamina/Armor budget.
*/
export function itemStatsFor(source: ItemStatSource): ItemStats {
const authored = AUTHORED_SOURCES.get(source.itemId);
const sourceLevel = Math.max(
1,
Math.trunc(source.sourceItemLevel ?? authored?.sourceItemLevel ?? source.itemLevel),
);
const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(source.itemLevel)));
const fallback = fallbackSourceStats(source);
const sourceStats = source.sourceStats
?? authored?.sourceStats
?? fallback.stats;
const sourceWeapon = source.sourceWeapon !== undefined
? source.sourceWeapon
: authored?.sourceWeapon ?? fallback.weapon;
const ratio = itemLevel / sourceLevel;
const numeric = Object.fromEntries(
ITEM_STAT_KEYS.map((key) => [key, scaledStat(sourceStats[key], ratio, key)]),
) as unknown as Readonly<Record<NumericItemStatKey, number>>;
const weapon = scaledWeapon(sourceWeapon, ratio);
const romNative = normalizeRomNativeGearStats(source.romNative);
return Object.freeze({
...numeric,
meleeWeapon: source.inventorySlot === "ranged" ? null : weapon,
rangedWeapon: source.inventorySlot === "ranged" ? weapon : null,
...(romNative ? { romNative } : {}),
});
}
/** Migrates pre-WotLK saved drops and validates modern persisted instances. */
export function normalizeItemStats(value: unknown, fallback: ItemStatSource): ItemStats {
if (!value || typeof value !== "object" || Array.isArray(value) || "power" in value) {
return itemStatsFor(fallback);
}
const candidate = value as Partial<ItemStats>;
const numeric = Object.fromEntries(
ITEM_STAT_KEYS.map((key) => [key, finiteStat(candidate[key])]),
) as unknown as Readonly<Record<NumericItemStatKey, number>>;
const normalizeWeapon = (weapon: unknown): WeaponStats | null => {
if (!weapon || typeof weapon !== "object" || Array.isArray(weapon)) return null;
const candidateWeapon = weapon as Partial<WeaponStats>;
const school = candidateWeapon.damageSchool;
if (
!["physical", "holy", "fire", "nature", "frost", "shadow", "arcane"].includes(school ?? "")
|| !Number.isFinite(candidateWeapon.damageMin)
|| !Number.isFinite(candidateWeapon.damageMax)
|| !Number.isFinite(candidateWeapon.speedMs)
) return null;
return scaledWeapon(candidateWeapon as WeaponStats, 1);
};
const romNative = normalizeRomNativeGearStats(candidate.romNative);
const normalized: ItemStats = Object.freeze({
...numeric,
meleeWeapon: normalizeWeapon(candidate.meleeWeapon),
rangedWeapon: normalizeWeapon(candidate.rangedWeapon),
...(romNative ? { romNative } : {}),
});
const hasStats = ITEM_STAT_KEYS.some((key) => normalized[key] > 0)
|| normalized.meleeWeapon
|| normalized.rangedWeapon
|| normalized.romNative;
return hasStats ? normalized : itemStatsFor(fallback);
}
const SUMMARY_STATS: readonly [NumericItemStatKey, string][] = [
["strength", "Strength"],
["agility", "Agility"],
["stamina", "Stamina"],
["intellect", "Intellect"],
["spirit", "Spirit"],
["spellPower", "Spell Power"],
["attackPower", "Attack Power"],
["criticalStrikeRating", "Critical Strike"],
["defenseRating", "Defense"],
["armor", "Armor"],
];
export function itemStatSummary(stats: ItemStats): string {
const weapon = stats.meleeWeapon ?? stats.rangedWeapon;
const lines = [
...(weapon ? [`${weapon.damageMin}-${weapon.damageMax} Damage`] : []),
...SUMMARY_STATS
.filter(([key]) => stats[key] > 0)
.map(([key, label]) => `${key === "armor" ? "" : "+"}${stats[key]} ${label}`),
];
return lines.slice(0, 3).join(" · ") || "No stat bonus";
}
export function weaponDps(weapon: WeaponStats): number {
return (weapon.damageMin + weapon.damageMax) / 2 / Math.max(0.1, weapon.speedMs / 1_000);
}
export function healthFromStamina(stamina: number): number {
return Math.max(0, Math.round(stamina)) * HEALTH_PER_STAMINA;
}
export function manaFromIntellect(intellect: number): number {
return Math.max(0, Math.round(intellect)) * MANA_PER_INTELLECT;
}
export function armorReductionPercent(armor: number, attackerLevel: number): number {
const safeArmor = Math.max(0, Number.isFinite(armor) ? armor : 0);
if (safeArmor <= 0) return 0;
const level = Math.max(1, Math.min(83, Math.trunc(attackerLevel)));
const armorConstant = level <= 59
? 400 + 85 * level
: 400 + 85 * level + 382.5 * (level - 59);
return Math.min(75, safeArmor / (safeArmor + armorConstant) * 100);
}
/** WotLK physical armor mitigation, capped at 75%. */
export function damageAfterArmor(amount: number, armor: number, attackerLevel: number): number {
const incoming = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
if (incoming <= 0) return 0;
return Math.max(1, Math.round(incoming * (1 - armorReductionPercent(armor, attackerLevel) / 100)));
}