Files
healer-man/src/game/coaGuardianParagonRuntime.test.ts
T
2026-10-06 22:25:34 -04:00

320 lines
14 KiB
TypeScript

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<Pick<CoaGuardianParagonActor, "role" | "specializationId" | "health" | "maxHealth">> = {},
): 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);
});
});