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

229 lines
9.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ClassId } from "../app/characterCatalog";
import type { AbilityDefinition } from "./abilityCatalog";
import { applyAura, resolveDamageValue, resolveStatValue, type ActiveAura } from "./combatAuras";
import {
coaGuardianCompanionFormationSpellId,
coaGuardianFormationAbilityForCast,
COA_GUARDIAN_FORMATION_EXCLUSIVE_GROUP,
COA_GUARDIAN_FOOTMANS_CALLING_DAMAGE_REDUCTION,
COA_GUARDIAN_FOOTMANS_CALLING_TALENT_ENTRY_ID,
COA_GUARDIAN_LINE_FORMATION_AURA,
COA_GUARDIAN_LINE_FORMATION_AURA_ID,
COA_GUARDIAN_LINE_FORMATION_SPELL_ID,
COA_GUARDIAN_LINE_MOVEMENT_SPEED_MULTIPLIER,
COA_GUARDIAN_LINE_ROOT_DURATION_REDUCTION,
COA_GUARDIAN_TOWER_FORMATION_AURA,
COA_GUARDIAN_TOWER_FORMATION_AURA_ID,
COA_GUARDIAN_TOWER_FORMATION_SPELL_ID,
coaGuardianFormationMovementSpeedMultiplier,
getCoaGuardianTowerShieldArmorBonus,
hasCoaGuardianFormationAura,
projectCoaGuardianFormationAuras,
resolveCoaGuardianControlDurationMs,
type CoaGuardianFormationActor,
} from "./coaGuardianFormationRuntime";
const actor = (
id = "guardian-player",
classId: ClassId = "guardian",
footmansCalling = false,
health = 100,
): CoaGuardianFormationActor => ({
id,
classId,
health,
talentRanks: footmansCalling
? { [`coa-entry-${COA_GUARDIAN_FOOTMANS_CALLING_TALENT_ENTRY_ID}`]: 1 }
: {},
});
const activeAura = (
definition: typeof COA_GUARDIAN_TOWER_FORMATION_AURA | typeof COA_GUARDIAN_LINE_FORMATION_AURA,
sourceId = "guardian-player",
targetId = sourceId,
now = 1_000,
): ActiveAura => applyAura([], {
definition,
sourceId,
targetId,
now,
}).aura;
const ability = (dbcSpellId: number, classId: ClassId = "guardian"): AbilityDefinition => ({
id: `ability-${dbcSpellId}`,
classId,
dbcSpellId,
name: "Formation",
unlockLevel: 4,
icon: "icon.png",
sigil: "FO",
description: "Source description",
target: "hostile",
range: { min: 0, max: 0 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 0,
cost: { resource: "energy", amount: 20 },
effects: [{
kind: "apply-aura",
aura: { id: "generic-import", name: "Generic import", disposition: "buff", durationMs: null },
sourceEffectType: 6,
sourceAuraType: 142,
miscValue: 127,
triggerSpellId: 0,
}],
passive: false,
source: "coa-progression",
});
describe("CoA Guardian formation runtime", () => {
it("defines persistent exclusive forms with source-specific effects", () => {
expect(COA_GUARDIAN_TOWER_FORMATION_AURA).toMatchObject({
id: COA_GUARDIAN_TOWER_FORMATION_AURA_ID,
durationMs: null,
exclusiveGroup: COA_GUARDIAN_FORMATION_EXCLUSIVE_GROUP,
modifiers: [
{ kind: "stat", stat: "block", operation: "flat", value: 10, perStack: false },
{ kind: "damage", direction: "dealt", operation: "percent", value: -0.1 },
],
});
expect(COA_GUARDIAN_LINE_FORMATION_AURA).toMatchObject({
id: COA_GUARDIAN_LINE_FORMATION_AURA_ID,
durationMs: null,
exclusiveGroup: COA_GUARDIAN_FORMATION_EXCLUSIVE_GROUP,
modifiers: [{ kind: "stat", stat: "movement-speed", operation: "percent", value: 0.15 }],
});
});
it("replaces imported effects with one self-applied runtime form aura", () => {
const tower = coaGuardianFormationAbilityForCast(ability(COA_GUARDIAN_TOWER_FORMATION_SPELL_ID));
expect(tower.cost).toEqual({ resource: "energy", amount: 20 });
expect(tower.target).toBe("self");
expect(tower.effects).toEqual([{
kind: "apply-aura",
aura: COA_GUARDIAN_TOWER_FORMATION_AURA,
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 87,
miscValue: 0,
triggerSpellId: 0,
}]);
const line = coaGuardianFormationAbilityForCast(ability(COA_GUARDIAN_LINE_FORMATION_SPELL_ID));
expect(line.effects).toEqual([expect.objectContaining({
kind: "apply-aura",
aura: COA_GUARDIAN_LINE_FORMATION_AURA,
recipient: "caster",
sourceAuraType: 232,
miscValue: 7,
})]);
const warriorAbility = ability(COA_GUARDIAN_TOWER_FORMATION_SPELL_ID, "warrior");
expect(coaGuardianFormationAbilityForCast(warriorAbility)).toBe(warriorAbility);
const unrelated = ability(123456);
expect(coaGuardianFormationAbilityForCast(unrelated)).toBe(unrelated);
});
it("recognizes only the living actor's own active self marker", () => {
const self = activeAura(COA_GUARDIAN_TOWER_FORMATION_AURA);
const foreign = activeAura(COA_GUARDIAN_TOWER_FORMATION_AURA, "another-guardian", "guardian-player");
const nonSelf = activeAura(COA_GUARDIAN_TOWER_FORMATION_AURA, "guardian-player", "another-guardian");
const expired = { ...self, expiresAt: 1_000 };
expect(hasCoaGuardianFormationAura([self], actor(), "tower", 1_000)).toBe(true);
expect(hasCoaGuardianFormationAura([foreign, nonSelf], actor(), "tower", 1_000)).toBe(false);
expect(hasCoaGuardianFormationAura([expired], actor(), "tower", 1_000)).toBe(false);
expect(hasCoaGuardianFormationAura([self], actor("guardian-player", "guardian", false, 0), "tower", 999))
.toBe(false);
expect(hasCoaGuardianFormationAura([self], actor("guardian-player", "warrior"), "tower", 999)).toBe(false);
});
it("projects shield armor and Footman's Calling only on owned forms", () => {
const tower = activeAura(COA_GUARDIAN_TOWER_FORMATION_AURA);
const owner = actor("guardian-player", "guardian", true);
const projected = projectCoaGuardianFormationAuras([tower], owner, 1_001, 120);
expect(getCoaGuardianTowerShieldArmorBonus(owner, [tower], 1_001, 120)).toBe(60);
expect(resolveStatValue(100, projected, "armor")).toBe(160);
expect(resolveStatValue(5, projected, "block")).toBe(15);
expect(resolveDamageValue(200, projected, { direction: "taken", school: "physical", attackKind: "melee" }))
.toBe(180);
expect(resolveDamageValue(200, projected, { direction: "dealt", school: "physical", attackKind: "melee" }))
.toBe(180);
const repeatedProjection = projectCoaGuardianFormationAuras(projected, owner, 1_001, 120);
expect(resolveStatValue(100, repeatedProjection, "armor")).toBe(160);
expect(resolveDamageValue(200, repeatedProjection, { direction: "taken", school: "physical", attackKind: "melee" }))
.toBe(180);
const withoutFootman = projectCoaGuardianFormationAuras([tower], actor(), 1_001, 120);
expect(resolveStatValue(100, withoutFootman, "armor")).toBe(160);
expect(resolveDamageValue(200, withoutFootman, { direction: "taken", school: "physical", attackKind: "melee" }))
.toBe(200);
const noLongerLearned = projectCoaGuardianFormationAuras(projected, actor(), 1_001, 120);
expect(resolveDamageValue(200, noLongerLearned, { direction: "taken", school: "physical", attackKind: "melee" }))
.toBe(200);
expect(getCoaGuardianTowerShieldArmorBonus(actor(), [tower], 1_001, 120)).toBe(60);
expect(getCoaGuardianTowerShieldArmorBonus(owner, [tower], 1_001, Number.NaN)).toBe(0);
});
it("does not project conditional mitigation or shield armor from foreign or Line forms", () => {
const foreignTower = activeAura(COA_GUARDIAN_TOWER_FORMATION_AURA, "other", "guardian-player");
const line = activeAura(COA_GUARDIAN_LINE_FORMATION_AURA);
const owner = actor("guardian-player", "guardian", true);
const foreignAuras = [foreignTower];
expect(projectCoaGuardianFormationAuras(foreignAuras, owner, 1_001, 120)).toBe(foreignAuras);
expect(resolveStatValue(100, projectCoaGuardianFormationAuras(foreignAuras, owner, 1_001, 120), "armor"))
.toBe(100);
const projectedLine = projectCoaGuardianFormationAuras([line], owner, 1_001, 120);
expect(resolveStatValue(100, projectedLine, "armor")).toBe(100);
expect(resolveDamageValue(100, projectedLine, { direction: "taken", school: "fire", attackKind: "spell" }))
.toBe(90);
});
it("reduces only root duration while the actor's own Line form is active", () => {
const line = activeAura(COA_GUARDIAN_LINE_FORMATION_AURA);
expect(resolveCoaGuardianControlDurationMs(8_000, actor(), [line], "root", 1_001))
.toBe(8_000 * (1 - COA_GUARDIAN_LINE_ROOT_DURATION_REDUCTION));
expect(resolveCoaGuardianControlDurationMs(8_000, actor(), [line], "slow", 1_001)).toBe(8_000);
expect(resolveCoaGuardianControlDurationMs(8_000, actor(), [], "root", 1_001)).toBe(8_000);
expect(resolveCoaGuardianControlDurationMs(8_000, actor("guardian-player", "guardian", false, 0), [line], "root", 1_001))
.toBe(8_000);
expect(coaGuardianFormationMovementSpeedMultiplier([line], actor(), 1_001))
.toBe(COA_GUARDIAN_LINE_MOVEMENT_SPEED_MULTIPLIER);
expect(coaGuardianFormationMovementSpeedMultiplier([line], actor("guardian-player", "warrior"), 1_001)).toBe(1);
expect(coaGuardianFormationMovementSpeedMultiplier([line], actor("guardian-player", "guardian", false, 0), 1_001))
.toBe(1);
expect(COA_GUARDIAN_FOOTMANS_CALLING_DAMAGE_REDUCTION).toBe(0.1);
});
it("chooses the authored companion form once and waits while its own form persists", () => {
const guardian = actor("guardian-companion");
const options = {
actor: guardian,
role: "vanguard-tank" as const,
towerAvailable: true,
lineAvailable: true,
auras: [] as readonly ActiveAura[],
now: 1_000,
};
expect(coaGuardianCompanionFormationSpellId(options)).toBe(COA_GUARDIAN_TOWER_FORMATION_SPELL_ID);
expect(coaGuardianCompanionFormationSpellId({ ...options, towerAvailable: false }))
.toBe(COA_GUARDIAN_LINE_FORMATION_SPELL_ID);
expect(coaGuardianCompanionFormationSpellId({ ...options, role: "other" }))
.toBe(COA_GUARDIAN_LINE_FORMATION_SPELL_ID);
expect(coaGuardianCompanionFormationSpellId({ ...options, towerAvailable: false, lineAvailable: false }))
.toBeNull();
const activeLine = activeAura(
COA_GUARDIAN_LINE_FORMATION_AURA,
"guardian-companion",
"guardian-companion",
options.now,
);
expect(coaGuardianCompanionFormationSpellId({ ...options, auras: [activeLine] })).toBeNull();
});
});