forked from phenom/healer-man
212 lines
9.4 KiB
TypeScript
212 lines
9.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import { abilitiesForCharacter } from "./abilityCatalog";
|
|
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
|
import { healingRecipientIds } from "./healingAbilityRules";
|
|
import type { CombatActorState } from "./combatActors";
|
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
|
|
|
const groupHeal = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490281)!;
|
|
const groupHealEffect = groupHeal.effects.find((effect) => effect.kind === "heal" && effect.scope === "party");
|
|
const GROUP_HEAL_ABILITY_ID = "rom-priest-primary-8";
|
|
|
|
function partyMember(
|
|
classId: "rom-priest" | "rom-knight" | "rom-scout" | "rom-rogue" | "rom-mage",
|
|
secondaryClassId: "rom-knight" | "rom-scout" | "rom-rogue" | "rom-warrior" | "rom-warlock",
|
|
role: "healer" | "tank" | "damage",
|
|
name: string,
|
|
index: number,
|
|
seed: string,
|
|
) {
|
|
return createConfiguredPartyMember({
|
|
name,
|
|
classId,
|
|
secondaryClassId,
|
|
raceId: classId === "rom-mage" ? "rom-dwarf" : classId === "rom-scout" || classId === "rom-rogue" ? "rom-elf" : "rom-human",
|
|
statRuleset: "rom",
|
|
specialization: "Primary",
|
|
specializationId: `${classId}:${secondaryClassId}`,
|
|
talentTreeId: "",
|
|
role,
|
|
}, 30, index, seed);
|
|
}
|
|
|
|
function installLineOfSightBlock(x: number): void {
|
|
registerCombatSpatialProvider({
|
|
actor: () => null,
|
|
hasLineOfSight: (_from, to) => to[0] !== x,
|
|
projectGroundPoint: (position) => position,
|
|
});
|
|
}
|
|
|
|
function createFixture(seed: string) {
|
|
const healer = partyMember("rom-priest", "rom-knight", "healer", "Priest", 0, seed);
|
|
const near = partyMember("rom-knight", "rom-scout", "tank", "Near ally", 1, seed);
|
|
const secondNear = partyMember("rom-scout", "rom-rogue", "damage", "Second near ally", 2, seed);
|
|
const far = partyMember("rom-rogue", "rom-warrior", "damage", "Far ally", 3, seed);
|
|
const blocked = partyMember("rom-mage", "rom-warlock", "damage", "Blocked ally", 4, seed);
|
|
return { healer, near, secondNear, far, blocked };
|
|
}
|
|
|
|
function placeParty(members: readonly ReturnType<typeof partyMember>[], positions: Readonly<Record<string, readonly [number, number, number]>>): void {
|
|
usePartyStore.setState({ members });
|
|
for (const member of members) {
|
|
const position = positions[member.name];
|
|
if (position) registerPartyRuntimePosition(member.id, position);
|
|
}
|
|
useCombatStore.getState().synchronizeActors();
|
|
}
|
|
|
|
function groupHealTargets(sourceId: string): readonly string[] {
|
|
return useCombatStore.getState().combatEvents
|
|
.filter((event) => event.kind === "healing" && event.sourceActorId === sourceId
|
|
&& event.abilityId === GROUP_HEAL_ABILITY_ID && event.effectiveAmount > 0)
|
|
.map((event) => event.targetActorId);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetCombatSpatialProvider();
|
|
resetPartyRuntimeClock();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetCombatSpatialProvider();
|
|
});
|
|
|
|
describe("RoM Group Heal radius", () => {
|
|
it("copies the authored radius onto only its party-heal effect and resolves living visible allies within 12m", () => {
|
|
expect(groupHeal.radius).toBe(12);
|
|
expect(groupHealEffect).toMatchObject({ kind: "heal", scope: "party", radius: 12 });
|
|
if (groupHealEffect?.kind !== "heal") throw new Error("Group Heal party-heal effect is missing");
|
|
|
|
const actors = Object.fromEntries([
|
|
[PLAYER_AGGRO_ID, { id: PLAYER_AGGRO_ID, kind: "player", alive: true, health: 50, maxHealth: 100 }],
|
|
["inside", { id: "inside", kind: "companion", alive: true, health: 50, maxHealth: 100 }],
|
|
["outside", { id: "outside", kind: "companion", alive: true, health: 50, maxHealth: 100 }],
|
|
["dead", { id: "dead", kind: "companion", alive: false, health: 0, maxHealth: 100 }],
|
|
["blocked", { id: "blocked", kind: "companion", alive: true, health: 50, maxHealth: 100 }],
|
|
["enemy", { id: "enemy", kind: "enemy", alive: true, health: 50, maxHealth: 100 }],
|
|
["legacy-range", { id: "legacy-range", kind: "companion", alive: true, health: 50, maxHealth: 100 }],
|
|
]) as unknown as Readonly<Record<string, CombatActorState>>;
|
|
const positions: Readonly<Record<string, readonly [number, number, number]>> = {
|
|
[PLAYER_AGGRO_ID]: [0, 0, 0],
|
|
inside: [12, 0, 0],
|
|
outside: [12.01, 0, 0],
|
|
dead: [3, 0, 0],
|
|
blocked: [6, 0, 0],
|
|
enemy: [2, 0, 0],
|
|
"legacy-range": [20, 0, 0],
|
|
};
|
|
const recipients = healingRecipientIds(
|
|
groupHealEffect,
|
|
PLAYER_AGGRO_ID,
|
|
null,
|
|
actors,
|
|
(id) => positions[id] ?? null,
|
|
[],
|
|
(_from, to) => to[0] !== 6,
|
|
);
|
|
expect(recipients).toEqual(expect.arrayContaining([PLAYER_AGGRO_ID, "inside"]));
|
|
for (const excluded of ["outside", "dead", "blocked", "enemy"]) expect(recipients).not.toContain(excluded);
|
|
|
|
const soulSource = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490288)!;
|
|
const soulHeal = soulSource.effects.find((effect) => effect.kind === "heal" && effect.scope === "party");
|
|
expect(soulSource.radius).toBe(9);
|
|
expect(soulHeal).toMatchObject({ kind: "heal", scope: "party" });
|
|
expect(soulHeal && "radius" in soulHeal ? soulHeal.radius : undefined).toBeUndefined();
|
|
if (soulHeal?.kind === "heal") {
|
|
expect(healingRecipientIds(
|
|
soulHeal,
|
|
PLAYER_AGGRO_ID,
|
|
null,
|
|
actors,
|
|
(id) => positions[id] ?? (id === "legacy-range" ? [20, 0, 0] : null),
|
|
[],
|
|
() => true,
|
|
)).toContain("legacy-range");
|
|
}
|
|
});
|
|
|
|
it("uses the 12m living and line-of-sight recipient set for player Group Heal", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30, role: "healer",
|
|
});
|
|
usePartyStore.getState().initializeParty("rom-group-heal-player-radius", 30,
|
|
useCombatStore.getState().health, "healer", 5, "rom");
|
|
const fixture = createFixture("rom-group-heal-player-radius");
|
|
const party = [fixture.near, fixture.secondNear, fixture.far, fixture.blocked].map((member) => ({
|
|
...member,
|
|
health: Math.round(member.maxHealth * 0.65),
|
|
}));
|
|
placeParty(party, {
|
|
"Near ally": [2, 0, 0],
|
|
"Second near ally": [10, 0, 0],
|
|
"Far ally": [13, 0, 0],
|
|
"Blocked ally": [6, 0, 0],
|
|
});
|
|
useCombatStore.getState().damagePlayer(Math.round(useCombatStore.getState().maxHealth * 0.25));
|
|
installLineOfSightBlock(6);
|
|
expect(useCombatStore.getState().castAbility(GROUP_HEAL_ABILITY_ID, undefined, 1_000)).toMatchObject({ ok: true });
|
|
useCombatStore.getState().tick(0.1, 4_100);
|
|
|
|
const recipients = groupHealTargets(PLAYER_AGGRO_ID);
|
|
expect(recipients).toEqual(expect.arrayContaining([PLAYER_AGGRO_ID, party[0]!.id, party[1]!.id]));
|
|
expect(recipients).not.toContain(party[2]!.id);
|
|
expect(recipients).not.toContain(party[3]!.id);
|
|
});
|
|
|
|
it("uses the same 12m living and line-of-sight recipient set for companion Group Heal", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 30 });
|
|
usePartyStore.getState().initializeParty("rom-group-heal-companion-radius", 30,
|
|
useCombatStore.getState().health, "damage", 5, "rom");
|
|
const fixture = createFixture("rom-group-heal-companion-radius");
|
|
const healerAbilities = abilitiesForCharacter("rom-priest", "rom-knight");
|
|
const party = [
|
|
{
|
|
...fixture.healer,
|
|
cooldowns: Object.fromEntries(healerAbilities.filter((ability) => ability.id !== GROUP_HEAL_ABILITY_ID)
|
|
.map((ability) => [ability.id, 60_000])),
|
|
},
|
|
{ ...fixture.near, health: Math.round(fixture.near.maxHealth * 0.7) },
|
|
{ ...fixture.secondNear, health: Math.round(fixture.secondNear.maxHealth * 0.75) },
|
|
{ ...fixture.far, health: Math.round(fixture.far.maxHealth * 0.75) },
|
|
{ ...fixture.blocked, health: Math.round(fixture.blocked.maxHealth * 0.75) },
|
|
];
|
|
placeParty(party, {
|
|
Priest: [0, 0, 0],
|
|
"Near ally": [2, 0, 0],
|
|
"Second near ally": [5, 0, 0],
|
|
"Far ally": [13, 0, 0],
|
|
"Blocked ally": [6, 0, 0],
|
|
});
|
|
installLineOfSightBlock(6);
|
|
useCombatStore.getState().registerMob("rom-group-heal-radius-boss", {
|
|
name: "Group Heal target", boss: true, maxHealth: 100_000, xpReward: 0,
|
|
});
|
|
setMobPosition("rom-group-heal-radius-boss", [2, 0, 2]);
|
|
useCombatStore.getState().damageMob("rom-group-heal-radius-boss", 1, 100);
|
|
usePartyStore.setState({ command: "attack", activeMobId: "rom-group-heal-radius-boss" });
|
|
for (const member of party) updatePartyRuntimeTargetVisibility(member.id, "rom-group-heal-radius-boss", true);
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
advanceDungeonPartyCombat(8_000);
|
|
|
|
const recipients = groupHealTargets(fixture.healer.id);
|
|
expect(recipients).toEqual(expect.arrayContaining([fixture.near.id, fixture.secondNear.id]));
|
|
expect(recipients).not.toContain(fixture.far.id);
|
|
expect(recipients).not.toContain(fixture.blocked.id);
|
|
});
|
|
});
|