Files
healer-man/src/game/combatAuras.test.ts
T
2026-08-14 15:56:39 -04:00

183 lines
6.6 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
absorbDamage,
applyAura,
dispelAuras,
evaluateAuraProcs,
expireAuras,
resolveDamageValue,
resolveResourceValue,
resolveStatValue,
type ActiveAura,
type AuraDefinition,
} from "./combatAuras";
const buff = (overrides: Partial<AuraDefinition> = {}): AuraDefinition => ({
id: "test-buff",
name: "Test Buff",
disposition: "buff",
durationMs: 5_000,
...overrides,
});
function apply(
definition: AuraDefinition,
now = 0,
current: readonly ActiveAura[] = [],
stacks = 1,
): readonly ActiveAura[] {
return applyAura(current, {
definition,
sourceId: "caster",
targetId: "player",
now,
stacks,
}).auras;
}
describe("combat auras", () => {
it("adds stacks to the cap and refreshes the duration", () => {
const definition = buff({ maxStacks: 3, stackBehavior: "add" });
let auras = apply(definition, 100, [], 2);
auras = apply(definition, 1_000, auras, 2);
expect(auras).toHaveLength(1);
expect(auras[0]).toMatchObject({ stacks: 3, appliedAt: 1_000, expiresAt: 6_000 });
});
it("refreshes without adding stacks and replaces when requested", () => {
const refreshing = buff({ maxStacks: 4, stackBehavior: "refresh" });
let auras = apply(refreshing, 0, [], 2);
auras = apply(refreshing, 500, auras, 1);
expect(auras[0]).toMatchObject({ stacks: 2, expiresAt: 5_500 });
const replacing = buff({ maxStacks: 4, stackBehavior: "replace" });
auras = apply(replacing, 1_000, auras, 1);
expect(auras[0]).toMatchObject({ stacks: 1, appliedAt: 1_000, expiresAt: 6_000 });
});
it("applies typed stat, resource, and damage modifiers", () => {
const auras = apply(buff({
maxStacks: 2,
modifiers: [
{ kind: "stat", stat: "strength", operation: "flat", value: 5 },
{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: -0.1 },
{ kind: "damage", direction: "dealt", school: "fire", operation: "percent", value: 0.2, perStack: false },
],
}), 0, [], 2);
expect(resolveStatValue(10, auras, "strength")).toBe(20);
expect(resolveResourceValue(100, auras, "mana", "cost")).toBe(80);
expect(resolveDamageValue(100, auras, { direction: "dealt", school: "fire", attackKind: "spell" })).toBe(120);
expect(resolveDamageValue(100, auras, { direction: "dealt", school: "frost", attackKind: "spell" })).toBe(100);
});
it("selectively dispels debuffs for allies and buffs for hostile purges", () => {
const magicDebuff = buff({ id: "magic-dot", disposition: "debuff", dispelCategory: "magic" });
const poisonDebuff = buff({ id: "poison", disposition: "debuff", dispelCategory: "poison" });
const magicBuff = buff({ id: "power", dispelCategory: "magic" });
let auras: readonly ActiveAura[] = [];
auras = apply(magicDebuff, 0, auras);
auras = apply(poisonDebuff, 1, auras);
auras = apply(magicBuff, 2, auras);
const cleanse = dispelAuras(auras, { relationship: "friendly", categories: ["magic"] });
expect(cleanse.removed.map((aura) => aura.definition.id)).toEqual(["magic-dot"]);
expect(cleanse.auras.map((aura) => aura.definition.id)).toEqual(["poison", "power"]);
const purge = dispelAuras(cleanse.auras, { relationship: "hostile", categories: ["magic"] });
expect(purge.removed.map((aura) => aura.definition.id)).toEqual(["power"]);
expect(purge.auras.map((aura) => aura.definition.id)).toEqual(["poison"]);
});
it("expires finite auras while retaining permanent ones", () => {
let auras: readonly ActiveAura[] = [];
auras = apply(buff({ id: "short", durationMs: 1_000 }), 100, auras);
auras = apply(buff({ id: "permanent", durationMs: null }), 100, auras);
const atBoundary = expireAuras(auras, 1_100);
expect(atBoundary.expired.map((aura) => aura.definition.id)).toEqual(["short"]);
expect(atBoundary.active.map((aura) => aura.definition.id)).toEqual(["permanent"]);
});
it("consumes only matching absorbs and reports damage left over", () => {
let auras: readonly ActiveAura[] = [];
auras = apply(buff({
id: "wards",
absorbs: [
{ id: "fire-ward", amount: 30, school: "fire" },
{ id: "barrier", amount: 25 },
],
}), 0, auras);
const result = absorbDamage(auras, {
targetId: "player",
amount: 70,
school: "fire",
now: 100,
});
expect(result).toMatchObject({ absorbed: 55, remainingDamage: 15 });
expect(result.consumptions.map(({ absorbId, amount }) => [absorbId, amount])).toEqual([
["fire-ward", 30],
["barrier", 25],
]);
expect(result.auras[0].absorbs.map((absorb) => absorb.remaining)).toEqual([0, 0]);
});
it("supports guaranteed and failed deterministic proc rolls", () => {
const definition = buff({
procs: [{
id: "spark",
triggers: "hit",
chance: 0.25,
action: { kind: "damage", amount: 10, school: "fire", target: "event-other" },
}],
});
const auras = apply(definition);
const guaranteed = evaluateAuraProcs(auras, {
trigger: "hit",
subjectId: "player",
otherId: "mob",
}, 100, () => 0.24);
expect(guaranteed.triggered).toHaveLength(1);
const failed = evaluateAuraProcs(auras, {
trigger: "hit",
subjectId: "player",
otherId: "mob",
}, 100, () => 0.25);
expect(failed.triggered).toHaveLength(0);
});
it("enforces proc internal cooldowns and consumes charges only on success", () => {
const roll = vi.fn(() => 0);
const definition = buff({
durationMs: 20_000,
procs: [{
id: "echo",
triggers: ["cast", "heal"],
chance: 1,
internalCooldownMs: 1_000,
charges: 2,
action: { kind: "custom", id: "echo-cast" },
}],
});
let auras = apply(definition);
let result = evaluateAuraProcs(auras, { trigger: "cast", subjectId: "player" }, 100, roll);
expect(result.triggered).toHaveLength(1);
expect(result.auras[0].procStates[0]).toMatchObject({ readyAt: 1_100, remainingCharges: 1 });
result = evaluateAuraProcs(result.auras, { trigger: "heal", subjectId: "player" }, 500, roll);
expect(result.triggered).toHaveLength(0);
expect(roll).toHaveBeenCalledTimes(1);
result = evaluateAuraProcs(result.auras, { trigger: "heal", subjectId: "player" }, 1_100, roll);
expect(result.triggered).toHaveLength(1);
expect(result.auras[0].procStates[0].remainingCharges).toBe(0);
result = evaluateAuraProcs(result.auras, { trigger: "cast", subjectId: "player" }, 3_000, roll);
expect(result.triggered).toHaveLength(0);
expect(roll).toHaveBeenCalledTimes(2);
});
});