Update Healer Man server
This commit is contained in:
1 parent
9d7e0e2f9e
commit
35dfdaed61
94 files changed
+17034
-418
No files matched your search
@@ -0,0 +1,324 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { AbilityEffect } from "./abilityCatalog";
|
||||
import { abilitiesForCharacter, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { cooldownResetCandidates } from "./cooldownReset";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romEffectsForCast } from "./romCombatMechanics";
|
||||
import {
|
||||
ROM_WARLOCK_STATE_TAGS,
|
||||
} from "./romWarlockOverrides";
|
||||
|
||||
const TARGET_ID = "rom-warlock-runtime-target";
|
||||
|
||||
function ability(spellId: number) {
|
||||
const result = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId);
|
||||
expect(result, `RoM Warlock spell ${spellId}`).toBeDefined();
|
||||
return result!;
|
||||
}
|
||||
|
||||
function finishCast(now: number) {
|
||||
const cast = useCombatStore.getState().activeCast;
|
||||
if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt);
|
||||
}
|
||||
|
||||
function prepareWarlock(secondaryClassId: "rom-warrior" | "rom-rogue" | "rom-priest") {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-warlock",
|
||||
secondaryClassId,
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 100,
|
||||
maxResource: 100,
|
||||
resourcePools: {
|
||||
...state.resourcePools,
|
||||
focus: 100,
|
||||
rage: 0,
|
||||
energy: 100,
|
||||
psi: 0,
|
||||
},
|
||||
maxResourcePools: {
|
||||
...state.maxResourcePools,
|
||||
focus: 100,
|
||||
rage: 100,
|
||||
energy: 100,
|
||||
psi: 6,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function registerTarget() {
|
||||
useCombatStore.getState().registerMob(TARGET_ID, { name: "Warlock Test Target", maxHealth: 100_000 });
|
||||
setMobPosition(TARGET_ID, [5, 0, 0]);
|
||||
}
|
||||
|
||||
describe("RuneWaker Warlock runtime", () => {
|
||||
beforeAll(async () => {
|
||||
await preloadAbilityCatalogsForClasses(["rom-warlock"]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("converts the RoM Warlock/Warrior Rage pool to psi at 35:1", () => {
|
||||
prepareWarlock("rom-warrior");
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, rage: 70, psi: 1 },
|
||||
}));
|
||||
|
||||
const mindTransference = ability(498642);
|
||||
expect(mindTransference.costs).toEqual([]);
|
||||
expect(useCombatStore.getState().castAbility(mindTransference.id, { now: 1_000 }).ok).toBe(true);
|
||||
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
|
||||
expect(useCombatStore.getState().resourcePools.psi).toBe(3);
|
||||
});
|
||||
|
||||
it("consumes all Rage for Heart Fence and extends its duration by one second per 20 Rage", () => {
|
||||
prepareWarlock("rom-warrior");
|
||||
usePartyStore.getState().initializeParty(
|
||||
"rom-warlock-heart-fence", 80, useCombatStore.getState().health, "damage", 3, "rom",
|
||||
);
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
registerPartyRuntimePosition(member.id, [4, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 45 } }));
|
||||
|
||||
const heartFence = ability(498645);
|
||||
expect(useCombatStore.getState().castAbility(heartFence.id, {
|
||||
targetActorId: member.id,
|
||||
now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
|
||||
const defenseAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id
|
||||
&& aura.definition.name.startsWith("Heart Fence")
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken"));
|
||||
expect(defenseAuras).toHaveLength(2);
|
||||
expect(defenseAuras.every((aura) => aura.expiresAt === 5_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("only refreshes state 501577 and restores Rage when Consistent Heart Strike finds it", () => {
|
||||
prepareWarlock("rom-warrior");
|
||||
registerTarget();
|
||||
const heartStrike = ability(498646);
|
||||
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 25 } }));
|
||||
|
||||
expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 500 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(25);
|
||||
|
||||
const soulTrauma = ability(498550);
|
||||
const traumaCast = useCombatStore.getState().castAbility(soulTrauma.id, { targetActorId: TARGET_ID, now: 2_000 });
|
||||
expect(traumaCast, JSON.stringify(traumaCast)).toMatchObject({ ok: true });
|
||||
const heartStatesAfterTrauma = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence));
|
||||
expect(heartStatesAfterTrauma.some((aura) => aura.expiresAt === 17_000)).toBe(true);
|
||||
|
||||
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 20 } }));
|
||||
expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 5_000 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools.rage).toBe(100);
|
||||
const heartStatesAfterStrike = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence));
|
||||
expect(heartStatesAfterStrike.length).toBeGreaterThan(0);
|
||||
expect(heartStatesAfterStrike.every((aura) => aura.expiresAt === 20_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("scales Crime and Punishment by 25% per state after the first", () => {
|
||||
const crimeAndPunishment = ability(498700);
|
||||
const baseDamage = crimeAndPunishment.effects.find((effect) => effect.kind === "damage");
|
||||
if (baseDamage?.kind !== "damage") throw new Error("Missing Crime and Punishment damage effect");
|
||||
const effectsFor = (targetTags: readonly string[]) => romEffectsForCast(crimeAndPunishment, {
|
||||
sealStacks: 0,
|
||||
bleedCount: 0,
|
||||
charged: false,
|
||||
mainHandDagger: false,
|
||||
casterMaxHealth: 10_000,
|
||||
targetHealthPercent: 1,
|
||||
targetTags,
|
||||
});
|
||||
|
||||
const oneState = effectsFor([
|
||||
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
|
||||
]).find((effect) => effect.kind === "damage");
|
||||
const twoStates = effectsFor([
|
||||
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
|
||||
ROM_WARLOCK_STATE_TAGS.soulPain,
|
||||
]).find((effect) => effect.kind === "damage");
|
||||
const threeStates = effectsFor([
|
||||
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
|
||||
ROM_WARLOCK_STATE_TAGS.soulPain,
|
||||
ROM_WARLOCK_STATE_TAGS.soulPoisonedFang,
|
||||
]).find((effect) => effect.kind === "damage");
|
||||
expect(oneState).toEqual(baseDamage);
|
||||
expect(twoStates).toMatchObject({
|
||||
coefficient: baseDamage.coefficient * 1.25,
|
||||
basePoints: baseDamage.basePoints! * 1.25,
|
||||
});
|
||||
expect(threeStates).toMatchObject({
|
||||
coefficient: baseDamage.coefficient * 1.5,
|
||||
basePoints: baseDamage.basePoints! * 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes the active three Warlock state effects after Crime and Punishment", () => {
|
||||
prepareWarlock("rom-rogue");
|
||||
registerTarget();
|
||||
for (const spellId of [497960, 497964, 498670]) {
|
||||
const stateSpell = ability(spellId);
|
||||
const castAt = spellId === 497960 ? 1_000 : spellId === 497964 ? 2_500 : 4_000;
|
||||
expect(useCombatStore.getState().castAbility(stateSpell.id, { targetActorId: TARGET_ID, now: castAt }).ok).toBe(true);
|
||||
finishCast(castAt);
|
||||
}
|
||||
useCombatStore.getState().tick(2.1, 6_100);
|
||||
const before = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot"
|
||||
&& effect.targetId === TARGET_ID
|
||||
&& ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`));
|
||||
expect(before.map((effect) => effect.remainingTicks).every((ticks) => ticks > 0)).toBe(true);
|
||||
expect(before).toHaveLength(3);
|
||||
expect(before.every((effect) => {
|
||||
const sourceDot = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId)
|
||||
?.effects.find((entry): entry is Extract<AbilityEffect, { kind: "dot" }> => (
|
||||
entry.kind === "dot" && entry.tag === effect.tag
|
||||
));
|
||||
if (sourceDot?.kind !== "dot") return false;
|
||||
return effect.remainingTicks < sourceDot.ticks;
|
||||
})).toBe(true);
|
||||
|
||||
const crimeAndPunishment = ability(498700);
|
||||
expect(useCombatStore.getState().castAbility(crimeAndPunishment.id, { targetActorId: TARGET_ID, now: 6_200 }).ok).toBe(true);
|
||||
const after = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot"
|
||||
&& effect.targetId === TARGET_ID
|
||||
&& ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`));
|
||||
expect(after).toHaveLength(before.length);
|
||||
expect(after.every((effect) => effect.nextTickAt > 6_200)).toBe(true);
|
||||
expect(after.map((effect) => effect.remainingTicks)).toEqual(before.map((effect) => {
|
||||
const source = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId);
|
||||
return source?.effects.find((entry): entry is Extract<AbilityEffect, { kind: "dot" }> => (
|
||||
entry.kind === "dot" && entry.tag === effect.tag
|
||||
))?.ticks;
|
||||
}));
|
||||
});
|
||||
|
||||
it("applies Liquidation Suffering to its selected ally and the rest of the party", () => {
|
||||
prepareWarlock("rom-rogue");
|
||||
usePartyStore.getState().initializeParty(
|
||||
"rom-warlock-liquidation-suffering", 80, useCombatStore.getState().health, "damage", 3, "rom",
|
||||
);
|
||||
const [selectedAlly, otherAlly] = usePartyStore.getState().members;
|
||||
expect(selectedAlly).toBeDefined();
|
||||
expect(otherAlly).toBeDefined();
|
||||
registerPartyRuntimePosition(selectedAlly!.id, [4, 0, 0]);
|
||||
registerPartyRuntimePosition(otherAlly!.id, [6, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const liquidation = ability(498695);
|
||||
expect(useCombatStore.getState().castAbility(liquidation.id, {
|
||||
targetActorId: selectedAlly!.id,
|
||||
now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
|
||||
const threatAuras = useCombatStore.getState().auras.filter((aura) => (
|
||||
aura.definition.name.startsWith("Liquidation Suffering")
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat")
|
||||
));
|
||||
const modifierFor = (targetId: string) => threatAuras.find((aura) => aura.targetId === targetId)
|
||||
?.definition.modifiers?.find((modifier) => modifier.kind === "stat" && modifier.stat === "threat");
|
||||
expect(modifierFor(selectedAlly!.id)).toMatchObject({ value: 0.05 });
|
||||
expect(modifierFor(PLAYER_AURA_ENTITY_ID)).toMatchObject({ value: -0.05 });
|
||||
expect(modifierFor(otherAlly!.id)).toMatchObject({ value: -0.05 });
|
||||
expect(threatAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => (
|
||||
modifier.kind === "stat" && modifier.stat === "threat" && modifier.value > 0
|
||||
)))).toHaveLength(1);
|
||||
expect(threatAuras.some((aura) => aura.targetId === selectedAlly!.id
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "threat" && modifier.value < 0))).toBe(false);
|
||||
});
|
||||
|
||||
it("grants Defense Net's source-described control immunity to its friendly target", () => {
|
||||
prepareWarlock("rom-warrior");
|
||||
usePartyStore.getState().initializeParty(
|
||||
"rom-warlock-defense-net", 80, useCombatStore.getState().health, "damage", 3, "rom",
|
||||
);
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
registerPartyRuntimePosition(member.id, [4, 0, 0]);
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, controlMechanic: "stun" as const, controlledUntil: 5_000 }
|
||||
: candidate),
|
||||
}));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const defenseNet = ability(498549);
|
||||
expect(useCombatStore.getState().castAbility(defenseNet.id, {
|
||||
targetActorId: member.id,
|
||||
now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
finishCast(1_000);
|
||||
|
||||
const updatedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id);
|
||||
expect(updatedMember).toMatchObject({ controlMechanic: null, controlledUntil: 0 });
|
||||
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id
|
||||
&& candidate.definition.name.startsWith("Defense Net"));
|
||||
expect(aura).toMatchObject({
|
||||
expiresAt: 6_000,
|
||||
definition: {
|
||||
controlImmunities: ["fear", "stun", "root", "slow"],
|
||||
modifiers: expect.arrayContaining([expect.objectContaining({ direction: "taken", value: -0.5 })]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reduces Defense Net to four seconds only for the Warlock/Priest Mind Shield pair", () => {
|
||||
prepareWarlock("rom-priest");
|
||||
usePartyStore.getState().initializeParty(
|
||||
"rom-warlock-mind-shield", 80, useCombatStore.getState().health, "damage", 3, "rom",
|
||||
);
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
registerPartyRuntimePosition(member.id, [4, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const defenseNet = ability(498549);
|
||||
expect(useCombatStore.getState().castAbility(defenseNet.id, {
|
||||
targetActorId: member.id,
|
||||
now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
finishCast(1_000);
|
||||
|
||||
expect(useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id
|
||||
&& candidate.definition.name.startsWith("Defense Net"))?.expiresAt).toBe(5_000);
|
||||
});
|
||||
|
||||
it("resolves Warlock paired named resets to only their source-named spells", () => {
|
||||
const expectations = new Map<number, number[]>([
|
||||
[498744, [491156]], // Static Resonance -> Electrostatic Charge
|
||||
[498758, [490225, 490251]], // Deep Inspiration -> Intensification and Energy Well
|
||||
[498662, [490256]], // Touch of Faith -> Rising Tide
|
||||
[498710, [498697]], // Soul Brand Sting -> Flaming Heart Strike
|
||||
]);
|
||||
for (const [sourceSpellId, expectedTargets] of expectations) {
|
||||
const source = ability(sourceSpellId);
|
||||
const activePair = abilitiesForCharacter(source.classId, source.secondaryClassId);
|
||||
const targets = source.effects.flatMap((effect) => effect.kind === "cooldown-reset"
|
||||
? cooldownResetCandidates(activePair, source.id, effect.abilityName).map((candidate) => candidate.dbcSpellId)
|
||||
: []);
|
||||
expect(targets.sort((left, right) => left - right), `source ${sourceSpellId}`).toEqual(
|
||||
[...expectedTargets].sort((left, right) => left - right),
|
||||
);
|
||||
}
|
||||
for (const sourceSpellId of [498634, 498648, 498668]) {
|
||||
expect(ability(sourceSpellId).effects.some((effect) => effect.kind === "cooldown-reset"), `source ${sourceSpellId}`)
|
||||
.toBe(false);
|
||||
}
|
||||
expect(ROM_ALL_ABILITIES.filter((candidate) => candidate.classId === "rom-warlock")
|
||||
.flatMap((candidate) => candidate.effects.filter((effect) => effect.kind === "cooldown-reset"
|
||||
&& effect.abilityName === undefined).map((effect) => candidate.dbcSpellId))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user