forked from phenom/healer-man
184 lines
7.2 KiB
TypeScript
184 lines
7.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ClassId, RomClassId } from "../app/characterCatalog";
|
|
import type { AbilityDefinition } from "./abilityCatalog";
|
|
import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog";
|
|
import { aiHintsForAbility } from "./abilityRules";
|
|
import { evaluateCombatRotation, type RotationContext } from "./combatRotation";
|
|
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "./romFiveRotation";
|
|
import type { PartyRole } from "./partyRoles";
|
|
|
|
const PRIMARY_RESOURCE: Readonly<Record<RomClassId, "mana" | "rage" | "energy" | "focus">> = {
|
|
"rom-warrior": "rage",
|
|
"rom-scout": "focus",
|
|
"rom-rogue": "energy",
|
|
"rom-mage": "mana",
|
|
"rom-priest": "mana",
|
|
"rom-knight": "mana",
|
|
"rom-warden": "mana",
|
|
"rom-druid": "mana",
|
|
"rom-warlock": "focus",
|
|
"rom-champion": "rage",
|
|
};
|
|
|
|
function nativeContext(
|
|
classId: RomClassId,
|
|
secondaryClassId: RomClassId,
|
|
role: PartyRole,
|
|
overrides: Partial<RotationContext> = {},
|
|
): RotationContext {
|
|
const abilities = abilitiesForCharacter(classId, secondaryClassId)
|
|
.map((ability) => learnedAbilityForBuild(ability, 30, {}))
|
|
.filter((ability): ability is AbilityDefinition => Boolean(ability && !ability.passive));
|
|
const knownDamagingAbilityNames = new Set(abilities.filter((ability) => {
|
|
const categories = aiHintsForAbility(ability).categories;
|
|
return categories.includes("damage") || categories.includes("execute");
|
|
}).map((ability) => ability.name));
|
|
return {
|
|
now: 1_000,
|
|
classId,
|
|
secondaryClassId,
|
|
statRuleset: "rom",
|
|
role,
|
|
level: 30,
|
|
health: 100,
|
|
maxHealth: 100,
|
|
selfId: `${classId}-player`,
|
|
resources: { mana: 100, rage: 100, energy: 100, focus: 100 },
|
|
maximumResources: { mana: 100, rage: 100, energy: 100, focus: 100 },
|
|
primaryResource: PRIMARY_RESOURCE[classId],
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
runes: [],
|
|
abilities,
|
|
knownDamagingAbilityNames,
|
|
hostileTarget: { id: "target", health: 20_000, maxHealth: 20_000, boss: true },
|
|
hostileCount: 1,
|
|
friendlyTargets: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function wowWarriorContext(): RotationContext {
|
|
const abilities = abilitiesForCharacter("warrior", null)
|
|
.map((ability) => learnedAbilityForBuild(ability, 80, {}))
|
|
.filter((ability): ability is AbilityDefinition => Boolean(ability && !ability.passive));
|
|
return {
|
|
now: 1_000,
|
|
classId: "warrior",
|
|
statRuleset: "wow",
|
|
specializationId: "warrior:protection",
|
|
role: "tank",
|
|
level: 80,
|
|
health: 100_000,
|
|
maxHealth: 100_000,
|
|
selfId: "wow-warrior",
|
|
resources: { rage: 100 },
|
|
maximumResources: { rage: 100 },
|
|
primaryResource: "rage",
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
runes: [],
|
|
abilities,
|
|
knownDamagingAbilityNames: new Set(abilities.filter((ability) => {
|
|
const categories = aiHintsForAbility(ability).categories;
|
|
return categories.includes("damage") || categories.includes("execute");
|
|
}).map((ability) => ability.name)),
|
|
hostileTarget: { id: "wow-target", health: 500_000, maxHealth: 500_000, boss: true },
|
|
hostileCount: 1,
|
|
};
|
|
}
|
|
|
|
describe("native RoM five profiles on the shared rotation evaluator", () => {
|
|
it.each([
|
|
["rom-knight", "rom-scout", "tank"],
|
|
["rom-priest", "rom-knight", "healer"],
|
|
["rom-rogue", "rom-warrior", "damage"],
|
|
["rom-scout", "rom-rogue", "damage"],
|
|
["rom-mage", "rom-warlock", "damage"],
|
|
] as const)("selects a learned native action for %s/%s as %s at level 30", (primary, secondary, role) => {
|
|
const context = nativeContext(primary, secondary, role, {
|
|
...(role === "tank" ? { targetIsLoose: true } : {}),
|
|
...(role === "healer" ? {
|
|
friendlyTargets: [
|
|
{ id: "tank", health: 55, maxHealth: 100, role: "tank" },
|
|
{ id: "scout", health: 75, maxHealth: 100, role: "damage" },
|
|
],
|
|
} : {}),
|
|
});
|
|
const profile = nativeRotationProfileFor(context);
|
|
const decision = evaluateCombatRotation(context);
|
|
|
|
expect(profile).not.toBeNull();
|
|
expect(decision).not.toBeNull();
|
|
expect(decision?.profileId).toBe(profile?.id);
|
|
expect(context.abilities.some((ability) => ability.name === decision?.ability.name)).toBe(true);
|
|
});
|
|
|
|
it("uses Group Heal on multiple injured allies when the priest is healthy", () => {
|
|
const context = nativeContext("rom-priest", "rom-knight", "healer", {
|
|
friendlyTargets: [
|
|
{ id: "tank", health: 65, maxHealth: 100, role: "tank" },
|
|
{ id: "scout", health: 75, maxHealth: 100, role: "damage" },
|
|
],
|
|
});
|
|
const groupHeal = context.abilities.find((ability) => ability.name === "Group Heal");
|
|
expect(groupHeal).toBeDefined();
|
|
expect(nativeRotationAbilityAllowed(groupHeal!, context)).toBe(true);
|
|
const decision = evaluateCombatRotation({ ...context, abilities: [groupHeal!] });
|
|
expect(decision?.ability.name).toBe("Group Heal");
|
|
expect(decision?.targetId).toBe(context.selfId);
|
|
});
|
|
|
|
it("keeps Urgent Heal ahead of Group Heal when one ally is critical", () => {
|
|
const context = nativeContext("rom-priest", "rom-knight", "healer", {
|
|
friendlyTargets: [
|
|
{ id: "tank", health: 20, maxHealth: 100, role: "tank" },
|
|
{ id: "scout", health: 70, maxHealth: 100, role: "damage" },
|
|
],
|
|
});
|
|
const urgentHeal = context.abilities.find((ability) => ability.name === "Urgent Heal");
|
|
const groupHeal = context.abilities.find((ability) => ability.name === "Group Heal");
|
|
expect(urgentHeal).toBeDefined();
|
|
expect(groupHeal).toBeDefined();
|
|
const decision = evaluateCombatRotation({ ...context, abilities: [groupHeal!, urgentHeal!] });
|
|
expect(decision?.ability.name).toBe("Urgent Heal");
|
|
expect(decision?.targetId).toBe("tank");
|
|
});
|
|
|
|
it("refreshes the Scout/Rogue DoT only for the current caster, target, and expiry window", () => {
|
|
const context = nativeContext("rom-scout", "rom-rogue", "damage");
|
|
const vampireArrows = context.abilities.find((ability) => ability.name === "Vampire Arrows");
|
|
expect(vampireArrows).toBeDefined();
|
|
const maintained = (sourceActorId: string, targetId: string, remainingMs: number) => [{
|
|
abilityId: vampireArrows!.id,
|
|
sourceActorId,
|
|
targetId,
|
|
remainingMs,
|
|
}];
|
|
expect(nativeRotationAbilityAllowed(vampireArrows!, {
|
|
...context,
|
|
maintainedPeriodicEffects: maintained(context.selfId!, "target", 10_000),
|
|
})).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(vampireArrows!, {
|
|
...context,
|
|
maintainedPeriodicEffects: maintained("other-scout", "target", 10_000),
|
|
})).toBe(true);
|
|
expect(nativeRotationAbilityAllowed(vampireArrows!, {
|
|
...context,
|
|
maintainedPeriodicEffects: maintained(context.selfId!, "other-target", 10_000),
|
|
})).toBe(true);
|
|
expect(nativeRotationAbilityAllowed(vampireArrows!, {
|
|
...context,
|
|
maintainedPeriodicEffects: maintained(context.selfId!, "target", 1_000),
|
|
})).toBe(true);
|
|
});
|
|
|
|
it("leaves the WoW class profile selector on its original path", () => {
|
|
const context = wowWarriorContext();
|
|
expect(nativeRotationProfileFor(context)).toBeNull();
|
|
const decision = evaluateCombatRotation(context);
|
|
expect(decision).not.toBeNull();
|
|
expect(decision?.profileId).toBe("classic:warrior:protection");
|
|
});
|
|
});
|