282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { abilitiesForCharacter, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
|
import { applyAura } from "./combatAuras";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import { ROM_WARLOCK_MAGE_WILLPOWER_BLADE_STATE_TAG } from "./romWarlockMageSoulBrandSting";
|
|
import { ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG } from "./romWarlockPriestSpreadingPain";
|
|
import { partyAbilityAmount, partyAbilityIsReady } from "./partyAbilityAi";
|
|
|
|
const SOUL_BRAND_TARGET = "rom-warlock-mage-soul-brand-target";
|
|
const CRIT_TARGET = "rom-warlock-mage-spreading-pain-target";
|
|
const STING_TARGET = "rom-warlock-mage-soul-brand-sting-target";
|
|
|
|
function prepareTarget(id: string, name: string): void {
|
|
useCombatStore.getState().registerMob(id, { name, maxHealth: 100_000 });
|
|
setMobPosition(id, [5, 0, 0]);
|
|
}
|
|
|
|
function applyStateAura(actorId: string, tag: string, auraId: string, name: string, now: number): void {
|
|
const current = useCombatStore.getState();
|
|
const aura = applyAura(current.auras, {
|
|
definition: { id: auraId, name, disposition: "buff", durationMs: 30_000, utilityTags: [tag] },
|
|
sourceId: actorId,
|
|
targetId: actorId,
|
|
now,
|
|
});
|
|
useCombatStore.setState({ auras: [...aura.auras] });
|
|
}
|
|
|
|
function damageSpell(targetId: string, sourceActorId: string, school: "fire" | "shadow", now: number, periodic = false) {
|
|
return useCombatStore.getState().damageMob(
|
|
targetId,
|
|
1_000,
|
|
now,
|
|
{ actorId: sourceActorId, role: "damage", abilityId: "coa-runtime-test-spell" },
|
|
school,
|
|
{
|
|
attackKind: "spell",
|
|
abilityId: "coa-runtime-test-spell",
|
|
seed: `${sourceActorId}:${school}:${now}`,
|
|
periodic,
|
|
rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
|
},
|
|
);
|
|
}
|
|
|
|
describe("current CoA Warlock hybrid runtime hooks", () => {
|
|
beforeAll(async () => {
|
|
await preloadAbilityCatalogsForClasses(["rom-warlock"]);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-mage",
|
|
raceId: "rom-dwarf",
|
|
level: 80,
|
|
});
|
|
});
|
|
|
|
it("procs Soul Brand on player direct and periodic Fire damage, stacks to four, and restores 1 Psi per landed event", () => {
|
|
prepareTarget(SOUL_BRAND_TARGET, "Soul Brand target");
|
|
useCombatStore.setState((state) => ({
|
|
resourcePools: { ...state.resourcePools, psi: 1 },
|
|
maxResourcePools: { ...state.maxResourcePools, psi: 6 },
|
|
}));
|
|
|
|
expect(damageSpell(SOUL_BRAND_TARGET, PLAYER_AGGRO_ID, "fire", 1_000).amount).toBeGreaterThan(0);
|
|
expect(damageSpell(SOUL_BRAND_TARGET, PLAYER_AGGRO_ID, "fire", 2_000).amount).toBeGreaterThan(0);
|
|
expect(damageSpell(SOUL_BRAND_TARGET, PLAYER_AGGRO_ID, "shadow", 3_000).amount).toBeGreaterThan(0);
|
|
expect(damageSpell(SOUL_BRAND_TARGET, PLAYER_AGGRO_ID, "fire", 4_000, true).amount).toBeGreaterThan(0);
|
|
|
|
const state = useCombatStore.getState();
|
|
expect(state.auras.find((aura) => aura.targetId === SOUL_BRAND_TARGET
|
|
&& aura.definition.id === "rom-warlock-mage-soul-brand-498705")?.stacks).toBe(3);
|
|
expect(state.resourcePools.psi).toBe(4);
|
|
});
|
|
|
|
it("gates Soul Brand Sting for both player and companion until the tagged Willpower Blade state is active", () => {
|
|
const ability = abilitiesForCharacter("rom-warlock", "rom-mage")
|
|
.find((candidate) => candidate.dbcSpellId === 498710)!;
|
|
useCombatStore.setState((state) => ({
|
|
resourcePools: { ...state.resourcePools, psi: 4 },
|
|
maxResourcePools: { ...state.maxResourcePools, psi: 6 },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility(ability.id, { now: 1_000 })).toMatchObject({
|
|
ok: false,
|
|
reason: "condition-not-met",
|
|
});
|
|
prepareTarget(STING_TARGET, "Soul Brand Sting target");
|
|
|
|
usePartyStore.getState().initializeParty(
|
|
"rom-warlock-mage-gate-party",
|
|
80,
|
|
useCombatStore.getState().health,
|
|
"damage",
|
|
2,
|
|
"rom",
|
|
);
|
|
const member = createConfiguredPartyMember({
|
|
name: "Warlock Mage",
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-mage",
|
|
raceId: "rom-dwarf",
|
|
specialization: "Warlock",
|
|
role: "damage",
|
|
}, 80, 0, "rom-warlock-mage-gate-party");
|
|
const partyMember = {
|
|
...member,
|
|
resourcePools: { ...member.resourcePools, psi: 4 },
|
|
cooldowns: { ...member.cooldowns, [ability.id]: 0 },
|
|
};
|
|
const stingDamage = ability.effects.find((effect) => effect.kind === "damage");
|
|
if (stingDamage?.kind === "damage") {
|
|
expect(partyAbilityAmount(partyMember, ability, stingDamage)).toBeGreaterThan(3_737.5);
|
|
}
|
|
usePartyStore.setState({ members: [partyMember] });
|
|
expect(partyAbilityIsReady(partyMember, ability, 1_000)).toBe(false);
|
|
|
|
applyStateAura(PLAYER_AURA_ENTITY_ID, ROM_WARLOCK_MAGE_WILLPOWER_BLADE_STATE_TAG,
|
|
"local-willpower-blade", "Willpower Blade", 1_100);
|
|
applyStateAura(member.id, ROM_WARLOCK_MAGE_WILLPOWER_BLADE_STATE_TAG,
|
|
"companion-willpower-blade", "Willpower Blade", 1_100);
|
|
expect(partyAbilityIsReady(partyMember, ability, 1_200)).toBe(true);
|
|
expect(useCombatStore.getState().castAbility(ability.id, {
|
|
targetActorId: STING_TARGET,
|
|
now: 1_200,
|
|
})).toMatchObject({ ok: true });
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
expect(activeCast?.abilityId).toBe(ability.id);
|
|
const completedCast = useCombatStore.getState().castAbility(ability.id, {
|
|
targetActorId: STING_TARGET,
|
|
now: 2_300,
|
|
completingCast: activeCast ?? undefined,
|
|
});
|
|
expect(completedCast.ok).toBe(true);
|
|
expect(completedCast.damage).toBeGreaterThan(7_475);
|
|
});
|
|
|
|
it("applies Spreading Pain's +25% critical bonus only to magical spells for its exact Warlock/Priest build", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-priest",
|
|
raceId: "rom-dwarf",
|
|
level: 80,
|
|
});
|
|
prepareTarget(CRIT_TARGET, "Spreading Pain target");
|
|
const hit = (now: number) => useCombatStore.getState().damageMob(
|
|
CRIT_TARGET,
|
|
1_000,
|
|
now,
|
|
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: "coa-critical-spell" },
|
|
"fire",
|
|
{
|
|
attackKind: "spell",
|
|
abilityId: "coa-critical-spell",
|
|
seed: `spreading-pain:${now}`,
|
|
criticalChanceBonusPercent: 100,
|
|
rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
|
},
|
|
);
|
|
|
|
const baseline = hit(2_000);
|
|
expect(baseline.combatResult?.critical).toBe(true);
|
|
applyStateAura(PLAYER_AURA_ENTITY_ID, ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG,
|
|
"spreading-pain-critical", "Spreading Pain: Magical Critical Bonus", 2_100);
|
|
const buffed = hit(2_200);
|
|
|
|
expect(buffed.combatResult?.critical).toBe(true);
|
|
expect(buffed.amount).toBe(Math.round(baseline.amount * 1.125));
|
|
});
|
|
|
|
it("uses the same Spreading Pain critical bonus for generated companion spells", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 });
|
|
usePartyStore.getState().initializeParty(
|
|
"rom-warlock-priest-spreading-pain-party",
|
|
80,
|
|
useCombatStore.getState().health,
|
|
"damage",
|
|
2,
|
|
"rom",
|
|
);
|
|
const member = createConfiguredPartyMember({
|
|
name: "Warlock Priest",
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-priest",
|
|
raceId: "rom-dwarf",
|
|
specialization: "Warlock",
|
|
role: "damage",
|
|
}, 80, 0, "rom-warlock-priest-spreading-pain-party");
|
|
usePartyStore.setState({ members: [member] });
|
|
useCombatStore.getState().synchronizeActors();
|
|
prepareTarget("rom-warlock-priest-crit-target", "Companion crit target");
|
|
const hit = (now: number) => useCombatStore.getState().damageMob(
|
|
"rom-warlock-priest-crit-target",
|
|
1_000,
|
|
now,
|
|
{ actorId: member.id, role: "damage", abilityId: "coa-party-critical-spell" },
|
|
"fire",
|
|
{
|
|
attackKind: "spell",
|
|
abilityId: "coa-party-critical-spell",
|
|
seed: `companion-spreading-pain:${now}`,
|
|
criticalChanceBonusPercent: 100,
|
|
rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
|
},
|
|
);
|
|
|
|
const baseline = hit(2_000);
|
|
expect(baseline.combatResult?.critical).toBe(true);
|
|
applyStateAura(member.id, ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG,
|
|
"companion-spreading-pain-critical", "Spreading Pain: Magical Critical Bonus", 2_100);
|
|
const buffed = hit(2_200);
|
|
expect(buffed.combatResult?.critical).toBe(true);
|
|
expect(buffed.amount).toBe(Math.round(baseline.amount * 1.125));
|
|
});
|
|
|
|
it("routes deterministic companion Fire hits through Soul Brand's post-landed hook", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 });
|
|
usePartyStore.getState().initializeParty(
|
|
"rom-warlock-mage-soul-brand-party",
|
|
80,
|
|
useCombatStore.getState().health,
|
|
"damage",
|
|
2,
|
|
"rom",
|
|
);
|
|
const member = createConfiguredPartyMember({
|
|
name: "Warlock Mage",
|
|
classId: "rom-warlock",
|
|
secondaryClassId: "rom-mage",
|
|
raceId: "rom-dwarf",
|
|
specialization: "Warlock",
|
|
role: "damage",
|
|
}, 80, 0, "rom-warlock-mage-soul-brand-party");
|
|
const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-mage");
|
|
const flameStrike = pairAbilities.find((ability) => ability.dbcSpellId === 498697)!;
|
|
const cooldowns = Object.fromEntries(pairAbilities.map((ability) => [
|
|
ability.id,
|
|
ability.id === flameStrike.id ? 0 : 100_000,
|
|
]));
|
|
const runtimeMember = {
|
|
...member,
|
|
cooldowns,
|
|
resourcePools: { ...member.resourcePools, psi: 3 },
|
|
resource: member.resourceType === "psi" ? 3 : member.resource,
|
|
nextActionAt: 0,
|
|
globalCooldownEndsAt: 0,
|
|
};
|
|
usePartyStore.setState({ command: "attack", activeMobId: SOUL_BRAND_TARGET,
|
|
memberTargetIds: { [member.id]: SOUL_BRAND_TARGET }, members: [runtimeMember] });
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
prepareTarget(SOUL_BRAND_TARGET, "Companion Soul Brand target");
|
|
useCombatStore.getState().damageMob(SOUL_BRAND_TARGET, 100, 900);
|
|
updatePartyRuntimeTargetVisibility(member.id, SOUL_BRAND_TARGET, true);
|
|
|
|
for (const now of [1_000, 1_300, 3_500, 3_800, 6_000]) advanceDungeonPartyCombat(now);
|
|
expect(damageSpell(SOUL_BRAND_TARGET, member.id, "fire", 7_000, true).amount).toBeGreaterThan(0);
|
|
|
|
const targetAura = useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === SOUL_BRAND_TARGET
|
|
&& aura.sourceId === member.id
|
|
&& aura.definition.id === "rom-warlock-mage-soul-brand-498705"
|
|
));
|
|
expect(targetAura?.stacks).toBe(2);
|
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.resourcePools.psi)
|
|
.toBeGreaterThan(3);
|
|
});
|
|
});
|