import generatedLootCatalog from "./generated/lootCatalog.json"; 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; } 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>>; 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; } const ZERO_NUMERIC_STATS = Object.fromEntries( ITEM_STAT_KEYS.map((key) => [key, 0]), ) as unknown as Readonly>; 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> = Object.freeze({ common: 0.8, uncommon: 1, rare: 1.25, epic: 1.55, legendary: 2, heirloom: 1.65, }); const SLOT_WEIGHT: Readonly> = 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> = 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(); 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>; const weapon = scaledWeapon(sourceWeapon, ratio); return Object.freeze({ ...numeric, meleeWeapon: source.inventorySlot === "ranged" ? null : weapon, rangedWeapon: source.inventorySlot === "ranged" ? weapon : null, }); } /** 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; const numeric = Object.fromEntries( ITEM_STAT_KEYS.map((key) => [key, finiteStat(candidate[key])]), ) as unknown as Readonly>; const normalizeWeapon = (weapon: unknown): WeaponStats | null => { if (!weapon || typeof weapon !== "object" || Array.isArray(weapon)) return null; const candidateWeapon = weapon as Partial; 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 normalized: ItemStats = Object.freeze({ ...numeric, meleeWeapon: normalizeWeapon(candidate.meleeWeapon), rangedWeapon: normalizeWeapon(candidate.rangedWeapon), }); const hasStats = ITEM_STAT_KEYS.some((key) => normalized[key] > 0) || normalized.meleeWeapon || normalized.rangedWeapon; 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))); }