forked from phenom/healer-man
320 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|