278 lines
16 KiB
TypeScript
278 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { abilityById, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
|
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
|
import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityNeedsTwoHand, romAbilityRequiredTargetStateTag, romEffectsForCast, romTargetBleedCount, romTargetHasSlashBleed, romWarriorAbilityAdjustment, romWarriorThreatMultiplier, type RomCastMechanics } from "./romCombatMechanics";
|
|
|
|
const empty: RomCastMechanics = {
|
|
sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 2_000,
|
|
targetHealthPercent: 1,
|
|
};
|
|
|
|
function cast(id: string, overrides: Partial<RomCastMechanics> = {}): readonly AbilityEffect[] {
|
|
const ability = abilityById(id);
|
|
expect(ability).toBeDefined();
|
|
return romEffectsForCast(ability!, { ...empty, ...overrides });
|
|
}
|
|
|
|
function kinds(effects: readonly AbilityEffect[]): readonly string[] {
|
|
return effects.map((effect) => effect.kind);
|
|
}
|
|
|
|
describe("RoM primary class conditional combat effects", () => {
|
|
it("applies learned Priest/Warrior Condensed Rage only to physical damage", () => {
|
|
const source = ROM_ALL_ABILITIES.find((ability) => ability.classId === "rom-priest")!;
|
|
const physicalAbility: AbilityDefinition = {
|
|
...source,
|
|
effects: [{ kind: "damage", coefficient: 0, basePoints: 100, school: "physical" }],
|
|
};
|
|
const holyAbility: AbilityDefinition = {
|
|
...physicalAbility,
|
|
effects: [{ kind: "damage", coefficient: 0, basePoints: 100, school: "holy" }],
|
|
};
|
|
const otherClassAbility: AbilityDefinition = { ...physicalAbility, classId: "rom-warrior" };
|
|
const withPassive = { ...empty, learnedPassiveSpellIds: [493038] };
|
|
const withoutPassive = { ...empty, learnedPassiveSpellIds: [] };
|
|
|
|
expect(romEffectsForCast(physicalAbility, withPassive)[0]).toMatchObject({ basePoints: 122, school: "physical" });
|
|
expect(romEffectsForCast(physicalAbility, withoutPassive)[0]).toMatchObject({ basePoints: 100 });
|
|
expect(romEffectsForCast(holyAbility, withPassive)[0]).toMatchObject({ basePoints: 100, school: "holy" });
|
|
expect(romEffectsForCast(otherClassAbility, withPassive)[0]).toMatchObject({ basePoints: 100 });
|
|
});
|
|
|
|
it("recognizes source-required shields and ranged weapons across primary and elite rows", () => {
|
|
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
|
|
for (const spellId of [491333, 491484]) expect(romAbilityNeedsShield(sourceAbility(spellId))).toBe(true);
|
|
for (const spellId of [490438, 491452, 491616, 494021, 494037, 494039, 494040, 494042, 494629]) {
|
|
expect(romAbilityNeedsRangedWeapon(sourceAbility(spellId))).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("selects the Nature's Power Healing Arrows HoT and consumes one point only when available", () => {
|
|
const baseline = cast("rom-druid-rom-scout-elite-25", { naturePower: 0 });
|
|
const empowered = cast("rom-druid-rom-scout-elite-25", { naturePower: 1 });
|
|
expect(baseline).toHaveLength(1);
|
|
expect(baseline[0]?.kind).toBe("hot");
|
|
if (baseline[0]?.kind === "hot") expect(baseline[0].basePoints).toBeCloseTo(18.88);
|
|
expect(empowered).toHaveLength(2);
|
|
expect(empowered[0]?.kind).toBe("hot");
|
|
if (empowered[0]?.kind === "hot") expect(empowered[0].basePoints).toBeCloseTo(23.6);
|
|
expect(empowered[1]).toEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" });
|
|
expect(cast("rom-druid-rom-scout-elite-25", { naturePower: 0.5 }).filter((effect) => effect.kind === "resource-drain"))
|
|
.toEqual([]);
|
|
});
|
|
|
|
it("counts only active Rogue bleeds applied by the caster to that target", () => {
|
|
const timed = [
|
|
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "rogue", targetId: "mob", remainingTicks: 3 },
|
|
{ kind: "dot", sourceAbilityId: "rom-rogue-general-0", sourceActorId: "rogue", targetId: "mob", remainingTicks: 2 },
|
|
{ kind: "dot", sourceAbilityId: "rom-rogue-general-0", sourceActorId: "rogue", targetId: "mob", remainingTicks: 1 },
|
|
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-9", sourceActorId: "other", targetId: "mob", remainingTicks: 2 },
|
|
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-4", sourceActorId: "rogue", targetId: "other-mob", remainingTicks: 2 },
|
|
];
|
|
expect(romTargetBleedCount(timed, "rogue", "mob")).toBe(2);
|
|
});
|
|
|
|
it("gives Wound Attack one hit plus one hit per active bleed, without inventing a DoT", () => {
|
|
expect(kinds(cast("rom-rogue-primary-2"))).toEqual(["damage"]);
|
|
expect(kinds(cast("rom-rogue-primary-2", { bleedCount: 1 }))).toEqual(["damage", "damage"]);
|
|
expect(kinds(cast("rom-rogue-primary-2", { bleedCount: 2 }))).toEqual(["damage", "damage", "damage"]);
|
|
expect(kinds(cast("rom-rogue-primary-1"))).toEqual(["damage"]);
|
|
expect(kinds(cast("rom-rogue-primary-1", { bleedCount: 1 }))).toEqual(["damage", "dot"]);
|
|
});
|
|
|
|
it("selects exactly one Electric Explosion hit for normal and Charged casts", () => {
|
|
const normal = cast("rom-mage-primary-54");
|
|
const charged = cast("rom-mage-primary-54", { charged: true });
|
|
expect(kinds(normal)).toEqual(["damage"]);
|
|
expect(kinds(charged)).toEqual(["damage"]);
|
|
expect((charged[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints)
|
|
.toBeGreaterThan((normal[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints ?? 0);
|
|
});
|
|
|
|
it("requires Holy Seals for Punishment and scales its payoff by stack count", () => {
|
|
expect(cast("rom-knight-general-0")).toEqual([]);
|
|
const one = cast("rom-knight-general-0", { sealStacks: 1 });
|
|
const four = cast("rom-knight-general-0", { sealStacks: 4 });
|
|
expect(kinds(one)).toEqual(["damage", "combo"]);
|
|
expect((four[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints)
|
|
.toBe(4 * ((one[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints ?? 0));
|
|
});
|
|
|
|
it("gates Knight seal riders and grants Shield Bash's absorb to the caster", () => {
|
|
expect(kinds(cast("rom-knight-primary-7"))).toEqual(["damage", "apply-aura"]);
|
|
expect(kinds(cast("rom-knight-primary-7", { sealStacks: 4 })).filter((kind) => kind === "damage")).toHaveLength(2);
|
|
expect(kinds(cast("rom-knight-primary-12"))).toEqual(["damage", "apply-aura"]);
|
|
expect(kinds(cast("rom-knight-primary-12", { sealStacks: 4 })).filter((kind) => kind === "damage")).toHaveLength(2);
|
|
const bash = cast("rom-knight-primary-11", { sealStacks: 3 });
|
|
const shield = bash.find((effect) => effect.kind === "apply-aura");
|
|
expect(shield?.kind === "apply-aura" && shield.recipient).toBe("caster");
|
|
expect(shield?.kind === "apply-aura" && shield.aura.absorbs?.[0]?.amount).toBe(300);
|
|
expect(kinds(bash)).toEqual(["damage", "apply-aura", "combo"]);
|
|
});
|
|
|
|
it("requires a dagger for Shadowstab and Sneak Attack bleeds", () => {
|
|
expect(kinds(cast("rom-rogue-general-0"))).toEqual(["damage"]);
|
|
expect(kinds(cast("rom-rogue-general-0", { mainHandDagger: true }))).toEqual(["damage", "dot"]);
|
|
expect(kinds(cast("rom-rogue-primary-9"))).toEqual(["damage", "apply-aura"]);
|
|
expect(kinds(cast("rom-rogue-primary-9", { mainHandDagger: true }))).toEqual(["damage", "apply-aura", "dot"]);
|
|
});
|
|
|
|
it("requires an active Vulnerable target for Keen Attack", () => {
|
|
const keenAttackId = "rom-warrior-rom-rogue-elite-35";
|
|
expect(cast(keenAttackId)).toEqual([]);
|
|
expect(kinds(cast(keenAttackId, { targetTags: ["rom-vulnerable"] }))).toContain("damage");
|
|
});
|
|
|
|
it("gates Open Flank on Vulnerable and Opportunity on the resulting Weakened state", () => {
|
|
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
|
|
expect(romAbilityRequiredTargetStateTag(sourceAbility(491136))).toBe("rom-vulnerable");
|
|
expect(romAbilityRequiredTargetStateTag(sourceAbility(492617))).toBe("rom-weakened");
|
|
expect(cast("rom-warrior-primary-2")).toEqual([]);
|
|
const openFlank = cast("rom-warrior-primary-2", { targetTags: ["rom-vulnerable"] });
|
|
expect(openFlank.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.utilityTags?.includes("rom-weakened"))).toBe(true);
|
|
expect(cast("rom-warrior-rom-priest-elite-35")).toEqual([]);
|
|
expect(kinds(cast("rom-warrior-rom-priest-elite-35", { targetTags: ["rom-weakened"] })))
|
|
.toContain("damage");
|
|
});
|
|
|
|
it("only applies Tactical Attack's Slash bleed when a Slash bleed is active", () => {
|
|
expect(kinds(cast("rom-warrior-primary-3"))).toEqual(["damage"]);
|
|
expect(kinds(cast("rom-warrior-primary-3", { slashBleed: true }))).toEqual(["damage", "dot"]);
|
|
const effects = [
|
|
{ kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "target", remainingTicks: 1 },
|
|
{ kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "other", remainingTicks: 3 },
|
|
{ kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "target", remainingTicks: 0 },
|
|
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "warrior", targetId: "target", remainingTicks: 3 },
|
|
];
|
|
expect(romTargetHasSlashBleed(effects, "target")).toBe(true);
|
|
expect(romTargetHasSlashBleed(effects.slice(1), "target")).toBe(false);
|
|
});
|
|
|
|
it("applies explicit Warrior passive Rage and cooldown reductions to their source skills", () => {
|
|
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
|
|
expect(romAbilityNeedsTwoHand(sourceAbility(491142))).toBe(true);
|
|
expect(romWarriorAbilityAdjustment(sourceAbility(490121), [491458]))
|
|
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 20_000 });
|
|
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [493248]))
|
|
.toEqual({ rageCostReduction: 20, cooldownReductionMs: 10_000 });
|
|
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [499491]))
|
|
.toEqual({ rageCostReduction: 25, cooldownReductionMs: 15_000 });
|
|
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [494377]))
|
|
.toEqual({ rageCostReduction: 0, cooldownReductionMs: 0 });
|
|
expect(romWarriorAbilityAdjustment(sourceAbility(490121), [])).toEqual({
|
|
rageCostReduction: 0, cooldownReductionMs: 0,
|
|
});
|
|
expect(romWarriorAbilityAdjustment(sourceAbility(490053), [499827]))
|
|
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 0 });
|
|
});
|
|
|
|
it("removes only the four retired Holy Seal DoTs and applies CoA Probing Attack's conditional weapon-DPS value", () => {
|
|
for (const spellId of [490148, 490149, 490150, 490151, 499909]) {
|
|
const ability = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId)!;
|
|
const resolved = romEffectsForCast(ability, spellId === 490151 || spellId === 499909 ? { ...empty, sealStacks: 4 } : empty);
|
|
expect(resolved.some((effect) => effect.kind === "dot")).toBe(false);
|
|
expect(resolved.some((effect) => effect.kind === "damage")).toBe(true);
|
|
if (spellId === 490148 || spellId === 490150) {
|
|
expect(resolved.some((effect) => effect.kind === "combo")).toBe(true);
|
|
}
|
|
}
|
|
|
|
const punishment = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === 499909)!;
|
|
const atTwoSeals = romEffectsForCast(punishment, { ...empty, sealStacks: 2 })
|
|
.find((effect) => effect.kind === "damage");
|
|
const atFourSeals = romEffectsForCast(punishment, { ...empty, sealStacks: 4 })
|
|
.find((effect) => effect.kind === "damage");
|
|
expect(atTwoSeals?.kind).toBe("damage");
|
|
expect(atFourSeals?.kind).toBe("damage");
|
|
if (atTwoSeals?.kind === "damage" && atFourSeals?.kind === "damage") {
|
|
expect(atTwoSeals.coefficient).toBeCloseTo(atFourSeals.coefficient / 2);
|
|
}
|
|
expect(romEffectsForCast(punishment, { ...empty, sealStacks: 0 })).toEqual([]);
|
|
|
|
const probing = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === 493283)!;
|
|
const base = romEffectsForCast(probing, empty).find((effect) => effect.kind === "damage");
|
|
const bleedEmpowered = romEffectsForCast(probing, { ...empty, bleedCount: 1 })
|
|
.find((effect) => effect.kind === "damage");
|
|
const hammerEmpowered = romEffectsForCast(probing, { ...empty, mainHandTwoHandHammer: true })
|
|
.find((effect) => effect.kind === "damage");
|
|
expect(base).toMatchObject({ kind: "damage", weaponDpsCoefficient: 6.643 });
|
|
expect(bleedEmpowered).toMatchObject({ kind: "damage", weaponDpsCoefficient: 11.169 });
|
|
expect(hammerEmpowered).toMatchObject({ kind: "damage", weaponDpsCoefficient: 11.169 });
|
|
});
|
|
|
|
it("applies Warrior/Warlock's physical-damage passive and Heroic Surprise Attack's on-hit payload", () => {
|
|
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
|
|
const rageBlow = sourceAbility(498706);
|
|
const baseDamage = romEffectsForCast(rageBlow, empty).find((effect) => effect.kind === "damage");
|
|
const trainedDamage = romEffectsForCast(rageBlow, {
|
|
...empty, learnedPassiveSpellIds: [499806],
|
|
}).find((effect) => effect.kind === "damage");
|
|
expect(baseDamage?.kind).toBe("damage");
|
|
expect(trainedDamage?.kind).toBe("damage");
|
|
if (baseDamage?.kind === "damage" && trainedDamage?.kind === "damage") {
|
|
expect(baseDamage.school).toBe("physical");
|
|
expect(trainedDamage.coefficient).toBeCloseTo(baseDamage.coefficient * 1.2);
|
|
}
|
|
|
|
const surpriseAttack = sourceAbility(490129);
|
|
const heroicEffects = romEffectsForCast(surpriseAttack, {
|
|
...empty, learnedPassiveSpellIds: [492661],
|
|
});
|
|
const heroicHit = heroicEffects.find((effect) => effect.kind === "damage");
|
|
expect(heroicHit?.kind).toBe("damage");
|
|
if (heroicHit?.kind === "damage") {
|
|
expect(heroicHit.onDamageResource).toEqual({ resource: "rage", amount: 25 });
|
|
}
|
|
expect(romWarriorThreatMultiplier(surpriseAttack, [492661])).toBe(5);
|
|
expect(romWarriorThreatMultiplier(surpriseAttack, [])).toBe(1);
|
|
});
|
|
|
|
it("keeps auxiliary source rows from adding extra hits, heals, or false control", () => {
|
|
expect(kinds(cast("rom-priest-general-2"))).toEqual(["hot"]);
|
|
expect(kinds(cast("rom-mage-primary-1"))).toEqual(["damage"]);
|
|
expect(kinds(cast("rom-mage-general-0"))).toEqual(["damage"]);
|
|
expect(kinds(cast("rom-rogue-primary-0"))).toEqual(["apply-aura", "apply-aura"]);
|
|
expect(kinds(cast("rom-scout-general-3"))).toEqual(["damage", "apply-aura"]);
|
|
});
|
|
|
|
it("executes the source count for multi-hit Mage storms", () => {
|
|
expect(kinds(cast("rom-mage-primary-5"))).toEqual(Array(5).fill("damage"));
|
|
expect(kinds(cast("rom-mage-primary-12"))).toEqual([
|
|
"damage", "damage", "damage", "apply-aura", "apply-aura",
|
|
]);
|
|
});
|
|
|
|
it("executes Moon Cleave's three source-authored attacks", () => {
|
|
const effects = cast("rom-warrior-primary-8");
|
|
expect(effects.filter((effect) => effect.kind === "damage")).toHaveLength(3);
|
|
expect(effects.filter((effect) => effect.kind === "scripted")).toHaveLength(1);
|
|
});
|
|
|
|
it("only adds The Final Battle's second hit below 30% target health", () => {
|
|
const atThreshold = cast("rom-warrior-rom-scout-elite-50", { targetHealthPercent: 0.3 });
|
|
const belowThreshold = cast("rom-warrior-rom-scout-elite-50", { targetHealthPercent: 0.29 });
|
|
expect(atThreshold.filter((effect) => effect.kind === "damage")).toHaveLength(1);
|
|
expect(belowThreshold.filter((effect) => effect.kind === "damage")).toHaveLength(2);
|
|
});
|
|
|
|
it("holds Shield of Discipline for 30 seconds and reflects only on being hit", () => {
|
|
const effects = cast("rom-knight-general-7");
|
|
expect(kinds(effects)).toEqual(["apply-aura", "apply-aura", "apply-aura"]);
|
|
const proc = effects.find((effect) => effect.kind === "apply-aura" && effect.aura.procs?.length);
|
|
expect(proc?.kind === "apply-aura" && proc.aura.durationMs).toBe(30_000);
|
|
expect(proc?.kind === "apply-aura" && proc.aura.procs?.[0]?.action).toMatchObject({
|
|
kind: "custom", id: "rom-shield-discipline-reflect",
|
|
});
|
|
});
|
|
|
|
it("uses one breakable control effect for each single-target restraint", () => {
|
|
for (const id of ["rom-scout-primary-4", "rom-mage-primary-56"]) {
|
|
const effects = cast(id);
|
|
expect(effects).toHaveLength(1);
|
|
expect(effects[0]).toMatchObject({ kind: "status", maxTargets: 1, breakOnDamage: true });
|
|
}
|
|
expect(kinds(cast("rom-rogue-primary-5"))).toEqual(["apply-aura"]);
|
|
});
|
|
});
|