Release Healer Man 0.1.4

This commit is contained in:
phenom
2026-08-18 12:06:04 -04:00
parent 5bb170039a
commit f4c9cf356c
63 changed files with 2694 additions and 81 deletions
+112
View File
@@ -20,6 +20,7 @@ import { EMPTY_ITEM_STATS } from "./itemStats";
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
import { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
function allocateTalentRanks(name: string, ranks: number): void {
const talent = TALENT_NODES.find((node) => node.name === name);
@@ -94,6 +95,26 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
});
it("emits exactly one cast and release event and cancels presentation with the cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
registerTarget("presentation-target", [10, 0, 0]);
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event") events.push(message.event);
});
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.getState().tick(0.1, 2_500);
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
.toEqual(["cast-start", "release", "impact"]);
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 4_000).ok).toBe(true);
expect(useCombatStore.getState().cancelCast()).toBe(true);
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
.toEqual(["cast-start", "release", "impact", "cast-start", "cast-cancel"]);
unsubscribe();
});
it("does not cast or bind Priest Holy Concentration", () => {
const abilityId = "wow335-priest-holy-concentration";
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({
@@ -385,6 +406,94 @@ describe("combat store", () => {
expect(useCombatStore.getState().health).toBe(healthBefore - 12);
});
it("automatically engages a dungeon pack when a living party actor enters its aggro range", () => {
const partyMemberId = preparePartyMember(0, [40, 0, 0]);
for (const [id, position] of [["proximity-pack:a", [20, 0, 0]], ["proximity-pack:b", [22, 0, 0]]] as const) {
setMobPosition(id, position);
useCombatStore.getState().registerMob(id, {
name: id,
maxHealth: 100,
});
}
setMobPosition("proximity-boss", [100, 0, 0]);
useCombatStore.getState().registerMob("proximity-boss", {
name: "Proximity Boss",
boss: true,
maxHealth: 100,
});
expect(useCombatStore.getState().mobs["proximity-pack:a"].aggroRange).toBe(16);
expect(useCombatStore.getState().mobs["proximity-boss"].aggroRange).toBe(20);
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().mobs["proximity-pack:a"].engaged).toBe(false);
updatePartyRuntimePosition(partyMemberId, 27, 0, 0);
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 0, 0])).toBe(1);
expect(useCombatStore.getState().mobs["proximity-pack:a"]).toMatchObject({
engaged: true,
targetActorId: partyMemberId,
homePosition: [20, 0, 0],
});
expect(useCombatStore.getState().mobs["proximity-pack:b"]).toMatchObject({
engaged: true,
targetActorId: partyMemberId,
homePosition: [22, 0, 0],
});
});
it("uses 3D distance for proximity aggro so stacked dungeon floors do not pull", () => {
setMobPosition("upper-floor-mob", [0, 20, 0]);
useCombatStore.getState().registerMob("upper-floor-mob", {
name: "Upper Floor Mob",
maxHealth: 100,
});
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(false);
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 8, 0])).toBe(1);
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(true);
});
it("anchors a roaming mob at the pull location and keeps aggro across vertical terrain", () => {
const id = "kresh-patrol:kresh-roaming";
setMobPosition(id, [0, -70, 0]);
useCombatStore.getState().registerMob(id, {
name: "Kresh",
boss: true,
maxHealth: 100,
leashRange: 55,
xpReward: 0,
});
// Kresh can travel far from the position where his model first mounted.
// His leash must begin at the patrol position where combat actually starts.
setMobPosition(id, [100, -106, 0]);
useCombatStore.getState().damageMob(id, 10, 1_000);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 90,
engaged: true,
homePosition: [100, -106, 0],
});
// A player falling from the ledge can be vertically farther than the leash
// while remaining beside the encounter in the dungeon's horizontal plane.
useCombatStore.getState().advanceMobCombat(1_450, [102, -30, 0]);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 90,
engaged: true,
combatPhase: "chasing",
});
// Horizontal distance still enforces the normal evade/reset boundary.
useCombatStore.getState().advanceMobCombat(1_500, [156, -106, 0]);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 100,
engaged: false,
combatPhase: "returning",
});
});
it("tracks damage and effective-healing threat independently for every engaged mob", () => {
for (const id of ["threat-pack:a", "threat-pack:b"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 500, xpReward: 0 });
@@ -728,11 +837,13 @@ describe("combat store", () => {
expect(useCombatStore.getState().talentRanks).toEqual({});
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
expect(useCombatStore.getState().settings.showMobAggroRanges).toBe(false);
useCombatStore.getState().updateSettings({
masterVolume: 9,
uiScale: 0.1,
minimapRotation: "north-up",
showThreatMeter: true,
showMobAggroRanges: true,
threatMeterPosition: { x: -2, y: 4 },
});
expect(useCombatStore.getState().settings).toMatchObject({
@@ -740,6 +851,7 @@ describe("combat store", () => {
uiScale: 0.75,
minimapRotation: "north-up",
showThreatMeter: true,
showMobAggroRanges: true,
threatMeterPosition: { x: 0, y: 1 },
});
useCombatStore.getState().initializeCharacter({ classId: "priest" });