import { describe, expect, it } from "vitest"; import { contentCategoryForClass, type ClassId } from "../app/characterCatalog"; import { classCanEquipItem, equipmentSlotForItem } from "./equipment"; import { compareEquipmentUpgrade, equipmentStatsForItems, resolvedAverageItemLevel, resolvedEquipmentItems, resolvedEquipmentLoadout, resolvedEquipmentStats, type HeirloomContext, } from "./heirloomEquipment"; import { EMPTY_ITEM_STATS, itemStatsFor } from "./itemStats"; import { LOOT_CATALOG, scaledLootItemLevel, type LootCatalogItem } from "./lootCatalog"; import type { InventoryItem } from "./lootTypes"; import { SPECIALIZATION_DEFINITIONS, type SpecializationDefinition, } from "./specializationCatalog"; import { useCombatStore } from "./combatStore"; import { normalizeInventory } from "./lootTypes"; import { deriveCharacterStats } from "./wotlkStats"; const templates = LOOT_CATALOG.bosses.flatMap((boss) => boss.items); function context(profile: SpecializationDefinition, level: number, ownerKind: "player" | "party" = "player"): HeirloomContext { return { ownerId: ownerKind === "player" ? "player" : `party-${profile.id}`, ownerKind, classId: profile.classId, specializationId: profile.id, role: profile.role, level, }; } function catalogItem( template: LootCatalogItem, itemLevel: number, suffix = "test", overrides: Partial = {}, ): InventoryItem { return { contentCategory: "wow", instanceId: `${template.itemId}:${itemLevel}:${suffix}`, 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: "test-boss", sourceBossName: "Test Boss", acquiredAt: 1, ...overrides, }; } function compatibleItems(classId: ClassId, level: number): InventoryItem[] { return templates .map((template, index) => catalogItem(template, level, String(index))) .filter((item) => classCanEquipItem(classId, item)); } describe("specialization-aware heirlooms", () => { it("caps player and party heirlooms at 79", () => { const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!; expect(new Set(resolvedEquipmentItems({}, [], context(profile, 80)).map((item) => item.itemLevel))).toEqual(new Set([79])); expect(new Set(resolvedEquipmentItems({}, [], context(profile, 80, "party")).map((item) => item.itemLevel))).toEqual(new Set([79])); expect(resolvedAverageItemLevel({}, [], context(profile, 40))).toBe(40); }); it("fills all applicable slots and uses the specialization weapon layout", () => { const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!; const loadout = resolvedEquipmentLoadout({}, [], context(protection, 40)); expect(loadout["head"]?.quality).toBe("heirloom"); expect(loadout["finger"]?.quality).toBe("heirloom"); expect(loadout["trinket"]?.quality).toBe("heirloom"); expect(loadout["main-hand"]?.inventorySlot).toBe("one-hand"); expect(loadout["off-hand"]?.armorType).toBe("shield"); }); it("keeps RoM training defaults when foreign, unresolved, or non-native assigned items are present", () => { const romContext: HeirloomContext = { ownerId: "rom-owner", ownerKind: "player", classId: "rom-priest", specializationId: null, role: "healer", level: 40, }; const chestTemplate = templates.find((template) => template.inventorySlot === "chest" && template.armorType === "cloth")!; const training = resolvedEquipmentLoadout({}, [], romContext).chest!; expect(training).toMatchObject({ contentCategory: "rom", quality: "heirloom", sourceBossKey: "heirloom", sourceBossName: "Training Armory", }); expect(training.stats.romNative).toBeDefined(); const trainingSlotWithoutReference = resolvedEquipmentLoadout({}, [], romContext)["off-hand"]!; expect(trainingSlotWithoutReference.stats.romNative).toBeUndefined(); expect(equipmentStatsForItems([trainingSlotWithoutReference]).romNative).toBeDefined(); const copiedTraining = JSON.parse(JSON.stringify(trainingSlotWithoutReference)) as InventoryItem; expect(copiedTraining).toMatchObject({ contentCategory: "rom", instanceId: trainingSlotWithoutReference.instanceId, sourceBossName: "Training Armory", }); expect(equipmentStatsForItems([copiedTraining]).romNative).toBeUndefined(); expect(classCanEquipItem("rom-priest", copiedTraining)).toBe(false); const badAssignedItems = [ catalogItem(chestTemplate, 40, "foreign-wow"), catalogItem(chestTemplate, 40, "unresolved", { contentCategory: null }), catalogItem(chestTemplate, 40, "rom-without-native", { contentCategory: "rom" }), ]; for (const item of badAssignedItems) { expect(classCanEquipItem("rom-priest", item)).toBe(false); const loadout = resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext); expect(loadout.chest).toMatchObject({ sourceBossName: "Training Armory", contentCategory: "rom" }); expect(resolvedEquipmentStats({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext)) .toEqual(resolvedEquipmentStats({}, [], romContext)); } const nativeItem = catalogItem(chestTemplate, 40, "rom-native", { contentCategory: "rom", stats: itemStatsFor({ ...chestTemplate, itemLevel: 40, romNative: { stamina: 3 } }), }); expect(classCanEquipItem("rom-priest", nativeItem)).toBe(true); expect(resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: nativeItem.instanceId } }, [nativeItem], romContext).chest) .toBe(nativeItem); }); it("allocates only useful archetype stats", () => { const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!; const holy = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!; const combat = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "rogue:combat")!; const protectionStats = resolvedEquipmentItems({}, [], context(protection, 40)).find((item) => item.inventorySlot === "chest")!.stats; const holyStats = resolvedEquipmentItems({}, [], context(holy, 40)).find((item) => item.inventorySlot === "chest")!.stats; const combatStats = resolvedEquipmentItems({}, [], context(combat, 40)).find((item) => item.inventorySlot === "chest")!.stats; expect(protectionStats).toMatchObject({ stamina: expect.any(Number), defenseRating: expect.any(Number), blockRating: expect.any(Number), spellPower: 0 }); expect(holyStats).toMatchObject({ intellect: expect.any(Number), spirit: expect.any(Number), spellPower: expect.any(Number), mp5: expect.any(Number), attackPower: 0 }); expect(combatStats).toMatchObject({ agility: expect.any(Number), attackPower: expect.any(Number), hitRating: expect.any(Number), spellPower: 0 }); expect(protectionStats.blockRating).toBeGreaterThan(0); expect(holyStats.spellPower).toBeGreaterThan(0); expect(combatStats.agility).toBeGreaterThan(0); }); it.each([ "priest:discipline", "hunter:marksmanship", "mage:fire", "rogue:assassination", ])("turns %s heirloom crit and haste ratings into combat percentages", (specializationId) => { const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === specializationId)!; const gear = equipmentStatsForItems(resolvedEquipmentItems({}, [], context(profile, 80))); const geared = deriveCharacterStats(profile.classId, 80, gear); const ungeared = deriveCharacterStats(profile.classId, 80, EMPTY_ITEM_STATS); expect(gear.criticalStrikeRating).toBeGreaterThan(0); expect(gear.hasteRating).toBeGreaterThan(0); expect(Math.max(geared.meleeCritPercent, geared.rangedCritPercent, geared.spellCritPercent)) .toBeGreaterThan(Math.max(ungeared.meleeCritPercent, ungeared.rangedCritPercent, ungeared.spellCritPercent)); expect(Math.max(geared.meleeHastePercent, geared.rangedHastePercent, geared.spellHastePercent)).toBeGreaterThan(0); }); it.each([1, 40, 79])("keeps compatible same-level boss items below heirlooms at level %i", (level) => { for (const profile of SPECIALIZATION_DEFINITIONS) { const loadout = resolvedEquipmentLoadout({}, [], context(profile, level)); for (const item of compatibleItems(profile.classId, level)) { expect(compareEquipmentUpgrade(item, loadout, profile, context(profile, level)).isUpgrade, `${profile.id}: ${item.name}`).toBe(false); } } }); it("opens an item-level-85 catalog upgrade path for every WoW gear profile at level 80", () => { expect(scaledLootItemLevel(79)).toBe(79); expect(scaledLootItemLevel(80)).toBe(85); for (const profile of SPECIALIZATION_DEFINITIONS.filter((candidate) => contentCategoryForClass(candidate.classId) === "wow")) { const loadout = resolvedEquipmentLoadout({}, [], context(profile, 80)); const upgrades = compatibleItems(profile.classId, 85) .filter((item) => compareEquipmentUpgrade(item, loadout, profile, context(profile, 80)).isUpgrade); expect(upgrades.length, profile.id).toBeGreaterThan(0); } }); it("lets earned assignments override a slot and restores its heirloom when removed", () => { const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!; const ownerContext = context(profile, 80); const item = compatibleItems(profile.classId, 85).find((candidate) => equipmentSlotForItem(candidate) === "head") ?? compatibleItems(profile.classId, 85)[0]; const slot = equipmentSlotForItem(item)!; const equipped = resolvedEquipmentLoadout({ player: { [slot]: item.instanceId } }, [item], ownerContext); expect(equipped[slot]).toBe(item); expect(resolvedEquipmentLoadout({}, [item], ownerContext)[slot]?.quality).toBe("heirloom"); }); it("preserves player health and resource percentages across gear maximum changes", () => { const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!; useCombatStore.getState().initializeCharacter({ classId: "priest", specializationId: profile.id, role: profile.role, level: 40, }); const item = compatibleItems("priest", 85).find((candidate) => equipmentSlotForItem(candidate) === "head") ?? compatibleItems("priest", 85)[0]; const before = useCombatStore.getState(); useCombatStore.setState({ health: before.maxHealth * 0.4, resource: before.maxResource * 0.25, inventory: [item], }); expect(useCombatStore.getState().equipItem(item.instanceId)).toBe(true); expect(useCombatStore.getState().health / useCombatStore.getState().maxHealth).toBeCloseTo(0.4, 5); expect(useCombatStore.getState().resource / useCombatStore.getState().maxResource).toBeCloseTo(0.25, 5); }); it("raises persisted item safety to 255 without admitting virtual heirlooms", () => { const persisted = { ...compatibleItems("priest", 85)[0], itemLevel: 999 }; expect(normalizeInventory([persisted])[0].itemLevel).toBe(255); expect(normalizeInventory([{ ...persisted, quality: "heirloom" }])).toEqual([]); }); });