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 { 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 { 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 { 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); 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("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); }); });