Files
healer-man/src/game/romFiveValidation.test.ts
T

220 lines
11 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 } 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,
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");
}
});
});