259 lines
13 KiB
TypeScript
259 lines
13 KiB
TypeScript
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, equipInventoryItem } 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<string, "human" | "night-elf"> = {
|
|
"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<string, (typeof ROM_FIVE_IDENTITIES)[number]>();
|
|
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,
|
|
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
|
|
specializationId: null,
|
|
role,
|
|
level: 60,
|
|
});
|
|
if (classId === "rom-knight") {
|
|
expect(loadout["off-hand"]?.armorType).toBe("shield");
|
|
expect(loadout.ranged?.weaponType).toBe("bow");
|
|
}
|
|
if (classId === "rom-priest") {
|
|
expect(loadout["main-hand"]?.weaponType).toBe("mace");
|
|
expect(loadout["off-hand"]?.armorType).toBe("shield");
|
|
expect(loadout.ranged).toBeUndefined();
|
|
}
|
|
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");
|
|
expect(loadout["off-hand"]?.weaponType).toBe("dagger");
|
|
}
|
|
if (classId === "rom-rogue") {
|
|
expect(loadout["main-hand"]?.weaponType).toBe("dagger");
|
|
expect(loadout["off-hand"]?.weaponType).toBe("dagger");
|
|
expect(loadout.ranged?.weaponType).toBe("projectile");
|
|
}
|
|
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.ranged).toBeUndefined();
|
|
}
|
|
expect(loadout.wrist).toBeUndefined();
|
|
for (const item of Object.values(loadout)) expect(classCanEquipItem(classId, item!, {
|
|
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
|
|
level: 60,
|
|
})).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("opens the independent ranged and off-hand positions at the source training levels", () => {
|
|
const loadout = (classId: "rom-knight" | "rom-scout" | "rom-rogue", secondaryClassId: "rom-scout" | "rom-rogue" | "rom-warrior", level: number) =>
|
|
resolvedEquipmentLoadout({}, [], {
|
|
ownerId: `rom-slots:${classId}`,
|
|
ownerKind: "player",
|
|
classId,
|
|
secondaryClassId: level >= 10 ? secondaryClassId : null,
|
|
specializationId: null,
|
|
role: classId === "rom-knight" ? "tank" : "damage",
|
|
level,
|
|
});
|
|
expect(loadout("rom-knight", "rom-scout", 9).ranged).toBeUndefined();
|
|
expect(loadout("rom-knight", "rom-scout", 10).ranged?.weaponType).toBe("bow");
|
|
expect(loadout("rom-scout", "rom-rogue", 15)["off-hand"]).toBeUndefined();
|
|
expect(loadout("rom-scout", "rom-rogue", 16)["off-hand"]?.weaponType).toBe("dagger");
|
|
expect(loadout("rom-rogue", "rom-warrior", 11).ranged).toBeUndefined();
|
|
expect(loadout("rom-rogue", "rom-warrior", 12).ranged?.weaponType).toBe("projectile");
|
|
expect(loadout("rom-rogue", "rom-warrior", 15)["off-hand"]).toBeUndefined();
|
|
expect(loadout("rom-rogue", "rom-warrior", 16)["off-hand"]?.weaponType).toBe("dagger");
|
|
const knightBow = loadout("rom-knight", "rom-scout", 60).ranged!;
|
|
expect(classCanEquipItem("rom-knight", knightBow, { level: 60 })).toBe(false);
|
|
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
|
|
{ secondaryClassId: "rom-scout", level: 60 }).ok).toBe(true);
|
|
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
|
|
{ secondaryClassId: "rom-priest", level: 60 }).ok).toBe(false);
|
|
});
|
|
|
|
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,
|
|
secondaryClassId: level >= 10 ? identity.secondaryClassId : null,
|
|
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");
|
|
}
|
|
});
|
|
});
|