forked from phenom/healer-man
350 lines
12 KiB
TypeScript
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)));
|
|
}
|