import { describe, expect, it } from "vitest"; import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog"; import { isValidRomClassPair } from "../app/romDualClass"; import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds"; import { readinessStatusFor } from "./companionSpecReadiness"; import { classCanEquipItem } from "./equipment"; import { equipmentStatsForItems, resolvedEquipmentLoadout } from "./heirloomEquipment"; import { partyRolesForClass } from "./partyRoles"; import { createConfiguredPartyMember, createDungeonParty, createSpectatorDungeonParty, usePartyStore } from "./partyStore"; import { ROM_ORDINARY_ABILITIES_BY_CLASS } from "./romAbilityCatalog"; import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats"; import { romGearProfile } from "./specializationCatalog"; import { baseAttributesFor, baseResourcesFor, deriveCharacterStats, healthFromTotalStamina, manaFromTotalIntellect, MANA_POOL_MULTIPLIER } from "./wotlkStats"; const CASES = [ { classId: "rom-priest", role: "healer", layout: "weapon-shield" }, { classId: "rom-knight", role: "tank", layout: "weapon-shield" }, { classId: "rom-scout", role: "damage", layout: "ranged" }, { classId: "rom-rogue", role: "damage", layout: "dual-wield" }, { classId: "rom-mage", role: "damage", layout: "two-hand" }, ] as const; describe("RoM five class validation scope", () => { it.each([1, 10, 30, 60, 79])("uses WoW base attribute and resource curves for the accepted pairs at level %i", (level) => { const referenceRace: Record = { "rom-knight": "human", "rom-priest": "human", "rom-scout": "night-elf", "rom-rogue": "human", "rom-mage": "human", }; for (const identity of ROM_FIVE_IDENTITIES) { const secondary = level >= 10 ? identity.secondaryClassId : null; const primaryBase = baseAttributesFor(identity.classId, level, referenceRace[identity.classId] ?? "human"); const secondaryBase = secondary ? baseAttributesFor(secondary, level, referenceRace[secondary] ?? "human") : null; const expected = Object.fromEntries((Object.keys(primaryBase) as (keyof typeof primaryBase)[]).map((stat) => [ stat, Math.round(primaryBase[stat] + (secondaryBase?.[stat] ?? 0) * .1), ])); const combat = deriveCharacterStats(identity.classId, level, EMPTY_ITEM_STATS, identity.raceId, {}, secondary); expect(combat, `${identity.name} at ${level}`).toMatchObject(expected); const baseResources = baseResourcesFor(identity.classId, level); const secondaryResources = secondary ? baseResourcesFor(secondary, level) : null; expect(combat.health).toBe(Math.round(baseResources.health + (secondaryResources?.health ?? 0) * .1 + healthFromTotalStamina(combat.stamina))); if (identity.classId === "rom-knight" || identity.classId === "rom-priest" || identity.classId === "rom-mage") { expect(combat.mana).toBe(MANA_POOL_MULTIPLIER * Math.round(baseResources.mana + (secondaryResources?.mana ?? 0) * .1 + manaFromTotalIntellect(combat.intellect))); } } }); it.each([10, 30, 60, 80])("keeps each secondary class at the primary level for stat growth at level %i", (level) => { for (const identity of ROM_FIVE_IDENTITIES) { const primary = romNativeClassBaseStats(identity.classId, level); const secondary = romNativeClassBaseStats(identity.secondaryClassId, level); const combined = romDualClassBaseStats(identity.classId, identity.secondaryClassId, level)!; for (const stat of Object.keys(primary) as (keyof typeof primary)[]) { expect(combined[stat]).toBeCloseTo(primary[stat] + secondary[stat] * 0.1, 6); } } }); it("builds the requested named class pairs and races without talents", async () => { const builds = await loadCanonicalBenchmarkBuilds(60, CASES.map((entry) => entry.classId)); const selected = builds.filter((build) => CASES.some((entry) => ( entry.classId === build.classId && entry.role === build.role ))); expect(selected).toHaveLength(5); for (const build of selected) { expect(build.orientation).toBe("Primary"); const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === build.classId && entry.role === build.role)!; expect(build).toMatchObject({ characterName: identity.name, raceId: identity.raceId, secondaryClassId: identity.secondaryClassId, }); expect(isValidRomClassPair(identity.raceId, identity.classId, identity.secondaryClassId)).toBe(true); expect(build.talentRanks).toEqual({}); expect(readinessStatusFor(romGearProfile(build.classId, build.role).id)).toBe("needs-testing"); } }); it("uses the same identities for live NPCs and unlocks their secondary class at level 10", () => { const found = new Map(); for (let seed = 0; seed < 40; seed += 1) { for (const member of createSpectatorDungeonParty(`rom-pairs-${seed}`, 60, "rom")) { const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === member.classId && entry.role === member.role); if (!identity) continue; expect(member).toMatchObject({ name: identity.name, raceId: identity.raceId, secondaryClassId: identity.secondaryClassId, talentRanks: {}, }); found.set(identity.classId, identity); } } expect(found.size).toBe(5); expect(new Set(ROM_FIVE_IDENTITIES.map((entry) => entry.name)).size).toBe(5); usePartyStore.getState().initializeSpectatorParty("rom-unlock", 1, "rom"); expect(usePartyStore.getState().members.every((member) => member.secondaryClassId === null)).toBe(true); usePartyStore.getState().syncLevel(10); for (const member of usePartyStore.getState().members) { const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === member.classId && entry.role === member.role); if (identity) expect(member.secondaryClassId).toBe(identity.secondaryClassId); } const mage = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-mage")!; expect(createConfiguredPartyMember({ name: mage.name, classId: mage.classId, secondaryClassId: mage.secondaryClassId, raceId: mage.raceId, specialization: "Primary", role: mage.role, }, 60, 4, "rom-manual")).toMatchObject({ name: "Durnik", raceId: "rom-dwarf", secondaryClassId: "rom-warlock", }); }); it("adds general secondary skills to each pair's learned kit", () => { for (const identity of ROM_FIVE_IDENTITIES) { const abilities = abilitiesForCharacter(identity.classId, identity.secondaryClassId); expect(abilities.some((ability) => ability.classId === identity.secondaryClassId && ability.romSkillGroup === "general")).toBe(true); } }); it("uses ordinary learned abilities with no elite or talent-point gate", () => { for (const { classId, role } of CASES) { expect(partyRolesForClass(classId)).toContain(role); const abilities = abilitiesForCharacter(classId, null); expect(abilities.map((ability) => ability.id)) .toEqual(ROM_ORDINARY_ABILITIES_BY_CLASS[classId].map((ability) => ability.id)); expect(abilities.every((ability) => !ability.secondaryClassId && ability.romSkillGroup !== "elite" && !ability.talentNodeId)).toBe(true); for (const ability of abilities) { const learned = learnedAbilityForBuild(ability, 80, {}); expect(learned?.id, ability.id).toBe(ability.id); } } }); it("equips the role defining weapons in the manual cohort", () => { for (const { classId, role, layout } of CASES) { expect(romGearProfile(classId, role).weaponLayout).toBe(layout); const loadout = resolvedEquipmentLoadout({}, [], { ownerId: `rom-five:${classId}`, ownerKind: "party", classId, specializationId: null, role, level: 60, }); if (classId === "rom-knight") expect(loadout["off-hand"]?.armorType).toBe("shield"); if (classId === "rom-priest") { expect(loadout["main-hand"]?.weaponType).toBe("mace"); expect(loadout["off-hand"]?.armorType).toBe("shield"); } if (classId === "rom-scout") { expect(loadout["main-hand"]?.weaponType).toBe("dagger"); expect(loadout["main-hand"]?.inventorySlot).toBe("one-hand"); expect(loadout.ranged?.weaponType).toBe("bow"); } if (classId === "rom-rogue") { expect(loadout["main-hand"]?.weaponType).toBe("dagger"); expect(loadout["off-hand"]?.weaponType).toBe("dagger"); } if (classId === "rom-mage") { expect(loadout["main-hand"]?.weaponType).toBe("staff"); expect(loadout["main-hand"]?.inventorySlot).toBe("two-hand"); expect(loadout["off-hand"]).toBeUndefined(); } expect(loadout.wrist).toBeUndefined(); for (const item of Object.values(loadout)) expect(classCanEquipItem(classId, item!)).toBe(true); } }); it("grows the five hybrid combat loadouts through the heirloom levels", () => { for (const { classId, role } of CASES) { const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === classId && entry.role === role)!; let priorHealth = 0; let priorPower = 0; for (const level of [1, 20, 40, 60, 79]) { const loadout = resolvedEquipmentLoadout({}, [], { ownerId: `rom-hybrid:${classId}`, ownerKind: "player", classId, specializationId: null, role, level, }); const gear = equipmentStatsForItems(Object.values(loadout).filter((item) => item !== undefined)); const combat = deriveCharacterStats(classId, level, gear, identity.raceId, {}, level >= 10 ? identity.secondaryClassId : null); const power = classId === "rom-scout" ? combat.rangedAttackPower : classId === "rom-rogue" || classId === "rom-knight" ? combat.attackPower : combat.spellPower; expect(combat.health, `${classId} health at ${level}`).toBeGreaterThan(priorHealth); expect(power, `${classId} power at ${level}`).toBeGreaterThan(priorPower); expect(Object.values(loadout).every((item) => item?.itemLevel === level)).toBe(true); if (classId === "rom-knight" || classId === "rom-priest") expect(combat.blockValue).toBeGreaterThan(0); priorHealth = combat.health; priorPower = power; } } }); it("seats Knight only as a tank in RoM generated parties", () => { const parties = Array.from({ length: 40 }, (_, index) => createDungeonParty(`rom-knight-${index}`, 60, "healer", 5, "rom")); expect(parties.some((party) => party.some((member) => member.classId === "rom-knight"))).toBe(true); for (const party of parties) for (const member of party) { if (member.classId === "rom-knight") expect(member.role).toBe("tank"); } }); });