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 => ({ 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); }); });