forked from phenom/healer-man
151 lines
7.5 KiB
TypeScript
151 lines
7.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ClassId } from "../app/characterCatalog";
|
|
import { classCanEquipItem, equipmentSlotForItem } from "./equipment";
|
|
import {
|
|
compareEquipmentUpgrade,
|
|
resolvedAverageItemLevel,
|
|
resolvedEquipmentItems,
|
|
resolvedEquipmentLoadout,
|
|
type HeirloomContext,
|
|
} from "./heirloomEquipment";
|
|
import { 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";
|
|
|
|
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"): InventoryItem {
|
|
return {
|
|
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,
|
|
};
|
|
}
|
|
|
|
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("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([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 a real item-level-85 upgrade path for every gear profile at level 80", () => {
|
|
expect(scaledLootItemLevel(79)).toBe(79);
|
|
expect(scaledLootItemLevel(80)).toBe(85);
|
|
for (const profile of SPECIALIZATION_DEFINITIONS) {
|
|
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([]);
|
|
});
|
|
});
|