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

907 lines
43 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { BASE_CLASS_IDS } from "../app/characterCatalog";
import {
abilitiesForCharacter,
abilitiesForClass,
learnedAbilityForBuild,
resourceProfileForClass,
type AbilityDefinition,
type ResourceType,
} from "./abilityCatalog";
import {
abilityIsPrecombatPreparation,
abilityHasRotationSemantics,
abilityAuraMaintainedOnTarget,
CLASSIC_ROTATION_PROFILES,
COMBAT_ROTATION_VERSION,
evaluateCombatRotation,
rotationProfileFor,
type RotationContext,
} from "./combatRotation";
import { createDeathKnightRunes } from "./combatActors";
import { aiHintsForAbility } from "./abilityRules";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { partyTalentPrimaryTreeId } from "./partyTalents";
import { specializationsForClass } from "./specializationCatalog";
import { talentNodeById } from "./talentCatalog";
const SIGNATURES = Object.freeze({
warrior: "Mortal Strike",
paladin: "Divine Storm",
hunter: "Explosive Shot",
rogue: "Killing Spree",
priest: "Penance",
"death-knight": "Frost Strike",
shaman: "Feral Spirit",
mage: "Arcane Barrage",
warlock: "Haunt",
druid: "Starfall",
} as const);
function context(overrides: Partial<RotationContext> = {}): RotationContext {
const classId = overrides.classId ?? "rogue";
const primaryResource = resourceProfileForClass(classId).type;
return {
now: 1_000,
classId,
specializationId: classId === "rogue" ? "rogue:combat" : null,
role: "damage",
level: 80,
health: 1_000,
maxHealth: 1_000,
resources: { [primaryResource]: 100 },
maximumResources: { [primaryResource]: 100 },
primaryResource,
comboPoints: 0,
comboTargetId: null,
runes: classId === "death-knight" ? createDeathKnightRunes() : [],
abilities: [],
hostileTarget: { id: "target", health: 1_000, maxHealth: 1_000 },
hostileCount: 1,
...overrides,
};
}
function ability(classId: RotationContext["classId"], name: string): AbilityDefinition {
const result = abilitiesForClass(classId).find((candidate) => candidate.name === name);
expect(result, `${classId}:${name}`).toBeDefined();
return result!;
}
function semanticAbility(
id: string,
effects: AbilityDefinition["effects"],
options: Partial<AbilityDefinition> = {},
): AbilityDefinition {
return {
id,
classId: "rom-warrior",
dbcSpellId: 0,
name: id,
unlockLevel: 1,
icon: "",
sigil: "AI",
description: id,
target: "hostile",
range: { min: 0, max: 30 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 1_500,
cost: { resource: "rage", amount: 0 },
effects,
...options,
};
}
describe("shared combat rotations", () => {
it("registers exactly one resolvable profile for every classic specialization", () => {
const classicSpecs = BASE_CLASS_IDS.flatMap((classId) => specializationsForClass(classId));
expect(classicSpecs).toHaveLength(31);
expect(CLASSIC_ROTATION_PROFILES).toHaveLength(31);
expect(new Set(CLASSIC_ROTATION_PROFILES.map((entry) => entry.specializationId)).size).toBe(31);
for (const specialization of classicSpecs) {
const rotation = rotationProfileFor(specialization.classId, specialization.id);
expect(rotation?.specializationId, specialization.id).toBe(specialization.id);
for (const name of rotation?.abilityPriority ?? []) {
expect(abilitiesForClass(specialization.classId).some((candidate) => candidate.name === name),
`${specialization.id}:${name}`).toBe(true);
}
}
expect(COMBAT_ROTATION_VERSION).toMatch(/^playerbots-inspired-/);
});
it("prepares only long-lived maintenance effects and permanent pets before combat", () => {
const longBuff = semanticAbility("long-buff", [{
kind: "apply-aura",
aura: { id: "long-buff", name: "Long buff", disposition: "buff", durationMs: 60_000,
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }] },
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 22,
miscValue: 0,
triggerSpellId: 0,
}], { target: "self" });
const shortBuff = semanticAbility("short-buff", [{
kind: "apply-aura",
aura: { id: "short-buff", name: "Short buff", disposition: "buff", durationMs: 30_000,
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }] },
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 22,
miscValue: 0,
triggerSpellId: 0,
}], { target: "self" });
const pet = semanticAbility("call-pet", [{ kind: "pet", action: "call", creatureId: 1 }], { target: "self" });
const temporaryPet = semanticAbility("temporary-pet", [{ kind: "pet", action: "summon", creatureId: 2 }], {
target: "self",
cooldownMs: 120_000,
});
expect(abilityIsPrecombatPreparation(longBuff)).toBe(true);
expect(abilityIsPrecombatPreparation(shortBuff)).toBe(false);
expect(abilityIsPrecombatPreparation(pet)).toBe(true);
expect(abilityIsPrecombatPreparation(temporaryPet)).toBe(false);
});
it("uses an eligible defensive before a configured incoming boss mechanic", () => {
const defensive = ability("paladin", "Divine Protection");
const decision = evaluateCombatRotation(context({
classId: "paladin",
specializationId: "paladin:protection",
role: "tank",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [defensive],
incomingDefensiveMechanic: {
id: "tank-buster",
enabled: true,
bossKey: "test/boss",
bossName: "Test Boss",
bossServerEntry: 1,
attackId: "heavy-strike",
attackName: "Heavy Strike",
scope: "tank",
leadTimeMs: 2_500,
minimumCooldownSeconds: 20,
},
}));
expect(decision?.ability.name).toBe("Divine Protection");
expect(decision?.ruleId).toBe("preemptive-tank-buster");
});
it("gates representative signature abilities from every class by their exact talent node", () => {
for (const [classId, name] of Object.entries(SIGNATURES)) {
const signature = ability(classId as RotationContext["classId"], name);
expect(signature.talentNodeId, `${classId}:${name}`).toMatch(/^talent-\d+$/);
expect(learnedAbilityForBuild(signature, 80, {}), `${classId}:${name}`).toBeNull();
expect(learnedAbilityForBuild(signature, 80, { [signature.talentNodeId!]: 1 }), `${classId}:${name}`).not.toBeNull();
expect(signature.effects.length).toBeGreaterThan(0);
if (classId === "rogue" && name === "Killing Spree") {
// Its DBC wrapper is a periodic-dummy target selector; the dedicated
// Rogue runtime test covers the script's actual two-weapon assaults.
expect(signature.effects).toEqual([
expect.objectContaining({ kind: "utility", action: "class-script", durationMs: 2_000 }),
]);
} else {
expect(signature.effects.some((effect) => !["unsupported", "scripted", "utility"].includes(effect.kind)))
.toBe(true);
}
expect(signature.ranks?.length, `${classId}:${name} ranks`).toBeGreaterThan(0);
expect(signature.ranks?.every((rank) => rank.cost.amount >= 0 && rank.cooldownMs >= 0 && rank.effects.length > 0)).toBe(true);
expect(signature.cooldownMs).toBeGreaterThanOrEqual(0);
expect(signature.cost.amount).toBeGreaterThanOrEqual(0);
}
});
it("learns every primary-tree action named by its level-80 rotation", () => {
for (const classId of BASE_CLASS_IDS) {
for (const specialization of specializationsForClass(classId)) {
const profile = rotationProfileFor(classId, specialization.id)!;
const primaryTreeId = partyTalentPrimaryTreeId(classId, specialization.displayName);
const ranks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, 80);
const primaryActions = profile.abilityPriority
.map((name) => ability(classId, name))
.filter((candidate) => candidate.talentNodeId
&& talentNodeById(candidate.talentNodeId)?.treeId === primaryTreeId);
for (const candidate of primaryActions) {
expect(
learnedAbilityForBuild(candidate, 80, ranks),
`${specialization.id}:${candidate.name}`,
).not.toBeNull();
}
}
}
});
it("keeps every required Ready-spec mechanic in its learned Playerbots profile", () => {
const contracts = [
{ classId: "warrior" as const, specialization: "Protection", id: "warrior:protection", actions: ["Defensive Stance", "Taunt", "Shield Bash", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Shield Block", "Shield Wall", "Last Stand", "Enraged Regeneration"] },
{ classId: "priest" as const, specialization: "Discipline", id: "priest:discipline", actions: ["Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew", "Dispel Magic", "Cure Disease", "Resurrection", "Pain Suppression", "Inner Focus", "Power Infusion", "Shadowfiend"] },
{ classId: "hunter" as const, specialization: "Marksmanship", id: "hunter:marksmanship", actions: ["Call Pet", "Aspect of the Dragonhawk", "Serpent Sting", "Chimera Shot", "Aimed Shot", "Arcane Shot", "Steady Shot", "Multi-Shot", "Volley", "Kill Shot", "Rapid Fire", "Readiness", "Misdirection", "Feign Death", "Silencing Shot", "Disengage"] },
{ classId: "mage" as const, specialization: "Fire", id: "mage:fire", actions: ["Molten Armor", "Living Bomb", "Fireball", "Scorch", "Fire Blast", "Flamestrike", "Combustion", "Mirror Image", "Counterspell", "Fire Ward", "Frost Ward", "Ice Block", "Blink"] },
{ classId: "rogue" as const, specialization: "Assassination", id: "rogue:assassination", actions: ["Instant Poison", "Deadly Poison", "Mutilate", "Fan of Knives", "Shiv", "Rupture", "Envenom", "Cold Blood", "Kick", "Tricks of the Trade", "Evasion", "Cloak of Shadows", "Vanish"] },
];
for (const contract of contracts) {
const profile = rotationProfileFor(contract.classId, contract.id)!;
const talents = runtimePartyTalentRanksForLevel(contract.classId, contract.specialization, 80);
for (const name of contract.actions) {
expect(profile.abilityPriority, `${contract.id}:${name} priority`).toContain(name);
expect(learnedAbilityForBuild(ability(contract.classId, name), 80, talents), `${contract.id}:${name} learned`)
.not.toBeNull();
}
}
});
it("executes Ready-spec taunts, dispels, resurrection, interrupts, threat redirects, and movement reactively", () => {
const warriorTalents = runtimePartyTalentRanksForLevel("warrior", "Protection", 80);
const taunt = learnedAbilityForBuild(ability("warrior", "Taunt"), 80, warriorTalents)!;
expect(evaluateCombatRotation(context({ classId: "warrior", specializationId: "warrior:protection", role: "tank", primaryResource: "rage", resources: { rage: 100 }, maximumResources: { rage: 100 }, abilities: [taunt], targetIsLoose: true }))?.ability.name)
.toBe("Taunt");
const priestTalents = runtimePartyTalentRanksForLevel("priest", "Discipline", 80);
const dispel = learnedAbilityForBuild(ability("priest", "Dispel Magic"), 80, priestTalents)!;
const resurrection = learnedAbilityForBuild(ability("priest", "Resurrection"), 80, priestTalents)!;
const priest = context({ classId: "priest", specializationId: "priest:discipline", role: "healer", primaryResource: "mana", resources: { mana: 10_000 }, maximumResources: { mana: 10_000 } });
expect(evaluateCombatRotation({ ...priest, abilities: [dispel], friendlyTargets: [{ id: "ally", health: 500, maxHealth: 1_000, needsDispel: true }] })?.ability.name)
.toBe("Dispel Magic");
expect(evaluateCombatRotation({ ...priest, abilities: [resurrection], friendlyTargets: [{ id: "ally", health: 0, maxHealth: 1_000, fallen: true }] })?.ability.name)
.toBe("Resurrection");
for (const row of [
{ classId: "hunter" as const, specializationId: "hunter:marksmanship", specialization: "Marksmanship", name: "Silencing Shot", resource: "mana" as const },
{ classId: "mage" as const, specializationId: "mage:fire", specialization: "Fire", name: "Counterspell", resource: "mana" as const },
{ classId: "rogue" as const, specializationId: "rogue:assassination", specialization: "Assassination", name: "Kick", resource: "energy" as const },
]) {
const learned = learnedAbilityForBuild(ability(row.classId, row.name), 80,
runtimePartyTalentRanksForLevel(row.classId, row.specialization, 80))!;
expect(evaluateCombatRotation(context({
classId: row.classId,
specializationId: row.specializationId,
primaryResource: row.resource,
resources: { [row.resource]: 10_000 },
maximumResources: { [row.resource]: 10_000 },
abilities: [learned],
hostileTarget: { id: "caster", health: 10_000, maxHealth: 10_000, activeCast: { interruptible: true } },
}))?.ability.name).toBe(row.name);
}
const hunterTalents = runtimePartyTalentRanksForLevel("hunter", "Marksmanship", 80);
const misdirection = learnedAbilityForBuild(ability("hunter", "Misdirection"), 80, hunterTalents)!;
const disengage = learnedAbilityForBuild(ability("hunter", "Disengage"), 80, hunterTalents)!;
const hunter = context({ classId: "hunter", specializationId: "hunter:marksmanship", primaryResource: "mana", resources: { mana: 10_000 }, maximumResources: { mana: 10_000 }, friendlyTargets: [{ id: "tank", health: 1_000, maxHealth: 1_000, role: "tank" }] });
expect(evaluateCombatRotation({ ...hunter, abilities: [misdirection], hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true } }))
.toMatchObject({ ability: { name: "Misdirection" }, targetId: "tank" });
expect(evaluateCombatRotation({ ...hunter, abilities: [disengage], needsEscape: true })?.ability.name).toBe("Disengage");
});
it("keeps specialization talent actions isolated", () => {
const furyTalents = runtimePartyTalentRanksForLevel("warrior", "Fury", 80);
const armsTalents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
const bloodthirst = ability("warrior", "Bloodthirst");
const mortalStrike = ability("warrior", "Mortal Strike");
expect(learnedAbilityForBuild(bloodthirst, 80, furyTalents)).not.toBeNull();
expect(learnedAbilityForBuild(mortalStrike, 80, furyTalents)).toBeNull();
expect(learnedAbilityForBuild(mortalStrike, 80, armsTalents)).not.toBeNull();
expect(learnedAbilityForBuild(bloodthirst, 80, armsTalents)).toBeNull();
});
it("certifies major offensive and defensive cooldown decisions for the Ready five", () => {
const cases = [
{ classId: "warrior" as const, specialization: "Protection", specializationId: "warrior:protection", offensive: null, defensive: "Shield Wall", role: "tank" as const },
{ classId: "priest" as const, specialization: "Discipline", specializationId: "priest:discipline", offensive: "Power Infusion", defensive: "Pain Suppression", role: "healer" as const },
{ classId: "hunter" as const, specialization: "Marksmanship", specializationId: "hunter:marksmanship", offensive: "Rapid Fire", defensive: "Feign Death", role: "damage" as const },
{ classId: "mage" as const, specialization: "Fire", specializationId: "mage:fire", offensive: "Combustion", defensive: "Ice Block", role: "damage" as const },
{ classId: "rogue" as const, specialization: "Assassination", specializationId: "rogue:assassination", offensive: "Cold Blood", defensive: "Evasion", role: "damage" as const },
];
for (const row of cases) {
const talents = runtimePartyTalentRanksForLevel(row.classId, row.specialization, 80);
const learned = (name: string) => learnedAbilityForBuild(ability(row.classId, name), 80, talents)!;
const primaryResource = resourceProfileForClass(row.classId).type;
const shared = context({
classId: row.classId,
specializationId: row.specializationId,
specialization: row.specialization,
role: row.role,
primaryResource,
resources: { [primaryResource]: 100_000 },
maximumResources: { [primaryResource]: 100_000 },
hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true },
friendlyTargets: [{ id: "tank", health: 1_000, maxHealth: 1_000, role: "tank" }],
});
if (row.offensive) {
const offensive = learned(row.offensive);
expect(offensive, `${row.specializationId}:${row.offensive}`).toBeDefined();
expect(evaluateCombatRotation({ ...shared, abilities: [offensive] })?.ability.name)
.toBe(row.offensive);
}
const defensive = learned(row.defensive);
expect(defensive, `${row.specializationId}:${row.defensive}`).toBeDefined();
const defensiveContext = row.defensive === "Feign Death"
? { ...shared, abilities: [defensive], hasDangerousThreat: true }
: row.defensive === "Evasion"
? { ...shared, abilities: [defensive], health: 400, enemyUsesMeleeAttacks: true }
: { ...shared, abilities: [defensive], health: 250 };
expect(evaluateCombatRotation(defensiveContext)?.ability.name).toBe(row.defensive);
}
});
it("prepares emergency healing with Inner Focus before direct or area healing", () => {
const talents = runtimePartyTalentRanksForLevel("priest", "Discipline", 80);
const innerFocus = learnedAbilityForBuild(ability("priest", "Inner Focus"), 80, talents)!;
const priest = context({
classId: "priest",
specializationId: "priest:discipline",
role: "healer",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [innerFocus],
});
expect(evaluateCombatRotation({
...priest,
friendlyTargets: [{ id: "tank", health: 400, maxHealth: 1_000, role: "tank" }],
})).toMatchObject({ ability: { name: "Inner Focus" }, ruleId: "prepare-emergency-heal" });
expect(evaluateCombatRotation({
...priest,
friendlyTargets: [
{ id: "tank", health: 600, maxHealth: 1_000, role: "tank" },
{ id: "rogue", health: 600, maxHealth: 1_000, role: "damage" },
],
})).toMatchObject({ ability: { name: "Inner Focus" }, ruleId: "prepare-area-heal" });
expect(evaluateCombatRotation({
...priest,
friendlyTargets: [{ id: "tank", health: 800, maxHealth: 1_000, role: "tank" }],
})).toBeNull();
});
it("uses Rogue defenses for boss threat, melee danger, and lethal threat resets", () => {
const talents = runtimePartyTalentRanksForLevel("rogue", "Assassination", 80);
const evasion = learnedAbilityForBuild(ability("rogue", "Evasion"), 80, talents)!;
const vanish = learnedAbilityForBuild(ability("rogue", "Vanish"), 80, talents)!;
const rogue = context({
classId: "rogue",
specializationId: "rogue:assassination",
primaryResource: "energy",
resources: { energy: 100 },
maximumResources: { energy: 100 },
hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true },
});
expect(evaluateCombatRotation({ ...rogue, abilities: [evasion], hasDangerousThreat: true })?.ability.name)
.toBe("Evasion");
expect(evaluateCombatRotation({ ...rogue, abilities: [evasion], health: 400,
enemyUsesMeleeAttacks: true })?.ability.name).toBe("Evasion");
expect(evaluateCombatRotation({ ...rogue, abilities: [evasion], incomingMeleeDanger: true }))
.toMatchObject({ ability: { name: "Evasion" }, ruleId: "preemptive-melee-evasion" });
expect(evaluateCombatRotation({ ...rogue, abilities: [vanish], hasDangerousThreat: true })?.ability.name)
.toBe("Vanish");
expect(evaluateCombatRotation({ ...rogue, abilities: [vanish], health: 200 }))
.toMatchObject({ ability: { name: "Vanish" }, ruleId: "emergency-vanish" });
});
it("uses Blink and Disengage to create distance, while Fire Blast covers movement", () => {
const mage = context({ classId: "mage", specializationId: "mage:fire", primaryResource: "mana",
resources: { mana: 10_000 }, maximumResources: { mana: 10_000 } });
expect(evaluateCombatRotation({ ...mage, abilities: [ability("mage", "Blink")], needsEscape: true })?.ability.name)
.toBe("Blink");
expect(evaluateCombatRotation({ ...mage, abilities: [ability("mage", "Fire Blast")], moving: true })?.ability.name)
.toBe("Fire Blast");
const talents = runtimePartyTalentRanksForLevel("hunter", "Marksmanship", 80);
const disengage = learnedAbilityForBuild(ability("hunter", "Disengage"), 80, talents)!;
const hunter = context({ classId: "hunter", specializationId: "hunter:marksmanship", primaryResource: "mana",
resources: { mana: 10_000 }, maximumResources: { mana: 10_000 }, abilities: [disengage], needsEscape: true });
expect(evaluateCombatRotation(hunter)?.ability.name).toBe("Disengage");
});
it("uses Marksmanship Readiness only on a durable target", () => {
const talents = runtimePartyTalentRanksForLevel("hunter", "Marksmanship", 80);
const readiness = learnedAbilityForBuild(ability("hunter", "Readiness"), 80, talents)!;
const base = context({
classId: "hunter",
specializationId: "hunter:marksmanship",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [readiness],
});
expect(evaluateCombatRotation(base)).toBeNull();
expect(evaluateCombatRotation({ ...base, hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true } })?.ability.name)
.toBe("Readiness");
});
it("uses combo builders before finishers and actual combo points for the decision", () => {
const builder = ability("rogue", "Sinister Strike");
const finisher = ability("rogue", "Eviscerate");
const building = evaluateCombatRotation(context({ abilities: [finisher, builder] }));
expect(building?.ability.id).toBe(builder.id);
const finishing = evaluateCombatRotation(context({
abilities: [builder, finisher],
comboPoints: 5,
comboTargetId: "target",
}));
expect(finishing?.ability.id).toBe(finisher.id);
});
it("uses Envenom as the Playerbots Assassination finisher ahead of Rupture", () => {
const talents = runtimePartyTalentRanksForLevel("rogue", "Assassination", 80);
const rupture = learnedAbilityForBuild(ability("rogue", "Rupture"), 80, talents)!;
const envenom = learnedAbilityForBuild(ability("rogue", "Envenom"), 80, talents)!;
const assassination = context({
specializationId: "rogue:assassination",
comboPoints: 5,
comboTargetId: "target",
abilities: [envenom, rupture],
});
expect(evaluateCombatRotation(assassination)?.ability.name).toBe("Envenom");
expect(evaluateCombatRotation({ ...assassination, maintainedAbilityIds: new Set([rupture.id]) })?.ability.name)
.toBe("Envenom");
});
it("uses Ready DPS area actions only at their authored pack thresholds", () => {
const multiShot = ability("hunter", "Multi-Shot");
const volley = ability("hunter", "Volley");
const hunter = context({
classId: "hunter",
specializationId: "hunter:marksmanship",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [volley, multiShot],
});
expect(evaluateCombatRotation({ ...hunter, hostileCount: 1 })).toBeNull();
expect(evaluateCombatRotation({ ...hunter, hostileCount: 2 })?.ability.name).toBe("Multi-Shot");
expect(evaluateCombatRotation({ ...hunter, hostileCount: 3 })?.ability.name).toBe("Volley");
const flamestrike = ability("mage", "Flamestrike");
const fire = context({
classId: "mage",
specializationId: "mage:fire",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [flamestrike],
});
expect(evaluateCombatRotation({ ...fire, hostileCount: 2 })).toBeNull();
expect(evaluateCombatRotation({ ...fire, hostileCount: 3 })?.ability.name).toBe("Flamestrike");
const fan = ability("rogue", "Fan of Knives");
const assassination = context({
specializationId: "rogue:assassination",
primaryResource: "energy",
resources: { energy: 100 },
maximumResources: { energy: 100 },
abilities: [fan],
});
expect(evaluateCombatRotation({ ...assassination, hostileCount: 2 })).toBeNull();
expect(evaluateCombatRotation({ ...assassination, hostileCount: 3 })?.ability.name).toBe("Fan of Knives");
});
it("reacts before throughput and conserves long offensive cooldowns for durable targets", () => {
const kick = ability("rogue", "Kick");
const strike = ability("rogue", "Sinister Strike");
expect(evaluateCombatRotation(context({
abilities: [strike, kick],
hostileTarget: {
id: "target",
health: 1_000,
maxHealth: 1_000,
activeCast: { interruptible: true },
},
}))?.ability.id).toBe(kick.id);
const bestialWrath = ability("hunter", "Bestial Wrath");
const arcaneShot = ability("hunter", "Arcane Shot");
const hunter = context({
classId: "hunter",
specializationId: "hunter:beast-mastery",
primaryResource: "mana",
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities: [bestialWrath, arcaneShot],
});
expect(evaluateCombatRotation(hunter)?.ability.id).toBe(arcaneShot.id);
expect(evaluateCombatRotation({ ...hunter, hostileTarget: { id: "boss", health: 10_000, maxHealth: 10_000, boss: true } })?.ability.id)
.toBe(bestialWrath.id);
});
it("uses healer triage bands and area healing for three injured allies", () => {
const flash = ability("priest", "Flash Heal");
const prayer = ability("priest", "Prayer of Healing");
const hostileLeech = semanticAbility("hostile-leech", [
{ kind: "damage", coefficient: 1 },
{ kind: "heal", coefficient: 1, recipient: "caster" },
]);
const decision = evaluateCombatRotation(context({
classId: "priest",
specializationId: "priest:holy",
role: "healer",
primaryResource: "mana",
resources: { mana: 1_000 },
maximumResources: { mana: 1_000 },
selfId: "self",
abilities: [flash, prayer, hostileLeech],
friendlyTargets: [
{ id: "a", health: 250, maxHealth: 1_000 },
{ id: "b", health: 500, maxHealth: 1_000 },
{ id: "c", health: 700, maxHealth: 1_000 },
],
}));
expect(decision?.ability.id).toBe(prayer.id);
expect(decision?.targetId).toBe("a");
});
it("includes RoM secondary general and elite skills in shared-policy candidates", () => {
const primaryOnly = abilitiesForCharacter("rom-warrior", null);
const dualClass = abilitiesForCharacter("rom-warrior", "rom-scout");
expect(dualClass.length).toBeGreaterThan(primaryOnly.length);
expect(dualClass.some((candidate) => candidate.secondaryClassId === "rom-scout")).toBe(true);
const secondaryGeneral = dualClass.find((candidate) => candidate.classId === "rom-scout"
&& !candidate.secondaryClassId && !candidate.passive && abilityHasRotationSemantics(candidate)
&& aiHintsForAbility(candidate).categories.some((category) => ["damage", "summon", "buff"].includes(category)));
const elite = dualClass.find((candidate) => candidate.secondaryClassId === "rom-scout"
&& !candidate.passive && abilityHasRotationSemantics(candidate)
&& aiHintsForAbility(candidate).categories.some((category) => ["damage", "summon", "buff"].includes(category)));
expect(secondaryGeneral).toBeDefined();
expect(elite).toBeDefined();
const dualContext = context({
classId: "rom-warrior",
secondaryClassId: "rom-scout",
specializationId: null,
primaryResource: "rage",
resources: { rage: 100, focus: 100 },
maximumResources: { rage: 100, focus: 100 },
hostileTarget: { id: "boss", health: 20_000, maxHealth: 20_000, boss: true },
});
expect(evaluateCombatRotation({ ...dualContext, abilities: [secondaryGeneral!] })?.ability.id).toBe(secondaryGeneral!.id);
expect(evaluateCombatRotation({ ...dualContext, abilities: [elite!] })?.ability.id).toBe(elite!.id);
});
it("handles execute, cleave/AoE thresholds, and maintained effects", () => {
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
const execute = semanticAbility("execute", [{ kind: "execute", coefficient: 1 }]);
const area = semanticAbility("area", [{ kind: "damage", coefficient: 1 }], { radius: 8 });
const debuff = semanticAbility("debuff", [{
kind: "apply-aura",
aura: { id: "test-debuff", name: "Test", disposition: "debuff", durationMs: 10_000,
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: 0.1 }] },
sourceEffectType: 6,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 0,
}]);
const base = context({ classId: "rom-warrior", specializationId: null, primaryResource: "rage",
resources: { rage: 100 }, maximumResources: { rage: 100 } });
expect(evaluateCombatRotation({ ...base, abilities: [strike, execute] })?.ability.id).toBe("strike");
expect(evaluateCombatRotation({ ...base, abilities: [strike, execute],
hostileTarget: { id: "target", health: 199, maxHealth: 1_000 } })?.ability.id).toBe("execute");
expect(evaluateCombatRotation({ ...base, abilities: [strike, area], hostileCount: 1 })?.ability.id).toBe("strike");
expect(evaluateCombatRotation({ ...base, abilities: [strike, area], hostileCount: 2 })?.ability.id).toBe("area");
expect(evaluateCombatRotation({ ...base, abilities: [strike, debuff] })?.ability.id).toBe("debuff");
expect(evaluateCombatRotation({ ...base, abilities: [strike, debuff], maintainedAbilityIds: new Set([debuff.id]) })?.ability.id)
.toBe("strike");
});
it("requires modeled combat state for reactive attacks and named execute-range spells", () => {
const revenge = ability("warrior", "Revenge");
const warrior = context({
classId: "warrior",
specializationId: "warrior:protection",
role: "tank",
primaryResource: "rage",
resources: { rage: 100 },
maximumResources: { rage: 100 },
abilities: [revenge],
});
expect(evaluateCombatRotation(warrior)).toBeNull();
expect(evaluateCombatRotation({
...warrior,
availableConditionalAbilityNames: new Set(["Revenge"]),
})?.ability.id).toBe(revenge.id);
const drainSoul = ability("warlock", "Drain Soul");
const shadowBolt = ability("warlock", "Shadow Bolt");
const warlock = context({
classId: "warlock",
specializationId: "warlock:affliction",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [drainSoul, shadowBolt],
});
expect(evaluateCombatRotation(warlock)?.ability.id).toBe(shadowBolt.id);
expect(evaluateCombatRotation({
...warlock,
hostileTarget: { id: "target", health: 250, maxHealth: 1_000 },
})?.ability.id).toBe(drainSoul.id);
});
it("keeps available specialization actions ahead of off-profile throughput", () => {
const fireball = ability("mage", "Fireball");
const arcaneMissiles = ability("mage", "Arcane Missiles");
const mage = context({
classId: "mage",
specializationId: "mage:fire",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [arcaneMissiles, fireball],
});
expect(evaluateCombatRotation(mage)?.ability.id).toBe(fireball.id);
const bearMangle = ability("druid", "Mangle (Bear)");
const rake = ability("druid", "Rake");
const moonfire = ability("druid", "Moonfire");
const bear = context({
classId: "druid",
specializationId: "druid:feral-bear",
role: "tank",
primaryResource: "mana",
resources: { mana: 10_000, rage: 100, energy: 100 },
maximumResources: { mana: 10_000, rage: 100, energy: 100 },
abilities: [rake, bearMangle],
});
expect(evaluateCombatRotation(bear)?.ability.id).toBe(bearMangle.id);
expect(evaluateCombatRotation({
...bear,
abilities: [rake, moonfire],
knownDamagingAbilityNames: new Set([rake.name, moonfire.name, bearMangle.name]),
})).toBeNull();
});
it("reserves off-profile utility for reactive needs instead of spending rotation GCDs", () => {
const demoralizingShout = ability("warrior", "Demoralizing Shout");
const pummel = ability("warrior", "Pummel");
const mortalStrike = ability("warrior", "Mortal Strike");
const arms = context({
classId: "warrior",
specializationId: "warrior:arms",
primaryResource: "rage",
resources: { rage: 100 },
maximumResources: { rage: 100 },
abilities: [demoralizingShout, pummel],
knownDamagingAbilityNames: new Set([mortalStrike.name]),
});
expect(evaluateCombatRotation(arms)).toBeNull();
expect(evaluateCombatRotation({
...arms,
hostileTarget: {
id: "caster",
health: 1_000,
maxHealth: 1_000,
activeCast: { interruptible: true },
},
})?.ability.id).toBe(pummel.id);
});
it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
const prayer = semanticAbility("prayer", [
{ kind: "heal", coefficient: 1 },
{
kind: "apply-aura",
aura: {
id: "prayer-hot",
name: "Prayer",
disposition: "buff",
durationMs: 21_000,
procs: [{ id: "prayer-proc", triggers: "periodic", chance: 1,
action: { kind: "heal", amount: 10, target: "aura-target" } }],
},
sourceEffectType: 6,
sourceAuraType: 23,
miscValue: 0,
triggerSpellId: 1,
},
], { target: "friendly" });
const base = context({ classId: "venomancer", specializationId: null, abilities: [prayer, strike] });
expect(evaluateCombatRotation(base)?.ability.id).toBe(strike.id);
expect(evaluateCombatRotation({ ...base, friendlyTargets: [{ id: "ally", health: 50, maxHealth: 100 }] }))
.toMatchObject({ ability: { id: prayer.id }, targetId: "ally" });
});
it("handles movement, threat recovery, defenses, resources, pets, and DK runes", () => {
const fireball = ability("mage", "Fireball");
const fireBlast = ability("mage", "Fire Blast");
const mage = context({ classId: "mage", specializationId: "mage:fire", primaryResource: "mana",
resources: { mana: 100 }, maximumResources: { mana: 100 }, abilities: [fireball, fireBlast] });
expect(evaluateCombatRotation(mage)?.ability.id).toBe(fireball.id);
expect(evaluateCombatRotation({ ...mage, moving: true })?.ability.id).toBe(fireBlast.id);
const fade = ability("priest", "Fade");
const mindBlast = ability("priest", "Mind Blast");
const priest = context({ classId: "priest", specializationId: "priest:shadow", primaryResource: "mana",
resources: { mana: 100 }, maximumResources: { mana: 100 }, abilities: [fade, mindBlast] });
expect(evaluateCombatRotation(priest)?.ability.id).toBe(mindBlast.id);
expect(evaluateCombatRotation({ ...priest, hasDangerousThreat: true })?.ability.id).toBe(fade.id);
const shield = semanticAbility("shield", [{ kind: "shield", coefficient: 1, durationMs: 5_000 }], { target: "self" });
const fallback = semanticAbility("fallback", [{ kind: "damage", coefficient: 1 }]);
const generator = semanticAbility("generator", [{ kind: "resource", amount: 30, resource: "rage" }], { target: "self" });
const summon = semanticAbility("summon", [{ kind: "summon", creatureId: 1, coefficient: 0.3, durationMs: 30_000 }],
{ target: "self" });
const semantic = context({ classId: "rom-warrior", specializationId: null, primaryResource: "rage",
resources: { rage: 10 }, maximumResources: { rage: 100 }, abilities: [fallback, shield, generator, summon],
activeSummonAbilityIds: new Set([summon.id]) });
expect(evaluateCombatRotation(semantic)?.ability.id).toBe(generator.id);
expect(evaluateCombatRotation({ ...semantic, resources: { rage: 100 }, health: 300,
activeSummonAbilityIds: new Set([summon.id]) })?.ability.id).toBe(shield.id);
expect(evaluateCombatRotation({ ...semantic, resources: { rage: 100 }, health: 1_000,
activeSummonAbilityIds: new Set([summon.id]) })?.ability.id).toBe(fallback.id);
const icyTouch = ability("death-knight", "Icy Touch");
const deathCoil = ability("death-knight", "Death Coil");
const dk = context({ classId: "death-knight", specializationId: "death-knight:unholy",
primaryResource: "runic-power", resources: { "runic-power": 100 }, maximumResources: { "runic-power": 100 },
abilities: [icyTouch, deathCoil], runes: createDeathKnightRunes(0) });
expect(evaluateCombatRotation(dk)?.ability.id).toBe(icyTouch.id);
expect(evaluateCombatRotation({ ...dk, runes: dk.runes.map((rune) => (
rune.type === "frost" ? { ...rune, readyAt: 10_000 } : rune
)) })?.ability.id).toBe(deathCoil.id);
});
it("uses Rockbiter as the low-level Shaman maintenance fallback", () => {
const rockbiter = learnedAbilityForBuild(ability("shaman", "Rockbiter Weapon"), 1, {})!;
const lightningBolt = learnedAbilityForBuild(ability("shaman", "Lightning Bolt"), 1, {})!;
const shaman = context({
classId: "shaman",
specializationId: "shaman:elemental",
level: 1,
primaryResource: "mana",
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities: [rockbiter, lightningBolt],
maintainedAbilityIds: new Set(),
activeAuraExclusiveGroups: new Set(),
});
expect(evaluateCombatRotation(shaman)?.ability.id).toBe(rockbiter.id);
expect(evaluateCombatRotation({ ...shaman, maintainedAbilityIds: new Set([rockbiter.id]) })?.ability.id)
.toBe(lightningBolt.id);
});
it("matches maintained auras by recipient and exact effect while allowing a group override", () => {
const auraAbility = (id: string, auraId: string, recipient: "caster" | "ability-target" = "caster") => semanticAbility(id, [{
kind: "apply-aura",
aura: {
id: auraId,
name: id,
disposition: "buff",
durationMs: 300_000,
exclusiveGroup: "test:stance",
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }],
},
recipient,
sourceEffectType: 6,
sourceAuraType: 22,
miscValue: 0,
triggerSpellId: 0,
}], { target: "self" });
const stance = auraAbility("stance", "stance-aura");
const replacementStance = auraAbility("replacement-stance", "replacement-aura");
const allyAura = {
...auraAbility("ally-aura", "ally-aura-id", "ability-target"),
target: "friendly" as const,
};
const activeOnSelf = context({
selfId: "player",
maintainedAuras: [{ auraId: "stance-aura", targetId: "player" }],
});
expect(abilityAuraMaintainedOnTarget(stance, activeOnSelf, "player")).toBe(true);
expect(abilityAuraMaintainedOnTarget(allyAura, activeOnSelf, "ally")).toBe(false);
expect(abilityAuraMaintainedOnTarget(stance, {
...activeOnSelf,
maintainedAuras: [{ auraId: "stance-aura", targetId: "ally" }],
}, "player")).toBe(false);
expect(abilityAuraMaintainedOnTarget(allyAura, {
...activeOnSelf,
maintainedAuras: [{ auraId: "ally-aura-id", targetId: "player" }],
}, "player")).toBe(true);
expect(abilityAuraMaintainedOnTarget(replacementStance, activeOnSelf, "player")).toBe(false);
expect(abilityAuraMaintainedOnTarget({
...stance,
effects: [...stance.effects, { kind: "damage", coefficient: 1 }],
}, activeOnSelf, "player")).toBe(false);
expect(abilityAuraMaintainedOnTarget(stance, { ...activeOnSelf, maintainedAuras: [] }, "player")).toBe(false);
});
it("maintains Earth Shield on a valid full-health ally", () => {
const earthShield = ability("shaman", "Earth Shield");
const shaman = context({
classId: "shaman",
specializationId: "shaman:restoration",
role: "healer",
selfId: "shaman",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [earthShield],
friendlyTargets: [{ id: "tank", health: 1_000, maxHealth: 1_000 }],
maintainedAbilityIds: new Set(),
activeAuraExclusiveGroups: new Set(),
});
expect(evaluateCombatRotation(shaman)).toMatchObject({
ability: { id: earthShield.id },
targetId: "tank",
});
expect(evaluateCombatRotation({ ...shaman, friendlyTargets: [] })).toBeNull();
});
it("uses Feral Spirit as an Enhancement major cooldown on a durable target", () => {
const feralSpirit = ability("shaman", "Feral Spirit");
const shaman = context({
classId: "shaman",
specializationId: "shaman:enhancement",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [feralSpirit],
hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true },
activeSummonAbilityIds: new Set(),
});
expect(evaluateCombatRotation(shaman)?.ability.id).toBe(feralSpirit.id);
});
it("enters a learned Death Knight presence before attacking", () => {
const bloodPresence = learnedAbilityForBuild(ability("death-knight", "Blood Presence"), 1, {})!;
const icyTouch = learnedAbilityForBuild(ability("death-knight", "Icy Touch"), 1, {})!;
expect(bloodPresence.effects.some((effect) => effect.kind === "heal")).toBe(false);
const deathKnight = context({
classId: "death-knight",
specializationId: "death-knight:blood",
role: "tank",
level: 1,
primaryResource: "runic-power",
resources: { "runic-power": 0 },
maximumResources: { "runic-power": 100 },
runes: createDeathKnightRunes(),
abilities: [bloodPresence, icyTouch],
maintainedAbilityIds: new Set(),
activeAuraExclusiveGroups: new Set(),
});
expect(evaluateCombatRotation(deathKnight)?.ability.id).toBe(bloodPresence.id);
});
it("summons one real pet and ignores noncombat utility summons", () => {
const shadowBolt = ability("warlock", "Shadow Bolt");
const imp = ability("warlock", "Summon Imp");
const eye = ability("warlock", "Eye of Kilrogg");
const warlock = context({
classId: "warlock",
specializationId: null,
primaryResource: "mana",
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities: [shadowBolt, imp, eye],
activeSummonAbilityIds: new Set(),
hasActivePet: false,
});
expect(evaluateCombatRotation(warlock)?.ability.id).toBe(imp.id);
expect(evaluateCombatRotation({ ...warlock, hasActivePet: true })?.ability.id).toBe(shadowBolt.id);
expect(evaluateCombatRotation({ ...warlock, abilities: [shadowBolt, eye] })?.ability.id).toBe(shadowBolt.id);
});
it("is consumer-independent for equivalent normalized state", () => {
const abilities = [ability("mage", "Fireball"), ability("mage", "Fire Blast")];
const normalized = context({
classId: "mage",
specializationId: "mage:fire",
primaryResource: "mana" as ResourceType,
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities,
});
const companionDecision = evaluateCombatRotation(normalized);
const benchmarkDecision = evaluateCombatRotation({ ...normalized });
expect(benchmarkDecision).toEqual(companionDecision);
});
});