Files
healer-man/src/game/romGroupHealRadius.integration.test.ts
2026-10-06 22:25:34 -04:00

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);
});
});