Update Healer Man server
This commit is contained in:
1 parent
69809f7228
commit
ff20ab8779
101 files changed
+16713
-945
No files matched your search
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, abilityAtLevel, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
|
||||
const NOW = 2_000;
|
||||
// Ten valid first-row Frost points satisfy Lichborne's current tree gate.
|
||||
const TALENTS = { "talent-2031": 3, "talent-2020": 2, "talent-1968": 5, "talent-2215": 1 };
|
||||
const LICHBORNE_ID = "wow335-death-knight-lichborne";
|
||||
|
||||
function prepare(options: {
|
||||
readonly recipient: "player" | "party";
|
||||
readonly spellId?: 19134 | 20511;
|
||||
readonly learned?: boolean;
|
||||
readonly genericControl?: "fear" | "stun" | "confuse" | "sleep";
|
||||
readonly standaloneControl?: boolean;
|
||||
readonly cooldownReadyAt?: number;
|
||||
readonly globalCooldownEndsAt?: number;
|
||||
}) {
|
||||
const spellId = options.spellId ?? 19134;
|
||||
const mechanic = options.standaloneControl ? options.genericControl!
|
||||
: spellId === 20511 ? "stun" : "fear";
|
||||
const controlledUntil = options.standaloneControl ? 4_000 : 9_000;
|
||||
const talentRanks = options.learned === false ? {} : TALENTS;
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: options.recipient === "player" ? "death-knight" : "priest",
|
||||
level: 80,
|
||||
talentRanks: options.recipient === "player" ? talentRanks : {},
|
||||
});
|
||||
usePartyStore.getState().initializeParty("lichborne-escape-fixture", 80,
|
||||
useCombatStore.getState().health, "damage", 1);
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Configured Lichborne Knight", classId: "death-knight",
|
||||
specialization: "Frost", specializationId: "death-knight:frost",
|
||||
talentTreeId: "death-knight-frost", talentRanks, role: "damage",
|
||||
}, 80, 0, "lichborne-escape-fixture");
|
||||
usePartyStore.setState({ members: options.recipient === "party" ? [{
|
||||
...member, controlledUntil, controlMechanic: mechanic, status: "holding",
|
||||
cooldowns: options.cooldownReadyAt === undefined ? member.cooldowns
|
||||
: { ...member.cooldowns, [LICHBORNE_ID]: options.cooldownReadyAt },
|
||||
globalCooldownEndsAt: options.globalCooldownEndsAt ?? 0,
|
||||
}] : [] });
|
||||
const targetId = options.recipient === "player" ? PLAYER_AGGRO_ID : member.id;
|
||||
if (options.recipient === "party") registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.setState((state) => {
|
||||
let auras = options.standaloneControl ? state.auras : applyAura(state.auras, { definition: {
|
||||
id: `enemy-control:source-spell-${spellId}`, name: "Pinned source shout fixture",
|
||||
disposition: "debuff", durationMs: 8_000,
|
||||
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.15 }],
|
||||
}, sourceId: "synthetic-enemy", targetId, now: 1_000 }).auras;
|
||||
if (options.genericControl) auras = applyAura(auras, { definition: {
|
||||
id: `enemy-control:separate-enemy:separate-cast:${options.genericControl}`, name: "Independent control fixture",
|
||||
disposition: "debuff", durationMs: 3_000,
|
||||
}, sourceId: "separate-enemy", targetId, now: 1_000 }).auras;
|
||||
return {
|
||||
auras: [...auras],
|
||||
controlledUntil: options.recipient === "player" ? controlledUntil : 0,
|
||||
controlMechanic: options.recipient === "player" ? mechanic : null,
|
||||
cooldowns: options.recipient === "player" && options.cooldownReadyAt !== undefined
|
||||
? { ...state.cooldowns, [LICHBORNE_ID]: options.cooldownReadyAt } : state.cooldowns,
|
||||
globalCooldownEndsAt: options.recipient === "player" ? options.globalCooldownEndsAt ?? 0 : 0,
|
||||
};
|
||||
});
|
||||
return targetId;
|
||||
}
|
||||
|
||||
function hasSourceControl(targetId: string) {
|
||||
return useCombatStore.getState().auras.some((aura) => aura.targetId === targetId
|
||||
&& aura.definition.id.startsWith("enemy-control:source-spell-"));
|
||||
}
|
||||
function hasLichborneFearGrant(targetId: string) {
|
||||
return useCombatStore.getState().auras.some((aura) => aura.targetId === targetId
|
||||
&& aura.definition.id.startsWith("wow335:49039:")
|
||||
&& aura.definition.controlImmunities?.includes("fear"));
|
||||
}
|
||||
|
||||
describe("source-bound Lichborne player and configured companion escape", () => {
|
||||
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["death-knight"]); });
|
||||
afterEach(() => {
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
});
|
||||
|
||||
it.each([19134, 20511] as const)("casts learned player Lichborne through source control %s", (spellId) => {
|
||||
const id = prepare({ recipient: "player", spellId });
|
||||
const selected = abilityAtLevel(abilitiesForClass("death-knight")
|
||||
.find((ability) => ability.id === LICHBORNE_ID)!, 80);
|
||||
expect(selected.dbcSpellId).toBe(49039);
|
||||
expect(useCombatStore.getState().talentRanks["talent-2215"]).toBe(1);
|
||||
const cast = useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
expect(hasLichborneFearGrant(id)).toBe(true);
|
||||
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
|
||||
expect(useCombatStore.getState().cooldowns[LICHBORNE_ID]).toBe(NOW + 120_000);
|
||||
useCombatStore.getState().tick(0.1, NOW + 100);
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([19134, 20511] as const)("uses learned companion Lichborne through source control %s", (spellId) => {
|
||||
const id = prepare({ recipient: "party", spellId });
|
||||
advanceDungeonPartyCombat(NOW);
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
expect(hasLichborneFearGrant(id)).toBe(true);
|
||||
expect(usePartyStore.getState().members[0]).toMatchObject({ controlledUntil: 0, controlMechanic: null });
|
||||
expect(useCombatStore.getState().actors[id]?.controlledUntil).toBe(0);
|
||||
expect(usePartyStore.getState().members[0]!.cooldowns[LICHBORNE_ID]).toBe(NOW + 120_000);
|
||||
});
|
||||
|
||||
it.each(["player", "party"] as const)("keeps %s controlled when Lichborne is unlearned, cooling down or blocked by unsupported sleep", (recipient) => {
|
||||
for (const condition of [{ learned: false }, { cooldownReadyAt: 10_000 }, { genericControl: "sleep" as const }]) {
|
||||
const id = prepare({ recipient, ...condition });
|
||||
if (recipient === "player") expect(useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW }).ok).toBe(false);
|
||||
else advanceDungeonPartyCombat(NOW);
|
||||
expect(hasSourceControl(id)).toBe(true);
|
||||
expect(hasLichborneFearGrant(id)).toBe(false);
|
||||
if (condition.genericControl) expect(useCombatStore.getState().auras.some((aura) => aura.targetId === id
|
||||
&& aura.definition.id === "enemy-control:separate-enemy:separate-cast:sleep")).toBe(true);
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["player", "party"] as const)("permits %s casts through source-authorized fear/stun/confuse while preserving unrelated control", (recipient) => {
|
||||
for (const genericControl of ["fear", "stun", "confuse"] as const) {
|
||||
for (const standaloneControl of [false, true]) {
|
||||
const id = prepare({ recipient, genericControl, standaloneControl });
|
||||
if (recipient === "player") {
|
||||
const cast = useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
}
|
||||
else advanceDungeonPartyCombat(NOW);
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
expect(hasLichborneFearGrant(id)).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === id
|
||||
&& aura.definition.id === `enemy-control:separate-enemy:separate-cast:${genericControl}`)).toBe(true);
|
||||
const actor = recipient === "player" ? useCombatStore.getState() : usePartyStore.getState().members[0]!;
|
||||
expect(actor.controlledUntil).toBe(4_000);
|
||||
expect(actor.controlMechanic).toBe(genericControl);
|
||||
if (recipient === "party") expect(usePartyStore.getState().members[0]!.status).toBe("holding");
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["player", "party"] as const)("preserves the existing %s GCD when source off-GCD Lichborne escapes", (recipient) => {
|
||||
const id = prepare({ recipient, globalCooldownEndsAt: 10_000 });
|
||||
if (recipient === "player") {
|
||||
const cast = useCombatStore.getState().castAbility(LICHBORNE_ID, { now: NOW });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
}
|
||||
else advanceDungeonPartyCombat(NOW);
|
||||
expect(hasSourceControl(id)).toBe(false);
|
||||
expect(recipient === "player" ? useCombatStore.getState().globalCooldownEndsAt
|
||||
: usePartyStore.getState().members[0]!.globalCooldownEndsAt).toBe(10_000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user