Release Healer Man 0.1.49

This commit is contained in:
phenom committed 2026-09-17 09:13:15 -04:00
1 parent 39425f2494
commit 612170afd0
91 files changed
+2278 -263

No files matched your search

+427
View File
@@ -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);
});
});