Files
healer-man/src/game/romAbilityCatalogSemantics.test.ts
T

500 lines
23 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { AbilityEffect } from "./abilityCatalog";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import generated from "./romPlayerCatalog.generated.json";
function named(name: string) {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.name === name);
if (!ability) throw new Error(`Missing RuneWaker ability: ${name}`);
return ability;
}
function sourceSkill(sourceSkillId: number) {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === sourceSkillId);
if (!ability) throw new Error(`Missing RuneWaker source skill: ${sourceSkillId}`);
return ability;
}
function isMeaningfulEffect(effect: AbilityEffect): boolean {
if (effect.kind === "scripted" || effect.kind === "unsupported") return false;
if (["damage", "heal", "dot", "hot", "shield"].includes(effect.kind)) {
const output = effect as Extract<AbilityEffect, { kind: "damage" | "heal" | "dot" | "hot" | "shield" }>;
return (
output.coefficient > 0
|| (output.coefficientPerLevel ?? 0) > 0
|| (output.kind === "damage" && (output.weaponDpsCoefficient ?? 0) > 0)
|| (output.basePoints ?? 0) > 0
|| (output.pointsPerLevel ?? 0) > 0
|| (output.kind === "heal" && (output.percentMaxHealth ?? 0) > 0)
);
}
if (effect.kind === "apply-aura") {
return Boolean(
effect.control
|| effect.aura.modifiers?.length
|| effect.aura.absorbs?.length
|| effect.aura.procs?.length
|| effect.aura.utilityTags?.length
|| effect.aura.controlImmunities?.length
|| effect.aura.reflectSpellChance
|| effect.aura.redirectDamagePercent,
);
}
return true;
}
describe("executable RuneWaker ability mappings", () => {
it("shows readable Priest / Knight spell descriptions without client string tokens", () => {
const priest = ROM_ALL_ABILITIES.filter((ability) => ability.classId === "rom-priest"
&& (!ability.secondaryClassId || ability.secondaryClassId === "rom-knight"));
for (const ability of priest) {
for (const description of [ability.description, ...(ability.ranks ?? []).map((rank) => rank.description)]) {
expect(description, ability.name).not.toMatch(/\([^)]*(?:Buff\d*|DMG\d*)[^)]*\)|\[SC_SKILLTIPS_[^\]]+\]|\[\d{6}\]/i);
}
}
});
it("maps Taunt, Shout, and Cleanse to their real combat mechanics", () => {
expect(named("Taunt").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "taunt" }),
]));
expect(named("Shout").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", control: { kind: "stun" } }),
]));
expect(named("Shout")).toMatchObject({
target: "self",
radius: 10,
targeting: { shape: "circle", radius: 10 },
cooldownMs: 40_000,
cost: { resource: "rage", amount: 15 },
});
expect(named("Cleanse").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", mode: "cleanse", relationship: "friendly" }),
]));
});
it("keeps Priest and Priest/Scout support casts on friendly targets", () => {
for (const name of ["Grace of Life", "Amplified Attack"]) {
const ability = named(name);
expect(ability.target, name).toBe("friendly");
expect(ability.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", recipient: "ability-target" }),
]));
}
const curingShot = named("Curing Shot");
expect(curingShot.target).toBe("friendly");
expect(curingShot.effects.map((effect) => effect.kind)).toEqual(expect.arrayContaining(["heal", "hot"]));
expect(curingShot.effects.some((effect) => effect.kind === "dot")).toBe(false);
});
it("fans Priest-secondary class buffs out to the described party recipients", () => {
for (const sourceId of [491335, 498649]) {
const auras = sourceSkill(sourceId).effects.filter((effect) => effect.kind === "apply-aura");
expect(auras.length, `source ${sourceId}`).toBeGreaterThan(0);
expect(auras.every((effect) => effect.scope === "party"), `source ${sourceId}`).toBe(true);
}
const salvation = sourceSkill(498719).effects.filter((effect) => effect.kind === "apply-aura");
expect(salvation.length).toBeGreaterThan(0);
expect(salvation.every((effect) => effect.scope === "party" && effect.excludeCaster)).toBe(true);
const advance = sourceSkill(498722).effects.filter((effect) => effect.kind === "apply-aura");
const partyBuffs = advance.filter((effect) => effect.scope === "party");
const casterPenalties = advance.filter((effect) => effect.scope !== "party");
expect(partyBuffs.some((effect) => effect.aura.modifiers?.some((modifier) => (
modifier.kind === "damage" && modifier.direction === "dealt" && modifier.value > 0
)))).toBe(true);
expect(casterPenalties.some((effect) => effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && ["armor", "magical-defense"].includes(modifier.stat) && modifier.value < 0
)))).toBe(true);
expect(casterPenalties.every((effect) => effect.recipient === "caster")).toBe(true);
});
it("keeps Scout/Knight Healing Shot damage and lowest-max-health party heal separate", () => {
const healingShot = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-scout-rom-knight-elite-20")!;
expect(healingShot).toMatchObject({ target: "hostile" });
expect(healingShot.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage" }),
expect.objectContaining({
kind: "heal", scope: "party", maximumTargets: 1, selection: "lowest-max-health",
}),
]));
});
it("requires Nature's Power for Druid/Scout Group Exorcism", () => {
const exorcism = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-druid-rom-scout-elite-30")!;
expect(exorcism).toMatchObject({ target: "self", targeting: { shape: "circle", radius: 5 } });
expect(exorcism.costs).toEqual(expect.arrayContaining([
expect.objectContaining({ resource: "nature-power", amount: 1 }),
]));
expect(exorcism.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", mode: "cleanse", relationship: "friendly" }),
]));
});
it("roots nearby enemies with Knight Shackles of Light", () => {
const shackles = named("Shackles of Light");
expect(shackles).toMatchObject({ target: "self", radius: 10 });
expect(shackles.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", control: { kind: "root" } }),
]));
});
it("maps Scout Cripple Joint to its 55% Joint Blow root proc", () => {
const crippleJoint = sourceSkill(491173);
expect(crippleJoint.passive).toBe(true);
expect(crippleJoint.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
utilityTags: ["rom-cripple-joint"],
procs: [{
id: "rom-cripple-joint-root",
triggers: ["hit", "crit"],
chance: 0.55,
action: { kind: "custom", id: "rom-cripple-joint" },
}],
}),
}),
]);
expect(sourceSkill(490420)).toMatchObject({ name: "Joint Blow", dbcSpellId: 490420 });
});
it("uses Neck Strike's sourced six-second NPC stun instead of an unsupported ten seconds", () => {
expect(sourceSkill(490438).effects).toEqual([{
kind: "status",
status: "stun",
durationMs: 6_000,
maxTargets: 1,
breakOnDamage: true,
}]);
});
it("gives Champion nearby and fan spells their source-shaped target areas", () => {
const bySpellId = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
expect(bySpellId(498481)).toMatchObject({ target: "self", radius: 10 });
expect(bySpellId(498537)).toMatchObject({ target: "self", radius: 7.5 });
expect(bySpellId(498721)).toMatchObject({
target: "self",
radius: 10,
targeting: { shape: "circle", radius: 10 },
});
expect(bySpellId(498534)).toMatchObject({
target: "self",
radius: 10,
targeting: { shape: "cone", radius: 10, attackArcRadians: Math.PI / 2 },
});
});
it("restores Rune Pulse Rage on the Champion after its hostile hit", () => {
const runePulse = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498529)!;
expect(runePulse.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({ modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "resource", resource: "rage", property: "regeneration" }),
]) }),
}),
]));
});
it("puts Scout/Mage Aggro Transfer mana regeneration on the selected ally", () => {
const aggroTransfer = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493255)!;
expect(aggroTransfer.target).toBe("friendly");
expect(aggroTransfer.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "ability-target",
aura: expect.objectContaining({ modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "resource", resource: "mana", property: "regeneration" }),
]) }),
}),
]));
});
it("preserves Druid elite healing and chain targeting semantics", () => {
const bySpellId = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
const arrows = bySpellId(494021);
expect(arrows).toMatchObject({ target: "friendly", targeting: { shape: "circle", radius: 20, maximumTargets: 5 } });
expect(arrows.effects.map((effect) => effect.kind)).toEqual(["hot", "hot"]);
const arrowHots = arrows.effects.filter((effect): effect is Extract<typeof effect, { kind: "hot" }> => effect.kind === "hot");
expect(arrowHots).toHaveLength(2);
expect(arrowHots[0]!.basePoints).toBeCloseTo(23.6);
expect(arrowHots[1]!.basePoints).toBeCloseTo(18.88);
const earthChain = bySpellId(494025);
expect(earthChain).toMatchObject({ targeting: { shape: "chain", maximumTargets: 3, jumpRange: 6 } });
expect(earthChain.effects.filter((effect) => effect.kind === "damage")).toEqual([
expect.objectContaining({ kind: "damage", falloffPerJump: 0.7 }),
expect.objectContaining({ kind: "damage", falloffPerJump: 0.7 }),
]);
const ripple = bySpellId(494323);
expect(ripple).toMatchObject({ targeting: { shape: "circle", radius: 6, maximumTargets: 3 } });
expect(ripple.effects.filter((effect) => effect.kind === "heal")).toEqual([
expect.objectContaining({ falloffPerJump: 0.5 }),
expect.objectContaining({ falloffPerJump: 0.5 }),
]);
expect(bySpellId(493540).targeting?.shape).toBe("line");
expect(bySpellId(494217)).toMatchObject({ target: "self", targeting: { shape: "circle", radius: 5 } });
expect(bySpellId(494217).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", relationship: "friendly", mode: "cleanse" }),
]));
expect(bySpellId(494027)).toMatchObject({ target: "friendly", targeting: { shape: "circle", radius: 6, maximumTargets: 3 } });
expect(bySpellId(494026).targeting).toMatchObject({ shape: "circle", radius: 12 });
});
it("models Champion Overrule as control immunity instead of applying control", () => {
const overrule = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498570)!;
expect(overrule.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: 3_000,
controlImmunities: expect.arrayContaining(["stun", "root", "slow"]),
}),
}),
]));
expect(overrule.effects.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false);
});
it("models Free Will as a 10-second Root and Slow immunity for its friendly target", () => {
const freeWill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491351)!;
expect(freeWill).toMatchObject({ target: "friendly" });
expect(freeWill.effects).toEqual([expect.objectContaining({
kind: "apply-aura",
recipient: "ability-target",
aura: expect.objectContaining({
disposition: "buff",
durationMs: 10_000,
controlImmunities: ["root", "slow"],
}),
})]);
expect(freeWill.effects.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false);
});
it("applies Fire Ward to the Priest's party", () => {
expect(named("Fire Ward").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", scope: "party" }),
]));
});
it("fans out Warden Crystalline Protection and Acceleration within their source radius", () => {
const protection = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498201)!;
const acceleration = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498202)!;
for (const ability of [protection, acceleration]) {
expect(ability).toMatchObject({
target: "self",
radius: 7.5,
targeting: { shape: "circle", radius: 7.5 },
});
expect(ability.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", scope: "party" }),
]));
}
expect(protection.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
scope: "party",
aura: expect.objectContaining({ absorbs: expect.arrayContaining([expect.any(Object)]) }),
}),
]));
expect(acceleration.effects.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []))
.toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "attack-speed" }),
expect.objectContaining({ kind: "stat", stat: "spell-haste" }),
]));
});
it("applies Coat of Arms defenses to both the caster and selected ally", () => {
const coatOfArms = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494611)!;
expect(coatOfArms.target).toBe("friendly");
expect(coatOfArms.effects).toHaveLength(2);
expect(coatOfArms.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: 20_000,
modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "armor", operation: "percent", value: 0.3 }),
expect.objectContaining({ kind: "stat", stat: "magical-defense", operation: "percent", value: 0.3 }),
]),
}),
}),
expect.objectContaining({ kind: "apply-aura", recipient: "ability-target" }),
]);
});
it("retains every executable part of multi-effect abilities", () => {
const kinds = named("Sneak Attack").effects.map((effect) => effect.kind);
expect(kinds).toEqual(["damage", "apply-aura", "dot"]);
});
it("executes one projectile hit for Rogue Throw and Scout Shot", () => {
for (const name of ["Throw", "Shot"]) {
const ability = named(name);
expect(ability.effects).toHaveLength(1);
expect(ability.effects[0]).toMatchObject({ kind: "damage" });
for (const version of ability.ranks ?? []) {
expect(version.effects).toHaveLength(1);
expect(version.effects[0]).toMatchObject({ kind: "damage" });
}
}
});
it("implements source-authored immunity and absorbs as named auras", () => {
const holyAura = named("Holy Aura").effects.find((effect) => effect.kind === "apply-aura");
expect(holyAura).toMatchObject({
aura: {
disposition: "buff",
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -1 }],
},
});
const electrostatic = named("Electrostatic Charge").effects.find((effect) => (
effect.kind === "apply-aura" && (effect.aura.absorbs?.length ?? 0) > 0
));
expect(electrostatic).toMatchObject({
aura: { disposition: "buff", absorbs: [expect.objectContaining({ amount: 30 })] },
});
});
it("never substitutes a generic mark or legacy shield for a RuneWaker mechanic", () => {
const effects = ROM_ALL_ABILITIES.flatMap((ability) => ability.effects);
expect(effects.some((effect) => effect.kind === "status" && effect.status === "marked")).toBe(false);
expect(effects.some((effect) => effect.kind === "shield")).toBe(false);
expect(generated.skills.some((ability) => ["shield", "status", "utility", "cleanse"].includes(ability.effect.kind))).toBe(false);
});
it("executes standard elite cleanses, movement, conversion, and triggered spells", () => {
expect(named("Antidote").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", categories: ["poison"] }),
]));
expect(named("Spirit Jump").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "pull", toward: "caster" }),
]));
expect(named("Essence Extraction").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "resource-drain", resource: "mana", amount: 10, percentage: true }),
expect.objectContaining({ kind: "resource", resource: "nature-power", amount: 4 }),
]));
expect(named("Spreading Pain").effects.filter((effect) => effect.kind === "trigger-spell")).toHaveLength(3);
});
it("gives every active base skill and playable rank a concrete runtime effect", () => {
const generatedSkillIds = new Set(generated.skills.map((skill) => skill.id));
const runtimeOnly = ROM_ALL_ABILITIES.filter((ability) => !generatedSkillIds.has(ability.id));
const generatedRankCount = generated.skills.reduce((count, skill) => count + skill.ranks.length, 0);
// Snapshot accounting is source-backed: 932 generated rows carry 937
// source ranks. Four explicit runtime additions have no separate ranks:
// Devotion Halo, CoA Blocking Stance, CoA Probing Attack, and CoA Enraged.
expect(generated.skills).toHaveLength(932);
expect(generatedRankCount).toBe(937);
expect(ROM_ALL_ABILITIES).toHaveLength(936);
expect(runtimeOnly.map((ability) => ability.id).sort()).toEqual([
"rom-priest-rom-knight-elite-70-devotion-halo",
"rom-warlock-rom-warrior-general-coa-499871",
"rom-warrior-rom-knight-elite-50-coa-493287",
"rom-warrior-rom-rogue-elite-50-coa-493283",
]);
expect(runtimeOnly.every((ability) => ability.ranks?.length === 0 || ability.ranks === undefined)).toBe(true);
const versions = ROM_ALL_ABILITIES.flatMap((ability) => [
{ name: ability.name, rank: 0, passive: ability.passive, effects: ability.effects },
...(ability.ranks ?? []).map((rank) => ({
name: ability.name,
rank: rank.rank,
passive: ability.passive,
effects: rank.effects,
})),
]);
expect(versions).toHaveLength(1_873);
const scriptOnly = ROM_ALL_ABILITIES
.filter((ability) => !ability.passive && ability.effects.every((effect) => effect.kind === "scripted"))
.map((ability) => ability.name)
.sort();
expect(scriptOnly).toEqual([]);
for (const version of versions.filter((entry) => !entry.passive)) {
expect(
version.effects.some(isMeaningfulEffect),
`${version.name} rank ${version.rank}`,
).toBe(true);
}
});
it("implements source state for RuneWaker's active transformations", () => {
expect(named("Frost Arrow").effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
utilityTags: ["rom-frost-arrow"],
procs: [expect.objectContaining({ action: { kind: "custom", id: "rom-frost-arrow" } })],
}),
}),
]));
expect(named("Electric Compression").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ utilityTags: ["rom-static-field-charge"] }) }),
]));
expect(named("Entling Offering").effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
utilityTags: ["rom-entling-offering"],
procs: [expect.objectContaining({ action: { kind: "custom", id: "rom-entling-offering" } })],
}),
}),
]));
expect(named("Holy Salvation Candle").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ utilityTags: ["rom-holy-salvation-candle"] }) }),
]));
expect(named("Remodeled Body").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ utilityTags: ["rom-remodeled-body"] }) }),
]));
expect(named("Revival").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "utility", action: "class-script" }),
]));
});
it("marks Briar Shield as the source prerequisite for Thorn Arrow", () => {
const briarShield = sourceSkill(493642);
expect(briarShield.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
durationMs: 900_000,
modifiers: [{ kind: "stat", stat: "armor", operation: "percent", value: 0.05 }],
utilityTags: expect.arrayContaining(["rom-briar-shield"]),
}),
}),
]));
expect(briarShield.effects.filter((effect) => effect.kind === "damage")).toHaveLength(0);
expect(sourceSkill(494037).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage" }),
]));
});
it("models the Knight's Holy Seal build-and-spend loop", () => {
expect(named("Holy Strike").effects).toEqual(expect.arrayContaining([
{ kind: "combo", amount: 1 },
]));
expect(named("Holy Seal").effects).toEqual(expect.arrayContaining([
{ kind: "combo", amount: 4 },
]));
expect(named("Charge").effects).toEqual(expect.arrayContaining([
{ kind: "combo", amount: 2 },
]));
expect(named("Punishment").effects).toEqual(expect.arrayContaining([
{ kind: "combo", amount: -5 },
]));
expect(named("Holy Strike").effects.filter((effect) => effect.kind === "dot")).toHaveLength(1);
expect(named("Holy Seal").effects.filter((effect) => effect.kind === "dot")).toHaveLength(1);
expect(named("Punishment").effects.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(named("Punishment").effects.filter((effect) => effect.kind === "dot")).toHaveLength(0);
expect(sourceSkill(499909).effects).toEqual(expect.arrayContaining([
{ kind: "combo", amount: -5 },
expect.objectContaining({ kind: "damage" }),
]));
expect(sourceSkill(499909).effects.filter((effect) => effect.kind === "dot")).toHaveLength(0);
});
});