Release Healer Man 0.1.49
This commit is contained in:
1 parent
39425f2494
commit
612170afd0
91 files changed
+2278
-263
No files matched your search
@@ -0,0 +1,427 @@
|
||||
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,
|
||||
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 { specializationsForClass } from "./specializationCatalog";
|
||||
|
||||
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("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);
|
||||
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("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("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("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("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("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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user