import { describe, expect, it } from "vitest"; import type { ClassId } from "../app/characterCatalog"; import type { AbilityDefinition } from "./abilityCatalog"; import { resolveStatValue, type ActiveAura } from "./combatAuras"; import { coaSpellById, coaTreeEntryById } from "./coaLiveCatalog"; import { applyCoaGuardianParagonEvent, coaGuardianParagonCastVariant, COA_GUARDIAN_PARAGON_BLOCK_ICD_AURA_ID, COA_GUARDIAN_PARAGON_BLOCK_ICD_MS, COA_GUARDIAN_PARAGON_BRACE_COOLDOWN_REDUCTION_MS, COA_GUARDIAN_PARAGON_HEAVY_BLOW_SPELL_ID, COA_GUARDIAN_PARAGON_HEAVY_BLOW_STACKS, COA_GUARDIAN_PARAGON_HEAVY_BLOW_TALENT_ENTRY_ID, COA_GUARDIAN_PARAGON_MAX_STACKS, COA_GUARDIAN_PARAGON_SMASH_ATTACK_POWER_COEFFICIENT, COA_GUARDIAN_PARAGON_SMASH_BASE_DAMAGE, COA_GUARDIAN_PARAGON_SMASH_BLOCK_CHANCE_BONUS, COA_GUARDIAN_PARAGON_SMASH_BLOCK_CHARGES, COA_GUARDIAN_PARAGON_SMASH_HEALTHY_TANK_AI_PRIORITY, COA_GUARDIAN_PARAGON_SMASH_HEALTHY_TANK_THRESHOLD, COA_GUARDIAN_PARAGON_SMASH_BUFF_AURA, COA_GUARDIAN_PARAGON_SMASH_BUFF_AURA_ID, COA_GUARDIAN_PARAGON_SMASH_BUFF_SPELL_ID, COA_GUARDIAN_PARAGON_SMASH_DAMAGE_SPELL_ID, COA_GUARDIAN_PARAGON_SMASH_RADIUS_YARDS, COA_GUARDIAN_PARAGON_SMASH_READY_AURA_ID, COA_GUARDIAN_PARAGON_SMASH_READY_DURATION_MS, COA_GUARDIAN_PARAGON_STACK_DURATION_MS, COA_GUARDIAN_PARAGON_STACKS_AURA_ID, COA_GUARDIAN_PARAGON_TALENT_ENTRY_ID, hasCoaGuardianParagon, projectCoaGuardianParagonAbility, type CoaGuardianParagonActor, } from "./coaGuardianParagonRuntime"; const actor = ( id = "guardian-player", classId: ClassId = "guardian", rank = 1, details: Partial> = {}, ): CoaGuardianParagonActor => ({ id, classId, talentRanks: { [`coa-entry-${COA_GUARDIAN_PARAGON_TALENT_ENTRY_ID}`]: rank }, ...details, }); const heavyBlow: AbilityDefinition = { id: "coa-tree-31169", classId: "guardian", dbcSpellId: COA_GUARDIAN_PARAGON_HEAVY_BLOW_SPELL_ID, name: "Heavy Blow", unlockLevel: 11, icon: "/assets/ui/spells/spell_paladin_hammerofwrath.png", sigil: "HB", description: "Smash the enemy with Heavy Blow.", target: "hostile", range: { min: 0, max: 5 }, castMode: "instant", castTimeMs: 0, cooldownMs: 10_000, gcdMs: 1_500, cost: { resource: "energy", amount: 25 }, effects: [{ kind: "damage", coefficient: 0.37, school: "physical" }], source: "coa-tree", talentEntryId: COA_GUARDIAN_PARAGON_HEAVY_BLOW_TALENT_ENTRY_ID, }; function auraById(auras: readonly ActiveAura[], id: string, actorId = "guardian-player") { return auras.find((aura) => aura.definition.id === id && aura.sourceId === actorId && aura.targetId === actorId); } function readyState(): readonly ActiveAura[] { let auras: readonly ActiveAura[] = []; for (let cast = 0; cast < 3; cast += 1) { auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "cast", ability: heavyBlow, result: "success", }, 1_000 + cast * 1_000).auras; } auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "block" }, 4_000).auras; return auras; } describe("CoA Guardian Paragon runtime", () => { it("matches the August tree grant and linked passive/stack records", () => { expect(coaTreeEntryById(COA_GUARDIAN_PARAGON_TALENT_ENTRY_ID)).toMatchObject({ classId: 18, name: "Paragon", entryType: "Talent", rankDescriptions: [{ rank: 1, spellId: 706514 }], }); expect(coaSpellById(706514)?.dbc?.effects[0]?.triggerSpellId).toBe(706516); expect(coaSpellById(706516)?.dbc).toMatchObject({ durationMs: COA_GUARDIAN_PARAGON_STACK_DURATION_MS, stackAmount: COA_GUARDIAN_PARAGON_MAX_STACKS, }); expect(coaTreeEntryById(COA_GUARDIAN_PARAGON_HEAVY_BLOW_TALENT_ENTRY_ID)) .toMatchObject({ name: "Heavy Blow", rankDescriptions: [{ spellId: COA_GUARDIAN_PARAGON_HEAVY_BLOW_SPELL_ID }] }); expect(COA_GUARDIAN_PARAGON_SMASH_DAMAGE_SPELL_ID).toBe(802629); expect(COA_GUARDIAN_PARAGON_SMASH_BUFF_SPELL_ID).toBe(803432); expect(COA_GUARDIAN_PARAGON_SMASH_BASE_DAMAGE).toBe(144); expect(COA_GUARDIAN_PARAGON_SMASH_ATTACK_POWER_COEFFICIENT).toBe(0.37); expect(COA_GUARDIAN_PARAGON_SMASH_RADIUS_YARDS).toBe(8); }); it("gates Paragon on the learned Guardian talent and keeps auras actor-owned", () => { expect(hasCoaGuardianParagon(actor())).toBe(true); expect(hasCoaGuardianParagon(actor("unlearned", "guardian", 0))).toBe(false); expect(hasCoaGuardianParagon(actor("other-class", "warrior", 1))).toBe(false); const empty: readonly ActiveAura[] = []; expect(applyCoaGuardianParagonEvent(empty, actor("unlearned", "guardian", 0), { type: "block" }, 1_000).auras) .toBe(empty); const firstActor = applyCoaGuardianParagonEvent(empty, actor("guardian-a"), { type: "block" }, 1_000).auras; const secondActor = applyCoaGuardianParagonEvent(firstActor, actor("guardian-b"), { type: "block" }, 1_000).auras; expect(secondActor.filter((aura) => aura.definition.id === COA_GUARDIAN_PARAGON_STACKS_AURA_ID) .map((aura) => [aura.sourceId, aura.targetId, aura.stacks])).toEqual([ ["guardian-a", "guardian-a", 1], ["guardian-b", "guardian-b", 1], ]); }); it("adds one stack per confirmed block at the 500 ms trigger rate and refreshes the 10-second stack aura", () => { let auras: readonly ActiveAura[] = []; auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "block" }, 1_000).auras; expect(auraById(auras, COA_GUARDIAN_PARAGON_STACKS_AURA_ID)).toMatchObject({ stacks: 1, appliedAt: 1_000, expiresAt: 1_000 + COA_GUARDIAN_PARAGON_STACK_DURATION_MS, }); expect(auraById(auras, COA_GUARDIAN_PARAGON_BLOCK_ICD_AURA_ID)).toMatchObject({ expiresAt: 1_000 + COA_GUARDIAN_PARAGON_BLOCK_ICD_MS, }); auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "block" }, 1_499).auras; expect(auraById(auras, COA_GUARDIAN_PARAGON_STACKS_AURA_ID)?.stacks).toBe(1); auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "block" }, 1_500).auras; expect(auraById(auras, COA_GUARDIAN_PARAGON_STACKS_AURA_ID)).toMatchObject({ stacks: 2, appliedAt: 1_500, expiresAt: 11_500, }); for (let stack = 2; stack < 12; stack += 1) { auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "block" }, 1_500 + stack * 500).auras; } expect(auraById(auras, COA_GUARDIAN_PARAGON_STACKS_AURA_ID)?.stacks).toBe(COA_GUARDIAN_PARAGON_MAX_STACKS); }); it("adds three stacks on a completed Heavy Blow even when its damage misses, but ignores failed casts", () => { const failed = applyCoaGuardianParagonEvent([], actor(), { type: "cast", ability: heavyBlow, result: "failure", }, 1_000); expect(failed.auras).toEqual([]); // A completed cast is the source trigger. Damage hit/miss resolution is a separate event. const first = applyCoaGuardianParagonEvent([], actor(), { type: "cast", ability: heavyBlow, result: "success", }, 1_000); expect(auraById(first.auras, COA_GUARDIAN_PARAGON_STACKS_AURA_ID)).toMatchObject({ stacks: COA_GUARDIAN_PARAGON_HEAVY_BLOW_STACKS, expiresAt: 11_000, }); let auras = first.auras; auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "cast", ability: heavyBlow, result: "success", }, 2_000).auras; auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "cast", ability: heavyBlow, result: "success", }, 3_000).auras; expect(auraById(auras, COA_GUARDIAN_PARAGON_STACKS_AURA_ID)?.stacks).toBe(9); auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "cast", ability: heavyBlow, result: "success", }, 4_000).auras; expect(auraById(auras, COA_GUARDIAN_PARAGON_STACKS_AURA_ID)?.stacks).toBe(COA_GUARDIAN_PARAGON_MAX_STACKS); expect(auraById(auras, COA_GUARDIAN_PARAGON_SMASH_READY_AURA_ID)).toMatchObject({ appliedAt: 4_000, expiresAt: 4_000 + COA_GUARDIAN_PARAGON_SMASH_READY_DURATION_MS, }); }); it("projects an 8-yard self-centered Smash while preserving Heavy Blow action and cost provenance", () => { const ready = readyState(); const projected = projectCoaGuardianParagonAbility(heavyBlow, actor(), ready, 4_000); expect(coaGuardianParagonCastVariant(heavyBlow)).toBe("heavy-blow"); expect(coaGuardianParagonCastVariant(projected)).toBe("paragon-smash"); expect(projected).toMatchObject({ id: heavyBlow.id, dbcSpellId: heavyBlow.dbcSpellId, talentEntryId: heavyBlow.talentEntryId, cost: heavyBlow.cost, cooldownMs: heavyBlow.cooldownMs, gcdMs: heavyBlow.gcdMs, name: "Paragon Smash", sigil: "PS", target: "self", range: { min: 0, max: 0 }, radius: COA_GUARDIAN_PARAGON_SMASH_RADIUS_YARDS, targeting: { shape: "circle", radius: COA_GUARDIAN_PARAGON_SMASH_RADIUS_YARDS }, coaGuardianParagonVariant: "paragon-smash", effects: [{ kind: "damage", basePoints: COA_GUARDIAN_PARAGON_SMASH_BASE_DAMAGE, bonusPowerCoefficient: 0, attackPowerCoefficient: COA_GUARDIAN_PARAGON_SMASH_ATTACK_POWER_COEFFICIENT, school: "physical", }], }); expect(projected.ai?.priority).toBeGreaterThanOrEqual(40); expect(projected.ai?.categories).toContain("damage"); const healthyTankProjection = projectCoaGuardianParagonAbility(heavyBlow, actor( "guardian-player", "guardian", 1, { role: "tank", specializationId: "guardian:vanguard", health: 55, maxHealth: 100 }, ), ready, 4_000); expect(healthyTankProjection.ai?.priority).toBe(COA_GUARDIAN_PARAGON_SMASH_HEALTHY_TANK_AI_PRIORITY); expect(COA_GUARDIAN_PARAGON_SMASH_HEALTHY_TANK_THRESHOLD).toBe(0.55); const injuredTankProjection = projectCoaGuardianParagonAbility(heavyBlow, actor( "guardian-player", "guardian", 1, { role: "tank", specializationId: "guardian:vanguard", health: 54, maxHealth: 100 }, ), ready, 4_000); expect(injuredTankProjection.ai?.priority).toBe(40); const nonTankProjection = projectCoaGuardianParagonAbility(heavyBlow, actor( "guardian-player", "guardian", 1, { role: "damage", specializationId: "guardian:vanguard", health: 100, maxHealth: 100 }, ), ready, 4_000); expect(nonTankProjection.ai?.priority).toBe(40); expect(projectCoaGuardianParagonAbility(heavyBlow, actor("unlearned", "guardian", 0), ready, 4_000)) .toBe(heavyBlow); expect(coaGuardianParagonCastVariant({ ...heavyBlow, classId: "warrior" })).toBe("other"); }); it("applies the five-block 10% bonus only after a successful Smash cast", () => { const ready = readyState(); const projected = projectCoaGuardianParagonAbility(heavyBlow, actor(), ready, 4_000); const failed = applyCoaGuardianParagonEvent(ready, actor(), { type: "cast", ability: projected, result: "failure", }, 4_100); expect(failed.auras).toBe(ready); expect(auraById(failed.auras, COA_GUARDIAN_PARAGON_SMASH_BUFF_AURA_ID)).toBeUndefined(); const cast = applyCoaGuardianParagonEvent(ready, actor(), { type: "cast", ability: projected, result: "success", }, 4_100); expect(auraById(cast.auras, COA_GUARDIAN_PARAGON_STACKS_AURA_ID)?.stacks).toBe(10); expect(auraById(cast.auras, COA_GUARDIAN_PARAGON_SMASH_READY_AURA_ID)?.expiresAt) .toBe(auraById(ready, COA_GUARDIAN_PARAGON_SMASH_READY_AURA_ID)?.expiresAt); expect(auraById(cast.auras, COA_GUARDIAN_PARAGON_SMASH_BUFF_AURA_ID)).toMatchObject({ stacks: COA_GUARDIAN_PARAGON_SMASH_BLOCK_CHARGES, appliedAt: 4_100, expiresAt: 14_100, definition: COA_GUARDIAN_PARAGON_SMASH_BUFF_AURA, }); expect(COA_GUARDIAN_PARAGON_SMASH_BUFF_AURA.modifiers).toEqual([{ kind: "stat", stat: "block", operation: "flat", value: COA_GUARDIAN_PARAGON_SMASH_BLOCK_CHANCE_BONUS, perStack: false, }]); expect(resolveStatValue(5, cast.auras, "block")).toBe(15); }); it("reduces Brace by two seconds on each of five successful blocks, then stops with the buff", () => { let auras = readyState(); const projected = projectCoaGuardianParagonAbility(heavyBlow, actor(), auras, 4_000); auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "cast", ability: projected, result: "success", }, 4_000).auras; for (let block = 0; block < COA_GUARDIAN_PARAGON_SMASH_BLOCK_CHARGES; block += 1) { const outcome = applyCoaGuardianParagonEvent(auras, actor(), { type: "block" }, 5_000 + block * 500); expect(outcome.braceCooldownReductionMs).toBe(COA_GUARDIAN_PARAGON_BRACE_COOLDOWN_REDUCTION_MS); auras = outcome.auras; } expect(auraById(auras, COA_GUARDIAN_PARAGON_SMASH_BUFF_AURA_ID)).toBeUndefined(); expect(auraById(auras, COA_GUARDIAN_PARAGON_SMASH_READY_AURA_ID)).toBeDefined(); const sixthBlock = applyCoaGuardianParagonEvent(auras, actor(), { type: "block" }, 7_500); expect(sixthBlock.braceCooldownReductionMs).toBe(0); }); it("keeps the timed Paragon stack state through the readiness window and allows a later qualifying event to re-establish readiness", () => { let auras: readonly ActiveAura[] = []; for (let cast = 0; cast < 4; cast += 1) { auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "cast", ability: heavyBlow, result: "success", }, 1_000 + cast * 1_000).auras; } const firstReadyAt = auraById(auras, COA_GUARDIAN_PARAGON_SMASH_READY_AURA_ID)?.appliedAt; expect(firstReadyAt).toBe(4_000); auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "cast", ability: heavyBlow, result: "success", }, 13_000).auras; auras = applyCoaGuardianParagonEvent(auras, actor(), { type: "cast", ability: heavyBlow, result: "success", }, 14_000).auras; expect(auraById(auras, COA_GUARDIAN_PARAGON_STACKS_AURA_ID)?.stacks) .toBe(COA_GUARDIAN_PARAGON_MAX_STACKS); expect(auraById(auras, COA_GUARDIAN_PARAGON_SMASH_READY_AURA_ID)?.appliedAt).toBe(14_000); }); });