forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,345 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { abilitiesForClass, abilityById, type AbilityDefinition } from "./abilityCatalog";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { equipmentAdjustedCombatAmount, useCombatStore } from "./combatStore";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import {
|
||||
coaWitchDoctorBrewingAbilityForCast,
|
||||
COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD,
|
||||
} from "./coaWitchDoctorBrewingRuntime";
|
||||
|
||||
const PLAYER_AURA_ID = PLAYER_AGGRO_ID;
|
||||
const FROG_BONES_MARKER_ID = "coa-witch-doctor:active-ingredient:frog-bones";
|
||||
const JUNGLE_SHROOMS_MARKER_ID = "coa-witch-doctor:active-ingredient:jungle-shrooms";
|
||||
|
||||
function abilityByTalentEntry(talentEntryId: number): AbilityDefinition {
|
||||
const ability = abilitiesForClass("witch-doctor").find(candidate => candidate.talentEntryId === talentEntryId);
|
||||
if (!ability) throw new Error(`Missing Witch Doctor Talent action ${talentEntryId}`);
|
||||
return ability;
|
||||
}
|
||||
|
||||
function ranksFor(...entries: readonly number[]): Record<string, number> {
|
||||
return Object.fromEntries(entries.map(entry => [`coa-entry-${entry}`, 1]));
|
||||
}
|
||||
|
||||
function initializeWitchDoctorPlayer(entries: readonly number[] = []): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "witch-doctor",
|
||||
specializationId: "witch-doctor:brewing",
|
||||
role: "healer",
|
||||
level: 60,
|
||||
talentRanks: ranksFor(...entries),
|
||||
});
|
||||
// Test configurations are deliberately surgical and do not model a legal build allocation.
|
||||
useCombatStore.setState(state => ({ talentRanks: { ...state.talentRanks, ...ranksFor(...entries) } }));
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
}
|
||||
|
||||
function initializePlayerAndParty(playerClassId: ClassId = "mage", playerRole: "damage" | "tank" = "damage") {
|
||||
useCombatStore.getState().initializeCharacter({ classId: playerClassId, level: 60, role: playerRole });
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
usePartyStore.getState().initializeParty("coa-witch-doctor-brewing-integration", 60,
|
||||
useCombatStore.getState().health, playerRole, 1, "coa");
|
||||
}
|
||||
|
||||
function makeCompanion(
|
||||
name: string,
|
||||
role: "healer" | "tank" | "damage",
|
||||
entries: readonly number[],
|
||||
index: number,
|
||||
) {
|
||||
return createConfiguredPartyMember({
|
||||
name,
|
||||
classId: "witch-doctor",
|
||||
specialization: "Brewing",
|
||||
specializationId: "witch-doctor:brewing",
|
||||
talentTreeId: "witch-doctor-brewing",
|
||||
talentRanks: ranksFor(...entries),
|
||||
role,
|
||||
}, 60, index, "coa-witch-doctor-brewing-integration");
|
||||
}
|
||||
|
||||
function installParty(members: ReturnType<typeof makeCompanion>[]): void {
|
||||
usePartyStore.setState({ members });
|
||||
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [index + 1, 0, 0]));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
}
|
||||
|
||||
function activeMarker(casterId: string, ability: AbilityDefinition, markerId: string, now: number) {
|
||||
const projected = coaWitchDoctorBrewingAbilityForCast(ability, [], casterId, now);
|
||||
const marker = projected.effects.find(effect => effect.kind === "apply-aura" && effect.aura.id === markerId);
|
||||
if (!marker || marker.kind !== "apply-aura") throw new Error(`Missing ${markerId} marker effect`);
|
||||
return { projected, marker };
|
||||
}
|
||||
|
||||
function setTargetHealth(memberId: string, health: number): void {
|
||||
usePartyStore.setState(state => ({
|
||||
members: state.members.map(member => member.id === memberId
|
||||
? { ...member, health: Math.max(0, Math.min(member.maxHealth, health)) }
|
||||
: member),
|
||||
}));
|
||||
}
|
||||
|
||||
function castPlayerAbility(ability: AbilityDefinition, targetActorId: string | null, now: number) {
|
||||
return useCombatStore.getState().castAbility(ability.id, { targetActorId, now });
|
||||
}
|
||||
|
||||
function clearRegistries(): void {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
}
|
||||
|
||||
describe("CoA Witch Doctor Brewing player and companion integration", () => {
|
||||
beforeEach(() => {
|
||||
clearRegistries();
|
||||
});
|
||||
|
||||
it("casts Frog Bones as an actor-owned ingredient marker without granting its malformed generic caster shield", () => {
|
||||
initializeWitchDoctorPlayer([29741, 12646]);
|
||||
const frogBones = abilityByTalentEntry(29741);
|
||||
|
||||
const result = castPlayerAbility(frogBones, null, 1_000);
|
||||
expect(result).toMatchObject({ ok: true, abilityId: frogBones.id });
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
const marker = state.auras.find(aura => aura.definition.id === FROG_BONES_MARKER_ID);
|
||||
expect(marker).toMatchObject({
|
||||
sourceId: PLAYER_AURA_ID,
|
||||
targetId: PLAYER_AURA_ID,
|
||||
expiresAt: null,
|
||||
stacks: 1,
|
||||
definition: { maxStacks: 1, stackBehavior: "refresh" },
|
||||
});
|
||||
expect(state.shield).toBe(0);
|
||||
expect(state.auras.some(aura => aura.targetId === PLAYER_AURA_ID
|
||||
&& (aura.absorbs.length > 0 || aura.definition.absorbs?.length))).toBe(false);
|
||||
expect(state.combatEvents.some(event => event.kind === "absorb" && event.abilityId === frogBones.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a Brewing companion toss Frog Bones' sourced shield onto the injured player for eight seconds", () => {
|
||||
initializePlayerAndParty();
|
||||
const healer = makeCompanion("Brewing companion", "healer", [12646, 29741], 0);
|
||||
installParty([healer]);
|
||||
|
||||
const frogBones = abilityByTalentEntry(29741);
|
||||
const { projected, marker } = activeMarker(healer.id, frogBones, FROG_BONES_MARKER_ID, 500);
|
||||
expect(useCombatStore.getState().applyActorAura(healer.id, healer.id, projected, marker, 500)).toBe(true);
|
||||
const member = usePartyStore.getState().members[0];
|
||||
const potionToss = abilityByTalentEntry(12646);
|
||||
usePartyStore.setState(state => ({
|
||||
members: state.members.map(candidate => candidate.id === healer.id
|
||||
? {
|
||||
...candidate,
|
||||
cooldowns: Object.fromEntries([
|
||||
...abilitiesForClass("witch-doctor"),
|
||||
abilityById("coa-13-loas-brew-49-801670")!,
|
||||
].map(ability => [ability.id, ability.id === potionToss.id ? 0 : 60_000])),
|
||||
globalCooldownEndsAt: 0,
|
||||
nextActionAt: 0,
|
||||
}
|
||||
: candidate),
|
||||
}));
|
||||
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
|
||||
setHealthForPlayer(0.5);
|
||||
useCombatStore.getState().registerMob("brew-integration-mob", {
|
||||
name: "Brewing integration target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("brew-integration-mob", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("brew-integration-mob", 1, 100);
|
||||
usePartyStore.setState({ command: "attack", activeMobId: "brew-integration-mob" });
|
||||
updatePartyRuntimeTargetVisibility(healer.id, "brew-integration-mob", true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents(event => events.push(event));
|
||||
try {
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
const absorb = events.find(event => event.kind === "absorb" && event.abilityId === potionToss.id);
|
||||
const expectedAmount = equipmentAdjustedCombatAmount(
|
||||
COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD,
|
||||
member.level,
|
||||
member.gearStats,
|
||||
"witch-doctor",
|
||||
potionToss,
|
||||
0,
|
||||
undefined,
|
||||
member.raceId,
|
||||
member.gender,
|
||||
member.statRuleset ?? "wow",
|
||||
member.secondaryClassId,
|
||||
);
|
||||
expect(events).toEqual(expect.arrayContaining([expect.objectContaining({
|
||||
kind: "healing",
|
||||
sourceActorId: healer.id,
|
||||
targetActorId: PLAYER_AURA_ID,
|
||||
abilityId: potionToss.id,
|
||||
effectiveAmount: expect.any(Number),
|
||||
})]));
|
||||
expect(absorb).toMatchObject({
|
||||
sourceActorId: healer.id,
|
||||
targetActorId: PLAYER_AURA_ID,
|
||||
rawAmount: expectedAmount,
|
||||
effectiveAmount: expectedAmount,
|
||||
absorbed: expectedAmount,
|
||||
});
|
||||
const shieldAura = useCombatStore.getState().auras.find(aura => (
|
||||
aura.sourceId === healer.id && aura.targetId === PLAYER_AURA_ID
|
||||
&& aura.definition.id === `shield:${healer.id}:${potionToss.id}`
|
||||
));
|
||||
expect(shieldAura).toMatchObject({ appliedAt: 1_000, expiresAt: 9_000 });
|
||||
expect(shieldAura?.definition.absorbs?.[0]?.amount).toBe(expectedAmount);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(Math.round(useCombatStore.getState().maxHealth / 2));
|
||||
});
|
||||
|
||||
it("executes a companion Frog Bones cast through the party runtime and stores its marker on that caster", () => {
|
||||
initializePlayerAndParty();
|
||||
const healer = makeCompanion("Brewing ingredient caster", "healer", [29741], 0);
|
||||
installParty([healer]);
|
||||
const frogBones = abilityByTalentEntry(29741);
|
||||
const completedAt = 2_000;
|
||||
// Ingredient actions have no NPC auto-selection policy. Seed an otherwise
|
||||
// valid in-flight companion cast so the normal party cast-completion path
|
||||
// resolves Frog Bones without inventing an AI priority.
|
||||
usePartyStore.setState(state => ({
|
||||
members: state.members.map(candidate => candidate.id === healer.id
|
||||
? {
|
||||
...candidate,
|
||||
activeCast: {
|
||||
abilityId: frogBones.id,
|
||||
school: "nature",
|
||||
mode: "cast",
|
||||
startedAt: completedAt - 1,
|
||||
completesAt: completedAt,
|
||||
originalDurationMs: 1,
|
||||
pushbackHits: 0,
|
||||
origin: [1, 0, 0] as const,
|
||||
targetId: healer.id,
|
||||
},
|
||||
activeCastTargetId: healer.id,
|
||||
nextActionAt: completedAt,
|
||||
}
|
||||
: candidate),
|
||||
}));
|
||||
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
|
||||
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents(event => events.push(event));
|
||||
try {
|
||||
advanceDungeonPartyCombat(completedAt);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
const marker = state.auras.find(aura => aura.definition.id === FROG_BONES_MARKER_ID);
|
||||
expect(marker).toMatchObject({
|
||||
sourceId: healer.id,
|
||||
targetId: healer.id,
|
||||
expiresAt: null,
|
||||
stacks: 1,
|
||||
definition: { maxStacks: 1, stackBehavior: "refresh" },
|
||||
});
|
||||
expect(state.auras.some(aura => aura.sourceId === healer.id && aura.targetId === healer.id
|
||||
&& (aura.absorbs.length > 0 || aura.definition.absorbs?.length))).toBe(false);
|
||||
expect(state.actors[healer.id]?.absorb).toBe(0);
|
||||
expect(events.some(event => event.kind === "absorb" && event.sourceActorId === healer.id
|
||||
&& event.abilityId === frogBones.id)).toBe(false);
|
||||
expect(usePartyStore.getState().members.find(candidate => candidate.id === healer.id)!.activeCast).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let another actor's Frog Bones marker enable the player's Potion Toss rider", () => {
|
||||
initializeWitchDoctorPlayer([12646]);
|
||||
const otherBrewer = makeCompanion("Other brewer", "damage", [29741], 0);
|
||||
const recipient = createConfiguredPartyMember({
|
||||
name: "Injured ally",
|
||||
classId: "warrior",
|
||||
specialization: "Protection",
|
||||
specializationId: "warrior:protection",
|
||||
talentTreeId: "warrior-protection",
|
||||
role: "tank",
|
||||
}, 60, 1, "coa-witch-doctor-brewing-integration");
|
||||
installParty([otherBrewer, recipient]);
|
||||
setTargetHealth(recipient.id, Math.round(recipient.maxHealth / 2));
|
||||
const frogBones = abilityByTalentEntry(29741);
|
||||
const potionToss = abilityByTalentEntry(12646);
|
||||
const firstEvents: StructuredCombatEvent[] = [];
|
||||
const stopFirst = subscribeCombatEvents(event => firstEvents.push(event));
|
||||
try {
|
||||
expect(castPlayerAbility(potionToss, recipient.id, 1_000).ok).toBe(true);
|
||||
} finally {
|
||||
stopFirst();
|
||||
}
|
||||
expect(firstEvents.some(event => event.kind === "absorb" && event.abilityId === potionToss.id)).toBe(false);
|
||||
expect(useCombatStore.getState().auras.some(aura => aura.sourceId === PLAYER_AURA_ID
|
||||
&& aura.targetId === recipient.id && aura.definition.id.includes("shield"))).toBe(false);
|
||||
|
||||
setTargetHealth(recipient.id, Math.round(recipient.maxHealth / 2));
|
||||
const { projected, marker } = activeMarker(otherBrewer.id, frogBones, FROG_BONES_MARKER_ID, 10_000);
|
||||
expect(useCombatStore.getState().applyActorAura(otherBrewer.id, otherBrewer.id, projected, marker, 10_000)).toBe(true);
|
||||
const foreignMarkerEvents: StructuredCombatEvent[] = [];
|
||||
const stopSecond = subscribeCombatEvents(event => foreignMarkerEvents.push(event));
|
||||
try {
|
||||
expect(castPlayerAbility(potionToss, recipient.id, 20_000).ok).toBe(true);
|
||||
} finally {
|
||||
stopSecond();
|
||||
}
|
||||
expect(foreignMarkerEvents.some(event => event.kind === "absorb" && event.abilityId === potionToss.id)).toBe(false);
|
||||
expect(useCombatStore.getState().auras.some(aura => aura.sourceId === PLAYER_AURA_ID
|
||||
&& aura.targetId === recipient.id && aura.definition.id.includes("shield"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a second ingredient at capacity before cost, cooldown, or aura state changes, while Mixologist allows two", () => {
|
||||
const jungleShrooms = abilityByTalentEntry(12645);
|
||||
const frogBones = abilityByTalentEntry(29741);
|
||||
|
||||
initializeWitchDoctorPlayer([12645, 29741, 12646]);
|
||||
expect(castPlayerAbility(jungleShrooms, null, 1_000).ok).toBe(true);
|
||||
const fullBefore = useCombatStore.getState();
|
||||
const resourceBefore = fullBefore.resource;
|
||||
const resourcePoolsBefore = fullBefore.resourcePools;
|
||||
const cooldownsBefore = fullBefore.cooldowns;
|
||||
const globalCooldownBefore = fullBefore.globalCooldownEndsAt;
|
||||
const aurasBefore = fullBefore.auras;
|
||||
expect(castPlayerAbility(frogBones, null, 2_600)).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
const fullAfter = useCombatStore.getState();
|
||||
expect(fullAfter.resource).toBe(resourceBefore);
|
||||
expect(fullAfter.resourcePools).toEqual(resourcePoolsBefore);
|
||||
expect(fullAfter.cooldowns).toEqual(cooldownsBefore);
|
||||
expect(fullAfter.globalCooldownEndsAt).toBe(globalCooldownBefore);
|
||||
expect(fullAfter.auras).toEqual(aurasBefore);
|
||||
expect(fullAfter.auras.some(aura => aura.definition.id === JUNGLE_SHROOMS_MARKER_ID
|
||||
&& aura.sourceId === PLAYER_AURA_ID && aura.targetId === PLAYER_AURA_ID)).toBe(true);
|
||||
expect(fullAfter.auras.some(aura => aura.definition.id === FROG_BONES_MARKER_ID)).toBe(false);
|
||||
|
||||
initializeWitchDoctorPlayer([12645, 29741, 12646, 6645]);
|
||||
expect(castPlayerAbility(jungleShrooms, null, 1_000).ok).toBe(true);
|
||||
expect(castPlayerAbility(frogBones, null, 2_600).ok).toBe(true);
|
||||
const activeIngredientMarkers = useCombatStore.getState().auras.filter(aura => (
|
||||
aura.sourceId === PLAYER_AURA_ID && aura.targetId === PLAYER_AURA_ID
|
||||
&& [JUNGLE_SHROOMS_MARKER_ID, FROG_BONES_MARKER_ID].includes(aura.definition.id)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > 2_600)
|
||||
));
|
||||
expect(activeIngredientMarkers.map(aura => aura.definition.id).sort()).toEqual([
|
||||
FROG_BONES_MARKER_ID,
|
||||
JUNGLE_SHROOMS_MARKER_ID,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function setHealthForPlayer(fraction: number): void {
|
||||
useCombatStore.setState(state => ({ health: Math.round(state.maxHealth * fraction) }));
|
||||
}
|
||||
Reference in new issue
Block a user