forked from phenom/healer-man
231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
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> = {},
|
|
): 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([]);
|
|
});
|
|
});
|