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 fractional, multi-school absorbs while keeping the capacity shared", () => { const auras = apply(buff({ id: "anti-magic-shell", absorbs: [{ id: "shell", amount: 120, schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"], fraction: 0.75, }], })); const magical = absorbDamage(auras, { targetId: "player", amount: 100, school: "fire", now: 100, }); expect(magical).toMatchObject({ absorbed: 75, remainingDamage: 25 }); expect(magical.auras[0].absorbs[0].remaining).toBe(45); const physical = absorbDamage(magical.auras, { targetId: "player", amount: 40, school: "physical", now: 200, }); expect(physical).toMatchObject({ absorbed: 0, remainingDamage: 40 }); expect(physical.auras[0].absorbs[0].remaining).toBe(45); const capped = absorbDamage(magical.auras, { targetId: "player", amount: 100, school: "shadow", now: 300, }); expect(capped).toMatchObject({ absorbed: 45, remainingDamage: 55 }); }); 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("spends melee-only proc charges only on landed melee attack events", () => { const auras = apply(buff({ procs: [{ id: "melee-charge", triggers: ["hit", "crit"], attackKinds: ["melee"], chance: 1, charges: 5, action: { kind: "custom", id: "melee-only" }, }], })); const rangedHit = evaluateAuraProcs(auras, { trigger: "hit", subjectId: "player", otherId: "mob", attackKind: "ranged", }, 100, () => 0); expect(rangedHit.triggered).toHaveLength(0); expect(rangedHit.auras[0].procStates[0].remainingCharges).toBe(5); const meleeHit = evaluateAuraProcs(rangedHit.auras, { trigger: "hit", subjectId: "player", otherId: "mob", attackKind: "melee", }, 200, () => 0); expect(meleeHit.triggered).toHaveLength(1); expect(meleeHit.auras[0].procStates[0].remainingCharges).toBe(4); }); 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); }); });