import { contentCategoryForClass, classById, isRomClassId, type ClassId, } from "../app/characterCatalog"; import { classCanEquipItem, classItemShapeFitsEquipmentRules, EQUIPMENT_SLOTS, equipmentSlotForItem, type EquipmentAssignments, type EquipmentLoadout, type EquipmentSlot, } from "./equipment"; import { EMPTY_ITEM_STATS, ITEM_STAT_KEYS, itemStatsFor, type ItemStats, type NumericItemStatKey, type SourceItemStats, type WeaponStats, } from "./itemStats"; import { LOOT_CATALOG, type LootCatalogItem } from "./lootCatalog"; import type { InventoryItem } from "./lootTypes"; import type { PartyRole } from "./partyRoles"; import { romNativeGearReference } from "./romNativeGear"; import { romNativeGearFromSharedDirectStats, sumRomNativeGearStats } from "./romNativeStatsTypes"; import { gearProfileFor, isRomHybridGearClass, specializationScore, type GearArchetype, type SpecializationDefinition, type WeaponLayout, } from "./specializationCatalog"; export const PLAYER_HEIRLOOM_LEVEL_CAP = 79; export const PARTY_HEIRLOOM_LEVEL_CAP = 79; export const FIRST_ENDGAME_ITEM_LEVEL = 85; export type HeirloomOwnerKind = "player" | "party"; export interface HeirloomContext { readonly ownerId: string; readonly ownerKind: HeirloomOwnerKind; readonly classId: ClassId; readonly secondaryClassId?: ClassId | null; readonly specializationId: string | null; readonly role: PartyRole; readonly level: number; } export interface EquipmentUpgradeComparison { readonly isUpgrade: boolean; readonly scoreDelta: number; readonly currentScore: number; readonly candidateScore: number; readonly displacedSlots: readonly EquipmentSlot[]; readonly usefulStatDeltas: Readonly>>; } const BASE_SLOTS: readonly EquipmentSlot[] = [ "head", "neck", "shoulder", "back", "chest", "wrist", "hands", "waist", "legs", "feet", "finger", "trinket", ]; 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, "off-hand": 0.85, ranged: 0.9, }); const SLOT_NAMES: Readonly> = Object.freeze({ head: "Crown", neck: "Pendant", shoulder: "Mantle", back: "Cloak", chest: "Vestment", wrist: "Bindings", hands: "Grips", waist: "Sash", legs: "Legguards", feet: "Treads", finger: "Signet", trinket: "Charm", "main-hand": "Weapon", "off-hand": "Focus", ranged: "Ranged Arm", }); const ARMOR_SLOTS = new Set([ "head", "shoulder", "chest", "wrist", "hands", "waist", "legs", "feet", ]); const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items); const itemCache = new Map(); // Training placeholders are generated internally and are never serialized as // earned inventory. Keep their legacy simulator projection private so it can // preserve the existing RoM training profile without making direct WoW stats // valid on a caller-supplied RoM item. const romTrainingItems = new WeakSet(); function effectiveLevel(context: HeirloomContext): number { const cap = context.ownerKind === "party" ? PARTY_HEIRLOOM_LEVEL_CAP : PLAYER_HEIRLOOM_LEVEL_CAP; return Math.max(1, Math.min(cap, Math.trunc(Number.isFinite(context.level) ? context.level : 1))); } function weaponSlots(context: HeirloomContext, layout: WeaponLayout): readonly EquipmentSlot[] { let slots: readonly EquipmentSlot[]; switch (layout) { case "dual-wield": case "weapon-shield": slots = ["main-hand", "off-hand"]; break; case "ranged": slots = ["main-hand", "ranged"]; break; case "caster": slots = ["main-hand", "off-hand", "ranged"]; break; default: slots = ["main-hand"]; } if (!isRomHybridGearClass(context.classId)) return slots; const level = effectiveLevel(context); const resolved = new Set(slots); if (context.classId === "rom-rogue" && level < 16) resolved.delete("off-hand"); if (context.classId === "rom-rogue" && level >= 12) resolved.add("ranged"); if (context.classId === "rom-knight" && context.secondaryClassId === "rom-scout" && level >= 10) resolved.add("ranged"); if (context.classId === "rom-scout" && context.secondaryClassId === "rom-rogue" && level >= 16) resolved.add("off-hand"); return [...resolved]; } function armorTypeForSlot(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string | null { if (slot === "off-hand" && layout === "weapon-shield") return "shield"; if (ARMOR_SLOTS.has(slot)) return classById(classId).armor; return null; } function weaponTypeFor(classId: ClassId, slot: EquipmentSlot, profile: SpecializationDefinition): string | null { if (slot === "off-hand" && (profile.weaponLayout === "dual-wield" || classId === "rom-scout")) return "dagger"; if (slot === "ranged") return classId === "rom-rogue" ? "projectile" : profile.gearArchetype === "spell-dps" || profile.gearArchetype === "healer" ? "wand" : "bow"; if (slot !== "main-hand") return null; if (classId === "rom-scout" && profile.weaponLayout === "ranged") return "dagger"; if (classId === "rom-mage") return profile.weaponLayout === "two-hand" ? "staff" : "dagger"; if (profile.weaponLayout === "caster") { return ["priest", "shaman", "druid", "witch-doctor", "chronomancer", "primalist", "venomancer"].includes(classId) ? "mace" : "dagger"; } if (profile.weaponLayout === "dual-wield") return "dagger"; if (classId === "hunter" || classId === "ranger") return "staff"; return "mace"; } function inventorySlotFor(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string { if (slot === "main-hand") return layout === "two-hand" || (layout === "ranged" && classId !== "rom-scout") ? "two-hand" : "one-hand"; return slot; } function statDistribution(archetype: GearArchetype, budget: number, armor: number): SourceItemStats { const common = { stamina: Math.max(1, Math.round(budget * 0.28)), ...(armor > 0 ? { armor } : {}) }; switch (archetype) { case "strength-dps": return { ...common, strength: Math.round(budget * 0.55), attackPower: Math.round(budget * 0.5), hitRating: Math.round(budget * 0.2), criticalStrikeRating: Math.round(budget * 0.2), hasteRating: Math.round(budget * 0.16), expertiseRating: Math.round(budget * 0.14), armorPenetrationRating: Math.round(budget * 0.14) }; case "agility-dps": return { ...common, agility: Math.round(budget * 0.55), attackPower: Math.round(budget * 0.48), hitRating: Math.round(budget * 0.2), criticalStrikeRating: Math.round(budget * 0.22), hasteRating: Math.round(budget * 0.16), expertiseRating: Math.round(budget * 0.14), armorPenetrationRating: Math.round(budget * 0.14) }; case "ranged-dps": return { ...common, agility: Math.round(budget * 0.55), rangedAttackPower: Math.round(budget * 0.52), hitRating: Math.round(budget * 0.21), criticalStrikeRating: Math.round(budget * 0.22), hasteRating: Math.round(budget * 0.17), armorPenetrationRating: Math.round(budget * 0.13) }; case "spell-dps": return { ...common, intellect: Math.round(budget * 0.5), spirit: Math.round(budget * 0.18), spellPower: Math.round(budget * 0.58), hitRating: Math.round(budget * 0.22), criticalStrikeRating: Math.round(budget * 0.18), hasteRating: Math.round(budget * 0.2) }; case "healer": return { ...common, intellect: Math.round(budget * 0.5), spirit: Math.round(budget * 0.38), spellPower: Math.round(budget * 0.58), mp5: Math.round(budget * 0.16), criticalStrikeRating: Math.round(budget * 0.12), hasteRating: Math.round(budget * 0.2) }; case "block-tank": return { ...common, strength: Math.round(budget * 0.35), stamina: Math.round(budget * 0.72), defenseRating: Math.round(budget * 0.24), dodgeRating: Math.round(budget * 0.18), parryRating: Math.round(budget * 0.18), blockRating: Math.round(budget * 0.2), blockValue: Math.round(budget * 0.2), expertiseRating: Math.round(budget * 0.1) }; case "non-block-tank": return { ...common, agility: Math.round(budget * 0.34), strength: Math.round(budget * 0.12), stamina: Math.round(budget * 0.72), defenseRating: Math.round(budget * 0.22), dodgeRating: Math.round(budget * 0.24), parryRating: Math.round(budget * 0.12), expertiseRating: Math.round(budget * 0.1) }; } } function weaponStats(slot: EquipmentSlot, inventorySlot: string, weaponType: string | null, budget: number): WeaponStats | null { if (!weaponType) return null; const twoHand = inventorySlot === "two-hand"; const multiplier = twoHand ? 1.55 : slot === "ranged" ? 1.15 : 0.9; return { damageMin: Math.max(1, Math.round(budget * 0.8 * multiplier * 10) / 10), damageMax: Math.max(2, Math.round(budget * 1.25 * multiplier * 10) / 10), damageSchool: "physical", speedMs: weaponType === "dagger" ? 1800 : weaponType === "staff" ? 2900 : weaponType === "bow" ? 2800 : 2400, }; } function syntheticCatalogItem(template: LootCatalogItem, itemLevel: number): InventoryItem { return { contentCategory: "wow", instanceId: `catalog:${template.itemId}`, itemId: template.itemId, itemKey: template.itemKey, name: template.itemName, quality: template.quality, inventorySlot: template.inventorySlot, armorType: template.armorType, weaponType: template.weaponType, itemLevel, sourceItemLevel: template.sourceItemLevel, stats: itemStatsFor({ ...template, itemLevel }), quantity: 1, sourceBossKey: "catalog", sourceBossName: "Boss catalog", acquiredAt: 0, }; } function highestCatalogScore(classId: ClassId, slot: EquipmentSlot, level: number, profile: SpecializationDefinition): number { let highest = 0; for (const template of CATALOG_ITEMS) { const candidate = syntheticCatalogItem(template, level); // Catalog comparison is an internal shape-only specialization baseline. // These WoW templates never enter inventory or an equipment assignment. if (equipmentSlotForItem(candidate) !== slot || !classItemShapeFitsEquipmentRules(classId, candidate)) continue; highest = Math.max(highest, specializationScore(candidate.stats, profile)); } return highest; } function scaleStats(stats: ItemStats, factor: number): ItemStats { const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, Math.max(0, Math.round(stats[key] * factor))])) as Record; const scaleWeapon = (weapon: WeaponStats | null): WeaponStats | null => weapon ? { ...weapon, damageMin: Math.max(1, Math.round(weapon.damageMin * factor * 10) / 10), damageMax: Math.max(2, Math.round(weapon.damageMax * factor * 10) / 10), } : null; return Object.freeze({ ...numeric, meleeWeapon: scaleWeapon(stats.meleeWeapon), rangedWeapon: scaleWeapon(stats.rangedWeapon), ...(stats.romNative ? { romNative: stats.romNative } : {}), }); } function heirloomItem( context: HeirloomContext, profile: SpecializationDefinition, slot: EquipmentSlot, forcedLayout?: WeaponLayout, ): InventoryItem { const level = effectiveLevel(context); const layout = forcedLayout ?? profile.weaponLayout; const cacheKey = `${context.ownerId}|${context.classId}|${profile.id}|${level}|${slot}|${layout}`; const cached = itemCache.get(cacheKey); if (cached) return cached; const armorType = armorTypeForSlot(context.classId, slot, layout); const weaponType = weaponTypeFor(context.classId, slot, { ...profile, weaponLayout: layout }); const inventorySlot = inventorySlotFor(context.classId, slot, layout); const weight = SLOT_WEIGHT[slot]; const budget = Math.max(2, level * weight * 1.18); const armor = armorType && armorType !== "shield" ? Math.max(1, Math.round(level * weight * ({ cloth: 1, leather: 1.35, mail: 1.7, chain: 1.7, plate: 2.1 }[armorType] ?? 1))) : armorType === "shield" ? Math.max(2, Math.round(level * weight * 3.2)) : 0; const distributedStats = statDistribution(profile.gearArchetype, budget, armor); const sourceStats = armorType === "shield" && isRomHybridGearClass(context.classId) ? { ...distributedStats, blockValue: Math.max(1, Math.round(level * weight * 0.45)) } : distributedStats; const sourceWeapon = weaponStats(slot, inventorySlot, weaponType, budget); const romNative = isRomClassId(context.classId) ? romNativeGearReference(context.classId, slot, level) : null; let stats = itemStatsFor({ itemId: 9_000_000, quality: "heirloom", inventorySlot, armorType, weaponType, itemLevel: level, sourceItemLevel: level, sourceStats, sourceWeapon, ...(romNative ? { romNative } : {}), }); const currentScore = specializationScore(stats, profile); const catalogScore = isRomHybridGearClass(context.classId) ? 0 : highestCatalogScore(context.classId, slot, level, profile) + (slot === "main-hand" && inventorySlot === "two-hand" ? highestCatalogScore(context.classId, "off-hand", level, profile) : 0); const conflictSafety = slot === "main-hand" && inventorySlot === "two-hand" ? 1.05 : 1.02; const targetScore = catalogScore > 0 ? catalogScore * conflictSafety : currentScore; if (currentScore > 0 && Math.abs(targetScore - currentScore) > 0.01) stats = scaleStats(stats, targetScore / currentScore); const normalizedScore = specializationScore(stats, profile); if (normalizedScore + 0.001 < targetScore) { const [bestKey, bestWeight] = Object.entries(profile.statWeights) .sort((left, right) => (right[1] ?? 0) - (left[1] ?? 0))[0] as [NumericItemStatKey, number]; stats = Object.freeze({ ...stats, [bestKey]: stats[bestKey] + Math.max(1, Math.ceil((targetScore - normalizedScore) / Math.max(0.01, bestWeight))), }); } const stableNumber = [...`${profile.id}:${slot}`].reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 17); // RoM has no heirloom items. The shared fallback supplies automatic training // gear until equipment progression is introduced; retain its stable IDs and // internal quality so this label change cannot alter saved builds or budgets. const romTrainingGear = isRomClassId(context.classId); const result: InventoryItem = Object.freeze({ contentCategory: contentCategoryForClass(context.classId), instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`, itemId: 9_000_000 + stableNumber % 900_000, itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`, name: `${profile.displayName} ${romTrainingGear ? "Training" : "Heirloom"} ${SLOT_NAMES[slot]}`, quality: "heirloom", inventorySlot, armorType, weaponType, itemLevel: level, sourceItemLevel: level, stats, quantity: 1, sourceBossKey: "heirloom", sourceBossName: romTrainingGear ? "Training Armory" : "Heirloom Armory", acquiredAt: 0, }); if (romTrainingGear) romTrainingItems.add(result); itemCache.set(cacheKey, result); return result; } export function resolvedEquipmentLoadout( assignments: EquipmentAssignments, inventory: readonly InventoryItem[], context: HeirloomContext, ): Readonly>> { const profile = gearProfileFor(context.classId, context.specializationId, context.role); const result: Partial> = {}; for (const slot of [...BASE_SLOTS, ...weaponSlots(context, profile.weaponLayout)]) { if (isRomHybridGearClass(context.classId) && slot === "wrist") continue; result[slot] = heirloomItem(context, profile, slot); } const itemById = new Map(inventory.map((item) => [item.instanceId, item])); const earned = assignments[context.ownerId] ?? {}; for (const slot of EQUIPMENT_SLOTS) { const item = earned[slot] ? itemById.get(earned[slot]!) : null; if (!item || !classCanEquipItem(context.classId, item, context)) continue; if (slot === "main-hand" && item.inventorySlot === "two-hand") delete result["off-hand"]; if (slot === "off-hand" && result["main-hand"]?.inventorySlot === "two-hand") { result["main-hand"] = heirloomItem(context, profile, "main-hand", "one-hand"); } result[slot] = item; } return Object.freeze(result); } export function resolvedEquipmentItems( assignments: EquipmentAssignments, inventory: readonly InventoryItem[], context: HeirloomContext, ): readonly InventoryItem[] { const loadout = resolvedEquipmentLoadout(assignments, inventory, context); return EQUIPMENT_SLOTS.flatMap((slot) => loadout[slot] ? [loadout[slot]!] : []); } export function equipmentStatsForItems(items: readonly InventoryItem[]): ItemStats { if (!items.length) return EMPTY_ITEM_STATS; const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, 0])) as Record; let meleeWeapon: WeaponStats | null = null; let rangedWeapon: WeaponStats | null = null; const nativePayloads = [] as NonNullable[]; for (const item of items) { for (const key of ITEM_STAT_KEYS) numeric[key] += item.stats[key]; const nativePayload = item.stats.romNative ?? (romTrainingItems.has(item) || item.contentCategory === undefined || item.contentCategory === "wow" ? romNativeGearFromSharedDirectStats(item.stats) : undefined); if (nativePayload) nativePayloads.push(nativePayload); if (item.stats.meleeWeapon) meleeWeapon = item.stats.meleeWeapon; if (item.stats.rangedWeapon) rangedWeapon = item.stats.rangedWeapon; } const romNative = sumRomNativeGearStats(nativePayloads); return Object.freeze({ ...numeric, meleeWeapon, rangedWeapon, ...(romNative ? { romNative } : {}) }); } export function resolvedEquipmentStats( assignments: EquipmentAssignments, inventory: readonly InventoryItem[], context: HeirloomContext, ): ItemStats { return equipmentStatsForItems(resolvedEquipmentItems(assignments, inventory, context)); } export function resolvedAverageItemLevel( assignments: EquipmentAssignments, inventory: readonly InventoryItem[], context: HeirloomContext, ): number { const items = resolvedEquipmentItems(assignments, inventory, context); if (!items.length) return 0; return Math.round(items.reduce((total, item) => total + item.itemLevel, 0) / items.length * 10) / 10; } function sumStats(items: readonly InventoryItem[]): ItemStats { return equipmentStatsForItems(items); } export function compareEquipmentUpgrade( candidate: InventoryItem, currentLoadout: Readonly>>, profile: SpecializationDefinition, context?: HeirloomContext, ): EquipmentUpgradeComparison { const slot = equipmentSlotForItem(candidate); if (!slot) return { isUpgrade: false, scoreDelta: 0, currentScore: 0, candidateScore: 0, displacedSlots: [], usefulStatDeltas: {} }; const displacedSlots: EquipmentSlot[] = slot === "main-hand" && candidate.inventorySlot === "two-hand" ? ["main-hand", "off-hand"] : slot === "off-hand" && currentLoadout["main-hand"]?.inventorySlot === "two-hand" ? ["main-hand", "off-hand"] : [slot]; const currentItems = displacedSlots.flatMap((key) => currentLoadout[key] ? [currentLoadout[key]!] : []); const currentStats = sumStats(currentItems); const candidateItems = slot === "off-hand" && currentLoadout["main-hand"]?.inventorySlot === "two-hand" && context ? [heirloomItem(context, profile, "main-hand", "one-hand"), candidate] : [candidate]; const candidateStats = sumStats(candidateItems); const currentScore = specializationScore(currentStats, profile); const candidateScore = specializationScore(candidateStats, profile); const scoreDelta = Math.round((candidateScore - currentScore) * 100) / 100; const usefulStatDeltas = Object.fromEntries(Object.keys(profile.statWeights).flatMap((rawKey) => { const key = rawKey as NumericItemStatKey; const delta = candidateStats[key] - currentStats[key]; return delta ? [[key, delta]] : []; })) as Partial>; return Object.freeze({ isUpgrade: scoreDelta > 0, scoreDelta, currentScore, candidateScore, displacedSlots, usefulStatDeltas, }); } export function emptyResolvedLoadout(): EquipmentLoadout { return {}; }