907 lines
43 KiB
TypeScript
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);
|
|
});
|
|
});
|