import { describe, expect, it } from "vitest"; import type { AbilityDefinition } from "./abilityCatalog"; import type { RotationContext } from "./combatRotation"; import { NATIVE_ROTATION_PROFILES, ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS, nativeRotationAbilityAllowed, nativeRotationProfileFor, } from "./romFiveRotation"; function context(overrides: Partial = {}): RotationContext { return { now: 1_000, classId: "rom-priest", secondaryClassId: "rom-knight", statRuleset: "rom", role: "healer", level: 30, health: 100, maxHealth: 100, selfId: "serenna", resources: { mana: 100 }, maximumResources: { mana: 100 }, primaryResource: "mana", comboPoints: 0, comboTargetId: null, runes: [], abilities: [], hostileTarget: { id: "target", health: 500, maxHealth: 500 }, friendlyTargets: [], hostileCount: 1, ...overrides, }; } function ability( name: string, overrides: Partial = {}, ): AbilityDefinition { return { id: `test-${name}`, classId: "rom-priest", dbcSpellId: 1, name, unlockLevel: 1, icon: "", sigil: "", description: "test fixture", target: "friendly", range: { min: 0, max: 30 }, castMode: "instant", castTimeMs: 0, cooldownMs: 0, gcdMs: 0, cost: { resource: "mana", amount: 0 }, effects: [], ...overrides, }; } describe("native RoM five rotation profiles", () => { it("only resolves exact pair, role, RoM ruleset, and secondary-class levels", () => { expect(nativeRotationProfileFor(context())).toMatchObject({ id: "rom-five:rom-priest+rom-knight:healer", classId: "rom-priest", secondaryClassId: "rom-knight", role: "healer", }); expect(nativeRotationProfileFor(context({ secondaryClassId: "rom-scout" }))).toBeNull(); expect(nativeRotationProfileFor(context({ classId: "rom-knight", secondaryClassId: "rom-scout" }))).toBeNull(); expect(nativeRotationProfileFor(context({ role: "damage" }))).toBeNull(); expect(nativeRotationProfileFor(context({ statRuleset: "wow" }))).toBeNull(); expect(nativeRotationProfileFor(context({ secondaryClassId: null }))).toBeNull(); expect(nativeRotationProfileFor(context({ level: 9 }))).toBeNull(); }); it("keeps the five ordered pairs and a damaging fallback tail for the same build", () => { expect(NATIVE_ROTATION_PROFILES.map(({ classId, secondaryClassId, role }) => [ classId, secondaryClassId, role, ])).toEqual([ ["rom-knight", "rom-scout", "tank"], ["rom-priest", "rom-knight", "healer"], ["rom-rogue", "rom-warrior", "damage"], ["rom-scout", "rom-rogue", "damage"], ["rom-mage", "rom-warlock", "damage"], ]); const profile = nativeRotationProfileFor(context({ knownDamagingAbilityNames: new Set(["Current CoA Ability", "Urgent Heal"]), })); expect(profile?.abilityPriority.slice(0, 2)).toEqual(["Urgent Heal", "Group Heal"]); expect(profile?.abilityPriority.at(-1)).toBe("Current CoA Ability"); }); }); describe("native RoM rotation action guards", () => { it("uses taunts only for a tank's actually loose target and gates shield spend defensively", () => { const arrow = ability("Arrow of Vengeance", { classId: "rom-knight" }); expect(nativeRotationAbilityAllowed(arrow, context({ classId: "rom-knight", secondaryClassId: "rom-scout", role: "tank", }))).toBe(false); expect(nativeRotationAbilityAllowed(arrow, context({ classId: "rom-knight", secondaryClassId: "rom-scout", role: "tank", targetIsLoose: true, }))).toBe(true); const shieldBash = ability("Truth Shield Bash", { classId: "rom-knight" }); expect(nativeRotationAbilityAllowed(shieldBash, context({ classId: "rom-knight", secondaryClassId: "rom-scout", role: "tank", }))).toBe(false); expect(nativeRotationAbilityAllowed(shieldBash, context({ classId: "rom-knight", secondaryClassId: "rom-scout", role: "tank", incomingMeleeDanger: true, }))).toBe(true); }); it("keeps emergency single heals fast and requires multiple injured allies for Group Heal", () => { const urgent = ability("Urgent Heal"); const group = ability("Group Heal"); const oneInjured = [{ id: "tank", health: 30, maxHealth: 100, role: "tank" as const }]; expect(nativeRotationAbilityAllowed(urgent, context({ friendlyTargets: oneInjured }))).toBe(true); expect(nativeRotationAbilityAllowed(urgent, context({ friendlyTargets: [{ id: "tank", health: 70, maxHealth: 100, role: "tank" }], }))).toBe(false); expect(nativeRotationAbilityAllowed(group, context({ friendlyTargets: oneInjured }))).toBe(false); expect(nativeRotationAbilityAllowed(group, context({ friendlyTargets: [ { id: "tank", health: 70, maxHealth: 100, role: "tank" }, { id: "scout", health: 80, maxHealth: 100, role: "damage" }, ], }))).toBe(true); }); it("refreshes a target-owned periodic effect only inside its expiry window", () => { const dot = ability("Soul Pain", { classId: "rom-warlock", secondaryClassId: "rom-mage", target: "hostile", effects: [{ kind: "dot", coefficient: 1, ticks: 12, intervalMs: 2_000, tag: "soul-pain" }], }); const effect = (remainingMs: number, sourceActorId = "serenna", targetId = "target") => ({ abilityId: dot.id, sourceActorId, targetId, remainingMs, }); expect(nativeRotationAbilityAllowed(dot, context({ classId: "rom-mage", secondaryClassId: "rom-warlock", role: "damage", selfId: "durnik", maintainedPeriodicEffects: [effect(ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS + 1, "durnik")], }))).toBe(false); expect(nativeRotationAbilityAllowed(dot, context({ classId: "rom-mage", secondaryClassId: "rom-warlock", role: "damage", selfId: "durnik", maintainedPeriodicEffects: [effect(ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS, "durnik")], }))).toBe(true); expect(nativeRotationAbilityAllowed(dot, context({ classId: "rom-mage", secondaryClassId: "rom-warlock", role: "damage", selfId: "durnik", maintainedAbilityIds: new Set([dot.id]), maintainedPeriodicEffects: [effect(10_000, "another-caster")], }))).toBe(true); expect(nativeRotationAbilityAllowed(dot, context({ classId: "rom-mage", secondaryClassId: "rom-warlock", role: "damage", selfId: "durnik", maintainedPeriodicEffects: [effect(10_000, "durnik", "another-target")], }))).toBe(true); expect(nativeRotationAbilityAllowed(dot, context({ classId: "rom-mage", secondaryClassId: "rom-warlock", role: "damage", selfId: "durnik", hostileTarget: { id: "small-target", health: 100, maxHealth: 100 }, }))).toBe(false); }); it("does not spend old health-cost Sacrificial Cure data and only bursts on durable targets", () => { const staleCure = ability("Sacrificial Cure", { cost: { resource: "health", amount: 70, percentage: true }, }); expect(nativeRotationAbilityAllowed(staleCure, context({ friendlyTargets: [{ id: "tank", health: 10, maxHealth: 100 }], }))).toBe(false); const burst = ability("Assassins Rage", { classId: "rom-rogue" }); expect(nativeRotationAbilityAllowed(burst, context({ classId: "rom-rogue", secondaryClassId: "rom-warrior", role: "damage", hostileTarget: { id: "small-mob", health: 150, maxHealth: 150 }, }))).toBe(false); expect(nativeRotationAbilityAllowed(burst, context({ classId: "rom-rogue", secondaryClassId: "rom-warrior", role: "damage", hostileTarget: { id: "boss", health: 10_000, maxHealth: 10_000, boss: true }, }))).toBe(true); }); it("waits for Vampire Arrows before Deadly Poison Bite and applies AoE minimums", () => { const bite = ability("Deadly Poison Bite", { classId: "rom-scout" }); const scout = { classId: "rom-scout" as const, secondaryClassId: "rom-rogue" as const, role: "damage" as const, selfId: "caelan", }; expect(nativeRotationAbilityAllowed(bite, context(scout))).toBe(false); expect(nativeRotationAbilityAllowed(bite, context({ ...scout, maintainedPeriodicEffects: [{ abilityId: "rom-scout-general-2", sourceActorId: "caelan", targetId: "target", remainingMs: 5_000, }], }))).toBe(true); const thunderstorm = ability("Thunderstorm", { classId: "rom-mage" }); const mage = { classId: "rom-mage" as const, secondaryClassId: "rom-warlock" as const, role: "damage" as const }; expect(nativeRotationAbilityAllowed(thunderstorm, context(mage))).toBe(false); expect(nativeRotationAbilityAllowed(thunderstorm, context({ ...mage, hostileCount: 2 }))).toBe(true); const areaAttack = ability("Current CoA area attack", { classId: "rom-mage", secondaryClassId: "rom-warlock", target: "hostile", effects: [{ kind: "damage", coefficient: 1 }], targeting: { shape: "circle", radius: 8 }, }); expect(nativeRotationAbilityAllowed(areaAttack, context(mage))).toBe(false); expect(nativeRotationAbilityAllowed(areaAttack, context({ ...mage, hostileCount: 2 }))).toBe(true); }); it("leaves unsupported and pre-secondary builds to the existing generic fallback", () => { const abilityUnderTest = ability("Holy Strike", { classId: "rom-knight" }); expect(nativeRotationAbilityAllowed(abilityUnderTest, context({ level: 9 }))).toBe(true); expect(nativeRotationAbilityAllowed(abilityUnderTest, context({ classId: "rom-knight", secondaryClassId: "rom-priest", role: "tank", }))).toBe(true); }); });