forked from phenom/healer-man
218 lines
12 KiB
TypeScript
218 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { COA_CLASS_IDS, type CoaClassId } from "../app/characterCatalog";
|
|
import type { AbilityDefinition, AbilityEffect, AbilityRankDefinition } from "./abilityCatalog";
|
|
import {
|
|
COA_ABILITIES_BY_CLASS,
|
|
COA_CLASS_RESOURCES,
|
|
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
|
} from "./coaAbilityCatalog";
|
|
import {
|
|
COA_PROGRESSION_CHAINS,
|
|
COA_TREE_ENTRIES,
|
|
coaClassIdFromDbcId,
|
|
coaSpellById,
|
|
} from "./coaLiveCatalog";
|
|
|
|
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
|
|
|
|
it("keeps Drakkari Barrier friendly and its retaliation in the damage-taken proc", () => {
|
|
const barrier = COA_ABILITIES_BY_CLASS["witch-doctor"].find((ability) => ability.name === "Drakkari Barrier")!;
|
|
expect(barrier.target).toBe("friendly");
|
|
for (const rank of barrier.ranks ?? []) {
|
|
expect(rank.effects.some((effect) => effect.kind === "damage")).toBe(false);
|
|
expect(rank.effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.procs?.some((proc) => proc.triggers === "damage-taken" && proc.action.kind === "damage"))).toBe(true);
|
|
}
|
|
});
|
|
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
|
|
rank: 1,
|
|
spellId: ability.dbcSpellId,
|
|
level: ability.unlockLevel,
|
|
description: ability.description,
|
|
castMode: ability.castMode,
|
|
castTimeMs: ability.castTimeMs,
|
|
cooldownMs: ability.cooldownMs,
|
|
gcdMs: ability.gcdMs,
|
|
cost: ability.cost,
|
|
effects: ability.effects,
|
|
}];
|
|
const ranks = abilities.flatMap((ability) => ranksFor(ability).map((rank) => ({ ability, rank })));
|
|
const byName = (classId: CoaClassId, name: string): AbilityDefinition => {
|
|
const ability = COA_ABILITIES_BY_CLASS[classId].find((candidate) => candidate.name === name);
|
|
if (!ability) throw new Error(`Missing ${classId} ability ${name}`);
|
|
return ability;
|
|
};
|
|
const rankEffectsForSpell = (spellId: number): readonly AbilityEffect[] => ranks
|
|
.find(({ rank }) => rank.spellId === spellId)?.rank.effects ?? [];
|
|
|
|
describe("Ascension Conquest ability catalog", () => {
|
|
it("keeps Rejuvenating Venom healing in its cast proc", () => {
|
|
const venom = byName("venomancer", "Rejuvenating Venom");
|
|
expect(venom.effects.some(effect => effect.kind === "heal")).toBe(false);
|
|
expect(venom.effects.some(effect => effect.kind === "apply-aura"
|
|
&& effect.aura.procs?.some(proc => proc.action.kind === "custom" && proc.action.data?.spellId === 630869))).toBe(true);
|
|
const payload = COA_TRIGGERED_ABILITIES_BY_SPELL_ID[630869];
|
|
expect(payload.effects.find(effect => effect.kind === "hot")).toMatchObject({ scope: "party", radius: 30, maximumTargets: 1 });
|
|
});
|
|
|
|
it("covers every class, source family, and playable rank", () => {
|
|
expect(Object.keys(COA_ABILITIES_BY_CLASS).sort()).toEqual([...COA_CLASS_IDS].sort());
|
|
expect(Object.keys(COA_CLASS_RESOURCES).sort()).toEqual([...COA_CLASS_IDS].sort());
|
|
expect(abilities).toHaveLength(1_275);
|
|
expect(ranks).toHaveLength(3_394);
|
|
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_347);
|
|
|
|
const runtimeSpellIds = new Set(ranks.map(({ rank }) => rank.spellId));
|
|
for (const chain of COA_PROGRESSION_CHAINS) {
|
|
const classId = coaClassIdFromDbcId(chain.classId);
|
|
expect(classId, chain.name).not.toBeNull();
|
|
for (const rank of chain.ranks) {
|
|
expect(runtimeSpellIds.has(rank.spellId), `${classId}: ${chain.name} rank ${rank.rank}`).toBe(true);
|
|
}
|
|
}
|
|
for (const entry of COA_TREE_ENTRIES.filter((candidate) => candidate.entryType === "Ability")) {
|
|
const classId = coaClassIdFromDbcId(entry.classId);
|
|
if (!classId) continue;
|
|
for (const rank of entry.rankDescriptions) {
|
|
expect(runtimeSpellIds.has(rank.spellId), `${classId}: ${entry.name} rank ${rank.rank}`).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("gives every family and rank finite, executable runtime data", () => {
|
|
expect(new Set(abilities.map((ability) => ability.id)).size).toBe(abilities.length);
|
|
for (const ability of abilities) {
|
|
expect(ability.classId, ability.id).toBeTruthy();
|
|
expect(ability.name.length, ability.id).toBeGreaterThan(0);
|
|
expect(ability.icon.startsWith("/assets/ui/spells/"), ability.id).toBe(true);
|
|
expect(Number.isFinite(ability.range.min), ability.id).toBe(true);
|
|
expect(Number.isFinite(ability.range.max), ability.id).toBe(true);
|
|
expect(ability.range.max, ability.id).toBeGreaterThanOrEqual(ability.range.min);
|
|
expect(ability.cooldownMs, ability.id).toBeGreaterThanOrEqual(0);
|
|
for (const rank of ranksFor(ability)) {
|
|
const label = `${ability.id} rank ${rank.rank} (${rank.spellId})`;
|
|
expect(rank.spellId, label).toBeGreaterThan(0);
|
|
expect(rank.level, label).toBeGreaterThan(0);
|
|
expect(rank.description.length, label).toBeGreaterThan(0);
|
|
expect(rank.castTimeMs, label).toBeGreaterThanOrEqual(0);
|
|
expect(rank.cooldownMs, label).toBeGreaterThanOrEqual(0);
|
|
expect(rank.gcdMs, label).toBeGreaterThanOrEqual(0);
|
|
expect(Number.isFinite(rank.cost.amount), label).toBe(true);
|
|
expect(rank.cost.amount, label).toBeGreaterThanOrEqual(0);
|
|
expect(rank.effects.length, label).toBeGreaterThan(0);
|
|
expect(rank.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), label).toBe(false);
|
|
if (!ability.passive) {
|
|
expect(rank.effects.some((effect) => effect.kind !== "utility" || effect.action !== "class-script"), label).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("translates every standard DBC combat effect into its executable semantic", () => {
|
|
const applyAuraTypes = new Set([0, 3, 6, 27, 35, 65, 119, 128, 129, 143, 190]);
|
|
const triggerTypes = new Set([32, 64, 140, 141, 142, 148, 151, 160, 165, 169, 175, 178, 183]);
|
|
const movementTypes = new Set([5, 29, 41, 42, 43, 79, 96, 98, 138, 144, 149]);
|
|
for (const { ability, rank } of ranks) {
|
|
const dbc = coaSpellById(rank.spellId)?.dbc;
|
|
if (!dbc) continue;
|
|
const kinds = new Set(rank.effects.map((effect) => effect.kind));
|
|
for (const effect of dbc.effects) {
|
|
const label = `${ability.classId}: ${ability.name} rank ${rank.rank}, DBC ${effect.effectType}/${effect.auraType}`;
|
|
if (effect.effectType === 2 || [17, 31, 58, 121].includes(effect.effectType)) expect(kinds.has("damage"), label).toBe(true);
|
|
if ([10, 67, 136].includes(effect.effectType)) expect(kinds.has("heal"), label).toBe(true);
|
|
if (applyAuraTypes.has(effect.effectType) && effect.auraType > 0) expect(kinds.has("apply-aura"), label).toBe(true);
|
|
if (effect.auraType === 3 && effect.periodMs > 0) expect(kinds.has("dot"), label).toBe(true);
|
|
if ([8, 20].includes(effect.auraType) && effect.periodMs > 0) expect(kinds.has("hot"), label).toBe(true);
|
|
if (triggerTypes.has(effect.effectType) && effect.triggerSpellId > 0) expect(kinds.has("trigger-spell"), label).toBe(true);
|
|
if (movementTypes.has(effect.effectType)) expect(kinds.has("movement"), label).toBe(true);
|
|
if (effect.effectType === 38) expect(kinds.has("dispel"), label).toBe(true);
|
|
if (effect.effectType === 68) expect(kinds.has("interrupt"), label).toBe(true);
|
|
if ([18, 94, 113, 117].includes(effect.effectType)) expect(kinds.has("resurrection"), label).toBe(true);
|
|
if ([192, 195].includes(effect.effectType)) expect(kinds.has("cooldown-reset"), label).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("preserves percentages, actual power pools, and shared cooldown groups", () => {
|
|
const percentageCosts = ranks.filter(({ rank }) => rank.cost.percentage);
|
|
expect(percentageCosts.length).toBeGreaterThan(1_500);
|
|
expect(ranks.some(({ rank }) => rank.cost.resource === "health")).toBe(true);
|
|
expect(ranks.some(({ rank }) => rank.cost.resource === "energy")).toBe(true);
|
|
expect(ranks.some(({ rank }) => rank.cost.resource === "rage")).toBe(true);
|
|
expect(ranks.some(({ rank }) => rank.cost.resource === "runic-power")).toBe(true);
|
|
|
|
const ascension = byName("sun-cleric", "Solar Invocation: Ascension");
|
|
const revelation = byName("sun-cleric", "Solar Invocation: Revelation");
|
|
expect(ascension.cooldownGroup).toBe("coa:solar-invocation-spells");
|
|
expect(revelation.cooldownGroup).toBe(ascension.cooldownGroup);
|
|
expect(revelation.target).toBe("any");
|
|
|
|
const profound = byName("templar", "Profound Enlightenment");
|
|
expect(profound.effects.filter((effect) => effect.kind === "cooldown-reset")).toEqual([
|
|
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
|
|
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
|
|
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
|
|
]);
|
|
});
|
|
|
|
it("keeps representative healing, protection, control, proc, trigger, and reset spells functional", () => {
|
|
expect(byName("sun-cleric", "Solar Invocation: Ascension")).toMatchObject({ target: "self", radius: 40 });
|
|
expect(byName("guardian", "Feats of Strength")).toMatchObject({ target: "friendly" });
|
|
expect(byName("knight-of-xoroth", "Black Shield").ranks?.every((rank) => rank.effects.some((effect) => effect.kind === "shield"))).toBe(true);
|
|
expect(byName("witch-hunter", "Brand of the Unworthy").effects).toContainEqual(expect.objectContaining({ kind: "status", status: "sleep" }));
|
|
expect(byName("necromancer", "Plaguestorm").ranks?.every((rank) => rank.effects.some((effect) => effect.kind === "dot"))).toBe(true);
|
|
expect(byName("templar", "Profound Enlightenment").effects.filter((effect) => effect.kind === "cooldown-reset")).toHaveLength(3);
|
|
expect(byName("sun-cleric", "New Day").effects.filter((effect) => effect.kind === "cooldown-reset")).toHaveLength(3);
|
|
|
|
const gaze = byName("cultist", "Gaze of C'Thun");
|
|
expect(gaze.target).toBe("any");
|
|
expect(gaze.effects.some((effect) => effect.kind === "trigger-spell")).toBe(true);
|
|
expect(gaze.effects.some((effect) => effect.kind === "damage")).toBe(true);
|
|
expect(gaze.effects.some((effect) => effect.kind === "heal")).toBe(true);
|
|
|
|
const razorice = byName("necromancer", "Razorice");
|
|
const procs = razorice.effects
|
|
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
|
.flatMap((effect) => effect.aura.procs ?? []);
|
|
expect(procs.length).toBeGreaterThan(0);
|
|
expect(procs.every((proc) => proc.action.kind === "damage" || proc.action.kind === "custom")).toBe(true);
|
|
});
|
|
|
|
it("keeps every rank spell lookup executable, including recursively expanded trigger spells", () => {
|
|
for (const { ability, rank } of ranks) {
|
|
expect(rankEffectsForSpell(rank.spellId).length, `${ability.name} ${rank.spellId}`).toBeGreaterThan(0);
|
|
}
|
|
const gaze = byName("cultist", "Gaze of C'Thun");
|
|
expect(gaze.effects.filter((effect) => effect.kind === "trigger-spell")).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ spellId: 520334 }),
|
|
expect.objectContaining({ spellId: 520335 }),
|
|
]));
|
|
});
|
|
|
|
it("ships executable hidden payloads for proc-triggered DBC spell IDs", () => {
|
|
// Geode Barrage now uses its ranked parent payload instead of hidden placeholder 803138.
|
|
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(584);
|
|
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[803138]).toBeUndefined();
|
|
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[800951]).toMatchObject({
|
|
name: "Battle Rhythm",
|
|
effects: expect.arrayContaining([{ kind: "resource", amount: 5 }]),
|
|
});
|
|
expect(Object.values(COA_TRIGGERED_ABILITIES_BY_SPELL_ID).filter((ability) => (
|
|
ability.effects.some((effect) => effect.kind !== "utility")
|
|
))).toHaveLength(545);
|
|
for (const [spellId, ability] of Object.entries(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
|
|
expect(ability.dbcSpellId, spellId).toBe(Number(spellId));
|
|
expect(ability.effects.length, spellId).toBeGreaterThan(0);
|
|
expect(ability.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), spellId).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("does not invent damage for proc auras or cooldown-only trigger payloads", () => {
|
|
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[707821].effects.some((effect) => effect.kind === "damage")).toBe(false);
|
|
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[807661].effects.every((effect) => effect.kind === "utility")).toBe(true);
|
|
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[712682].effects).toContainEqual(
|
|
expect.objectContaining({ kind: "trigger-spell", spellId: 712476, expanded: true }),
|
|
);
|
|
});
|
|
});
|