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[], positions: Readonly>): 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>; const positions: Readonly> = { [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); }); });