229 lines
9.9 KiB
TypeScript
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();
|
|
});
|
|
});
|