forked from phenom/healer-man
176 lines
7.4 KiB
TypeScript
176 lines
7.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { abilitiesForClass } from "./abilityCatalog";
|
|
import { targetingRulesForAbility } from "./abilityRules";
|
|
import {
|
|
coaPeriodicActionMaintainedOnTarget,
|
|
usesCoaTargetedPeriodicMaintenance,
|
|
} from "./coaPeriodicRotationRuntime";
|
|
import { evaluateCombatRotation, type RotationContext } from "./combatRotation";
|
|
|
|
function coaPeriodicAbility(classId: "ranger" | "witch-doctor", name: string) {
|
|
const ability = abilitiesForClass(classId).find((candidate) => candidate.name === name
|
|
&& candidate.effects.some((effect) => effect.kind === "dot" || effect.kind === "hot"));
|
|
if (!ability) throw new Error(`Missing CoA ${classId} periodic ability ${name}.`);
|
|
return ability;
|
|
}
|
|
|
|
function contextFor(
|
|
classId: RotationContext["classId"],
|
|
ability: RotationContext["abilities"][number],
|
|
options: {
|
|
readonly selfId?: string;
|
|
readonly targetId?: string;
|
|
readonly targetHealth?: number;
|
|
readonly maxTargetHealth?: number;
|
|
readonly friendlyTargets?: RotationContext["friendlyTargets"];
|
|
readonly maintainedPeriodicEffects?: RotationContext["maintainedPeriodicEffects"];
|
|
} = {},
|
|
): RotationContext {
|
|
const primaryResource = classId === "ranger" ? "focus" : classId === "barbarian" ? "energy" : "mana";
|
|
return {
|
|
now: 5_000,
|
|
classId,
|
|
specializationId: classId === "ranger" ? "ranger:archery" : "witch-doctor:brewing",
|
|
role: classId === "witch-doctor" ? "healer" : "damage",
|
|
level: 60,
|
|
health: 1_000,
|
|
maxHealth: 1_000,
|
|
selfId: options.selfId ?? "coa-periodic-caster",
|
|
resources: { [primaryResource]: 100 },
|
|
maximumResources: { [primaryResource]: 100 },
|
|
primaryResource,
|
|
comboPoints: 0,
|
|
comboTargetId: null,
|
|
runes: [],
|
|
abilities: [ability],
|
|
hostileTarget: options.targetId && ability.target !== "friendly"
|
|
? { id: options.targetId, health: options.targetHealth ?? 800, maxHealth: options.maxTargetHealth ?? 1_000 }
|
|
: null,
|
|
friendlyTargets: options.friendlyTargets,
|
|
maintainedAbilityIds: new Set([ability.id]),
|
|
maintainedPeriodicEffects: options.maintainedPeriodicEffects,
|
|
};
|
|
}
|
|
|
|
describe("target-specific CoA periodic maintenance", () => {
|
|
it("keeps a live Ranger DoT on enemy A from blocking the same cast on enemy B", () => {
|
|
const serratedShot = coaPeriodicAbility("ranger", "Serrated Shot");
|
|
expect(serratedShot.source).toMatch(/^coa-/);
|
|
expect(serratedShot.target).toBe("hostile");
|
|
expect(targetingRulesForAbility(serratedShot).shape).toBe("unit");
|
|
|
|
const onA = contextFor("ranger", serratedShot, {
|
|
targetId: "enemy-b",
|
|
maintainedPeriodicEffects: [{
|
|
abilityId: serratedShot.id,
|
|
sourceActorId: "coa-periodic-caster",
|
|
targetId: "enemy-a",
|
|
remainingMs: 2_000,
|
|
}],
|
|
});
|
|
expect(onA.maintainedAbilityIds?.has(serratedShot.id)).toBe(true);
|
|
expect(usesCoaTargetedPeriodicMaintenance(serratedShot, onA)).toBe(true);
|
|
expect(coaPeriodicActionMaintainedOnTarget(serratedShot, onA, "enemy-b")).toBe(false);
|
|
expect(evaluateCombatRotation(onA)).toMatchObject({ ability: { id: serratedShot.id }, targetId: "enemy-b" });
|
|
|
|
const onB = contextFor("ranger", serratedShot, {
|
|
targetId: "enemy-b",
|
|
maintainedPeriodicEffects: [{
|
|
abilityId: serratedShot.id,
|
|
sourceActorId: "coa-periodic-caster",
|
|
targetId: "enemy-b",
|
|
remainingMs: 2_000,
|
|
}],
|
|
});
|
|
expect(coaPeriodicActionMaintainedOnTarget(serratedShot, onB, "enemy-b")).toBe(true);
|
|
expect(evaluateCombatRotation(onB)).toBeNull();
|
|
});
|
|
|
|
it("uses the intended friendly recipient and ignores foreign or expired HoTs", () => {
|
|
const mojoBeam = coaPeriodicAbility("witch-doctor", "Mojo Beam");
|
|
expect(mojoBeam.source).toMatch(/^coa-/);
|
|
expect(mojoBeam.target).toBe("friendly");
|
|
expect(targetingRulesForAbility(mojoBeam).shape).toBe("unit");
|
|
const casterOnlyHot = {
|
|
...mojoBeam,
|
|
effects: [{ kind: "hot" as const, coefficient: 1, ticks: 2, intervalMs: 1, recipient: "caster" as const }],
|
|
};
|
|
expect(usesCoaTargetedPeriodicMaintenance(casterOnlyHot)).toBe(false);
|
|
const allies = [
|
|
{ id: "ally-a", health: 700, maxHealth: 1_000, role: "tank" as const },
|
|
{ id: "ally-b", health: 400, maxHealth: 1_000, role: "damage" as const },
|
|
];
|
|
const onAllyA = contextFor("witch-doctor", mojoBeam, {
|
|
friendlyTargets: allies,
|
|
maintainedPeriodicEffects: [{
|
|
abilityId: mojoBeam.id,
|
|
sourceActorId: "coa-periodic-caster",
|
|
targetId: "ally-a",
|
|
remainingMs: 2_000,
|
|
}],
|
|
});
|
|
expect(onAllyA.friendlyTargets?.[1]?.id).toBe("ally-b");
|
|
expect(coaPeriodicActionMaintainedOnTarget(mojoBeam, onAllyA, "ally-b")).toBe(false);
|
|
expect(evaluateCombatRotation(onAllyA)).toMatchObject({ ability: { id: mojoBeam.id }, targetId: "ally-b" });
|
|
|
|
const foreign = contextFor("witch-doctor", mojoBeam, {
|
|
friendlyTargets: allies,
|
|
maintainedPeriodicEffects: [{
|
|
abilityId: mojoBeam.id,
|
|
sourceActorId: "another-witch-doctor",
|
|
targetId: "ally-b",
|
|
remainingMs: 2_000,
|
|
}],
|
|
});
|
|
expect(usesCoaTargetedPeriodicMaintenance(mojoBeam, foreign)).toBe(true);
|
|
expect(coaPeriodicActionMaintainedOnTarget(mojoBeam, foreign, "ally-b")).toBe(false);
|
|
expect(evaluateCombatRotation(foreign)).toMatchObject({ ability: { id: mojoBeam.id }, targetId: "ally-b" });
|
|
|
|
const onRecipient = contextFor("witch-doctor", mojoBeam, {
|
|
friendlyTargets: allies,
|
|
maintainedPeriodicEffects: [{
|
|
abilityId: mojoBeam.id,
|
|
sourceActorId: "coa-periodic-caster",
|
|
targetId: "ally-b",
|
|
remainingMs: 2_000,
|
|
}],
|
|
});
|
|
expect(evaluateCombatRotation(onRecipient)).toBeNull();
|
|
|
|
const expired = contextFor("witch-doctor", mojoBeam, {
|
|
friendlyTargets: allies,
|
|
maintainedPeriodicEffects: [{
|
|
abilityId: mojoBeam.id,
|
|
sourceActorId: "coa-periodic-caster",
|
|
targetId: "ally-b",
|
|
remainingMs: 0,
|
|
}],
|
|
});
|
|
expect(coaPeriodicActionMaintainedOnTarget(mojoBeam, expired, "ally-b")).toBe(false);
|
|
expect(evaluateCombatRotation(expired)).toMatchObject({ ability: { id: mojoBeam.id }, targetId: "ally-b" });
|
|
});
|
|
|
|
it("leaves non-CoA and area periodic abilities on their existing maintenance path", () => {
|
|
const moonfire = abilitiesForClass("druid").find((ability) => ability.name === "Moonfire");
|
|
if (!moonfire) throw new Error("Missing WoW Moonfire.");
|
|
const wow = contextFor("druid", moonfire, { targetId: "enemy-b" });
|
|
const wowMaintenance = {
|
|
...wow,
|
|
specializationId: "druid:balance",
|
|
maintainedPeriodicEffects: undefined,
|
|
} satisfies RotationContext;
|
|
expect(usesCoaTargetedPeriodicMaintenance(moonfire, wowMaintenance)).toBe(false);
|
|
expect(evaluateCombatRotation({ ...wowMaintenance, maintainedAbilityIds: undefined })?.ability.id).toBe(moonfire.id);
|
|
expect(evaluateCombatRotation({
|
|
...wowMaintenance,
|
|
maintainedAbilityIds: new Set([moonfire.id]),
|
|
})).toBeNull();
|
|
|
|
const areaHot = abilitiesForClass("witch-doctor").find((ability) => ability.name === "Splash Potion");
|
|
if (!areaHot) throw new Error("Missing CoA area periodic action Splash Potion.");
|
|
const areaContext = contextFor("witch-doctor", areaHot, { targetId: "enemy-b" });
|
|
expect(targetingRulesForAbility(areaHot).shape).not.toBe("unit");
|
|
expect(usesCoaTargetedPeriodicMaintenance(areaHot, areaContext)).toBe(false);
|
|
});
|
|
});
|