264 lines
9.7 KiB
TypeScript
264 lines
9.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { AbilityDefinition } from "./abilityCatalog";
|
|
import type { RotationContext } from "./combatRotation";
|
|
import {
|
|
NATIVE_ROTATION_PROFILES,
|
|
ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS,
|
|
nativeRotationAbilityAllowed,
|
|
nativeRotationProfileFor,
|
|
} from "./romFiveRotation";
|
|
|
|
function context(overrides: Partial<RotationContext> = {}): RotationContext {
|
|
return {
|
|
now: 1_000,
|
|
classId: "rom-priest",
|
|
secondaryClassId: "rom-knight",
|
|
statRuleset: "rom",
|
|
role: "healer",
|
|
level: 30,
|
|
health: 100,
|
|
maxHealth: 100,
|
|
selfId: "serenna",
|
|
resources: { mana: 100 },
|
|
maximumResources: { mana: 100 },
|
|
primaryResource: "mana",
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
runes: [],
|
|
abilities: [],
|
|
hostileTarget: { id: "target", health: 500, maxHealth: 500 },
|
|
friendlyTargets: [],
|
|
hostileCount: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function ability(
|
|
name: string,
|
|
overrides: Partial<AbilityDefinition> = {},
|
|
): AbilityDefinition {
|
|
return {
|
|
id: `test-${name}`,
|
|
classId: "rom-priest",
|
|
dbcSpellId: 1,
|
|
name,
|
|
unlockLevel: 1,
|
|
icon: "",
|
|
sigil: "",
|
|
description: "test fixture",
|
|
target: "friendly",
|
|
range: { min: 0, max: 30 },
|
|
castMode: "instant",
|
|
castTimeMs: 0,
|
|
cooldownMs: 0,
|
|
gcdMs: 0,
|
|
cost: { resource: "mana", amount: 0 },
|
|
effects: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("native RoM five rotation profiles", () => {
|
|
it("only resolves exact pair, role, RoM ruleset, and secondary-class levels", () => {
|
|
expect(nativeRotationProfileFor(context())).toMatchObject({
|
|
id: "rom-five:rom-priest+rom-knight:healer",
|
|
classId: "rom-priest",
|
|
secondaryClassId: "rom-knight",
|
|
role: "healer",
|
|
});
|
|
expect(nativeRotationProfileFor(context({ secondaryClassId: "rom-scout" }))).toBeNull();
|
|
expect(nativeRotationProfileFor(context({ classId: "rom-knight", secondaryClassId: "rom-scout" }))).toBeNull();
|
|
expect(nativeRotationProfileFor(context({ role: "damage" }))).toBeNull();
|
|
expect(nativeRotationProfileFor(context({ statRuleset: "wow" }))).toBeNull();
|
|
expect(nativeRotationProfileFor(context({ secondaryClassId: null }))).toBeNull();
|
|
expect(nativeRotationProfileFor(context({ level: 9 }))).toBeNull();
|
|
});
|
|
|
|
it("keeps the five ordered pairs and a damaging fallback tail for the same build", () => {
|
|
expect(NATIVE_ROTATION_PROFILES.map(({ classId, secondaryClassId, role }) => [
|
|
classId,
|
|
secondaryClassId,
|
|
role,
|
|
])).toEqual([
|
|
["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"],
|
|
]);
|
|
|
|
const profile = nativeRotationProfileFor(context({
|
|
knownDamagingAbilityNames: new Set(["Current CoA Ability", "Urgent Heal"]),
|
|
}));
|
|
expect(profile?.abilityPriority.slice(0, 2)).toEqual(["Urgent Heal", "Group Heal"]);
|
|
expect(profile?.abilityPriority.at(-1)).toBe("Current CoA Ability");
|
|
});
|
|
});
|
|
|
|
describe("native RoM rotation action guards", () => {
|
|
it("uses taunts only for a tank's actually loose target and gates shield spend defensively", () => {
|
|
const arrow = ability("Arrow of Vengeance", { classId: "rom-knight" });
|
|
expect(nativeRotationAbilityAllowed(arrow, context({
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-scout",
|
|
role: "tank",
|
|
}))).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(arrow, context({
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-scout",
|
|
role: "tank",
|
|
targetIsLoose: true,
|
|
}))).toBe(true);
|
|
|
|
const shieldBash = ability("Truth Shield Bash", { classId: "rom-knight" });
|
|
expect(nativeRotationAbilityAllowed(shieldBash, context({
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-scout",
|
|
role: "tank",
|
|
}))).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(shieldBash, context({
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-scout",
|
|
role: "tank",
|
|
incomingMeleeDanger: true,
|
|
}))).toBe(true);
|
|
});
|
|
|
|
it("keeps emergency single heals fast and requires multiple injured allies for Group Heal", () => {
|
|
const urgent = ability("Urgent Heal");
|
|
const group = ability("Group Heal");
|
|
const oneInjured = [{ id: "tank", health: 30, maxHealth: 100, role: "tank" as const }];
|
|
expect(nativeRotationAbilityAllowed(urgent, context({ friendlyTargets: oneInjured }))).toBe(true);
|
|
expect(nativeRotationAbilityAllowed(urgent, context({
|
|
friendlyTargets: [{ id: "tank", health: 70, maxHealth: 100, role: "tank" }],
|
|
}))).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(group, context({ friendlyTargets: oneInjured }))).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(group, context({
|
|
friendlyTargets: [
|
|
{ id: "tank", health: 70, maxHealth: 100, role: "tank" },
|
|
{ id: "scout", health: 80, maxHealth: 100, role: "damage" },
|
|
],
|
|
}))).toBe(true);
|
|
});
|
|
|
|
it("refreshes a target-owned periodic effect only inside its expiry window", () => {
|
|
const dot = ability("Soul Pain", {
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-mage",
|
|
target: "hostile",
|
|
effects: [{ kind: "dot", coefficient: 1, ticks: 12, intervalMs: 2_000, tag: "soul-pain" }],
|
|
});
|
|
const effect = (remainingMs: number, sourceActorId = "serenna", targetId = "target") => ({
|
|
abilityId: dot.id,
|
|
sourceActorId,
|
|
targetId,
|
|
remainingMs,
|
|
});
|
|
|
|
expect(nativeRotationAbilityAllowed(dot, context({
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warlock",
|
|
role: "damage",
|
|
selfId: "durnik",
|
|
maintainedPeriodicEffects: [effect(ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS + 1, "durnik")],
|
|
}))).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(dot, context({
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warlock",
|
|
role: "damage",
|
|
selfId: "durnik",
|
|
maintainedPeriodicEffects: [effect(ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS, "durnik")],
|
|
}))).toBe(true);
|
|
expect(nativeRotationAbilityAllowed(dot, context({
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warlock",
|
|
role: "damage",
|
|
selfId: "durnik",
|
|
maintainedAbilityIds: new Set([dot.id]),
|
|
maintainedPeriodicEffects: [effect(10_000, "another-caster")],
|
|
}))).toBe(true);
|
|
expect(nativeRotationAbilityAllowed(dot, context({
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warlock",
|
|
role: "damage",
|
|
selfId: "durnik",
|
|
maintainedPeriodicEffects: [effect(10_000, "durnik", "another-target")],
|
|
}))).toBe(true);
|
|
expect(nativeRotationAbilityAllowed(dot, context({
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warlock",
|
|
role: "damage",
|
|
selfId: "durnik",
|
|
hostileTarget: { id: "small-target", health: 100, maxHealth: 100 },
|
|
}))).toBe(false);
|
|
});
|
|
|
|
it("does not spend old health-cost Sacrificial Cure data and only bursts on durable targets", () => {
|
|
const staleCure = ability("Sacrificial Cure", {
|
|
cost: { resource: "health", amount: 70, percentage: true },
|
|
});
|
|
expect(nativeRotationAbilityAllowed(staleCure, context({
|
|
friendlyTargets: [{ id: "tank", health: 10, maxHealth: 100 }],
|
|
}))).toBe(false);
|
|
|
|
const burst = ability("Assassins Rage", { classId: "rom-rogue" });
|
|
expect(nativeRotationAbilityAllowed(burst, context({
|
|
classId: "rom-rogue",
|
|
secondaryClassId: "rom-warrior",
|
|
role: "damage",
|
|
hostileTarget: { id: "small-mob", health: 150, maxHealth: 150 },
|
|
}))).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(burst, context({
|
|
classId: "rom-rogue",
|
|
secondaryClassId: "rom-warrior",
|
|
role: "damage",
|
|
hostileTarget: { id: "boss", health: 10_000, maxHealth: 10_000, boss: true },
|
|
}))).toBe(true);
|
|
});
|
|
|
|
it("waits for Vampire Arrows before Deadly Poison Bite and applies AoE minimums", () => {
|
|
const bite = ability("Deadly Poison Bite", { classId: "rom-scout" });
|
|
const scout = {
|
|
classId: "rom-scout" as const,
|
|
secondaryClassId: "rom-rogue" as const,
|
|
role: "damage" as const,
|
|
selfId: "caelan",
|
|
};
|
|
expect(nativeRotationAbilityAllowed(bite, context(scout))).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(bite, context({
|
|
...scout,
|
|
maintainedPeriodicEffects: [{
|
|
abilityId: "rom-scout-general-2",
|
|
sourceActorId: "caelan",
|
|
targetId: "target",
|
|
remainingMs: 5_000,
|
|
}],
|
|
}))).toBe(true);
|
|
|
|
const thunderstorm = ability("Thunderstorm", { classId: "rom-mage" });
|
|
const mage = { classId: "rom-mage" as const, secondaryClassId: "rom-warlock" as const, role: "damage" as const };
|
|
expect(nativeRotationAbilityAllowed(thunderstorm, context(mage))).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(thunderstorm, context({ ...mage, hostileCount: 2 }))).toBe(true);
|
|
|
|
const areaAttack = ability("Current CoA area attack", {
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warlock",
|
|
target: "hostile",
|
|
effects: [{ kind: "damage", coefficient: 1 }],
|
|
targeting: { shape: "circle", radius: 8 },
|
|
});
|
|
expect(nativeRotationAbilityAllowed(areaAttack, context(mage))).toBe(false);
|
|
expect(nativeRotationAbilityAllowed(areaAttack, context({ ...mage, hostileCount: 2 }))).toBe(true);
|
|
});
|
|
|
|
it("leaves unsupported and pre-secondary builds to the existing generic fallback", () => {
|
|
const abilityUnderTest = ability("Holy Strike", { classId: "rom-knight" });
|
|
expect(nativeRotationAbilityAllowed(abilityUnderTest, context({ level: 9 }))).toBe(true);
|
|
expect(nativeRotationAbilityAllowed(abilityUnderTest, context({
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-priest",
|
|
role: "tank",
|
|
}))).toBe(true);
|
|
});
|
|
});
|