import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { createConfiguredPartyMember, usePartyStore, type PartyMember } from "./partyStore"; import { useCombatStore } from "./combatStore"; import { useDungeonSimulationStore } from "./dungeonSimulationStore"; import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; import { ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES } from "./romDruidRogueCurrentCoa"; import { abilityAtLevel, type ResourceType } from "./abilityCatalog"; const RESOURCE_TYPES = ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES as readonly ResourceType[]; const STARTING_VALUES: Readonly> = { mana: 20, rage: 10, focus: 10, energy: 10, psi: 1, "nature-power": 1, health: 0, "runic-power": 0, "holy-power": 0, chi: 0, "soul-power": 0, }; function memberWithPools(seed: string, resources = RESOURCE_TYPES, index = 0): PartyMember { const member = createConfiguredPartyMember({ name: "Druid/Rogue", classId: "rom-druid", secondaryClassId: "rom-rogue", raceId: "rom-elf", specialization: "Druid/Rogue", specializationId: "rom-druid:rom-rogue", talentTreeId: "rom-druid-rom-rogue", talentRanks: {}, role: "damage", }, 80, index, seed); const resourcePools = Object.fromEntries( resources.map((resource) => [resource, STARTING_VALUES[resource]]), ) as Partial>; return { ...member, resource: resourcePools[member.resourceType] ?? member.resource, resourcePools, }; } function sinisterRequestId(): string { const request = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494023 && ability.classId === "rom-druid" && ability.secondaryClassId === "rom-rogue"); if (!request) throw new Error("Druid/Rogue Sinister Request catalog row is missing."); return request.id; } function assertPoolsAreFull(actorId: string, expectedResources: readonly ResourceType[]): void { const resources = useCombatStore.getState().actors[actorId]?.resources; expect(resources).toBeDefined(); for (const resource of expectedResources) { expect(resources![resource]?.current).toBe(resources![resource]?.maximum); } } describe("current CoA Druid/Rogue Sinister Request runtime", () => { beforeEach(() => { clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useDungeonSimulationStore.setState({ sessionMode: "play" }); useCombatStore.getState().initializeCharacter({ classId: "rom-druid", secondaryClassId: "rom-rogue", level: 80, xp: 0, }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty("sinister-request-test", 80, useCombatStore.getState().health, "damage", 3, "rom"); }); it("fills the player's and nearby companions' existing pools, includes radius edge, and leaves outside/absent pools alone", () => { const nearby = memberWithPools("sinister-player-nearby", RESOURCE_TYPES, 0); const edge = memberWithPools("sinister-player-edge", ["mana", "energy"], 1); const outside = memberWithPools("sinister-player-outside", RESOURCE_TYPES, 2); usePartyStore.setState({ members: [nearby, edge, outside] }); registerPartyRuntimePosition(nearby.id, [5, 0, 0]); registerPartyRuntimePosition(edge.id, [15, 0, 0]); registerPartyRuntimePosition(outside.id, [15.01, 0, 0]); useCombatStore.setState((state) => { const maxResourcePools = { ...state.maxResourcePools, mana: 240, rage: 100, focus: 100, energy: 100, psi: 6, "nature-power": 15, }; const resourcePools = { ...state.resourcePools, mana: 1, rage: 1, focus: 1, energy: 1, psi: 1, "nature-power": 1, }; return { maxResourcePools, resourcePools, maxResource: 240, resource: 1, }; }); useCombatStore.getState().synchronizeActors(); const beforeOutside = { ...useCombatStore.getState().actors[outside.id].resources }; const abilityId = sinisterRequestId(); const catalogRow = ROM_ALL_ABILITIES.find((ability) => ability.id === abilityId)!; expect(abilityAtLevel(catalogRow, 80).effects).toHaveLength(RESOURCE_TYPES.length); expect(useCombatStore.getState()).toMatchObject({ classId: "rom-druid", secondaryClassId: "rom-rogue" }); const castResult = useCombatStore.getState().castAbility(abilityId, { targetActorId: null, now: 1_000 }); expect(castResult).toMatchObject({ ok: true }); assertPoolsAreFull(PLAYER_AGGRO_ID, RESOURCE_TYPES); assertPoolsAreFull(nearby.id, RESOURCE_TYPES); assertPoolsAreFull(edge.id, ["mana", "energy"]); expect(useCombatStore.getState().actors[edge.id].resources.focus).toBeUndefined(); for (const resource of RESOURCE_TYPES) { expect(useCombatStore.getState().actors[outside.id].resources[resource]).toEqual(beforeOutside[resource]); } const events = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "resource" && event.abilityId === abilityId )); expect(events.filter((event) => event.targetActorId === PLAYER_AGGRO_ID)).toHaveLength(RESOURCE_TYPES.length); expect(events.filter((event) => event.targetActorId === nearby.id)).toHaveLength(RESOURCE_TYPES.length); expect(events.filter((event) => event.targetActorId === edge.id)).toHaveLength(2); expect(events.some((event) => event.targetActorId === outside.id)).toBe(false); expect(events.every((event) => event.sourceActorId === PLAYER_AGGRO_ID)).toBe(true); }); it("applies a companion cast to the in-range party, caps each existing pool, and attributes resource events to the caster", () => { const abilityId = sinisterRequestId(); const casterBase = memberWithPools("sinister-companion-caster", RESOURCE_TYPES, 0); const caster = { ...casterBase, activeCast: { abilityId, school: "nature" as const, mode: "cast" as const, startedAt: 0, completesAt: 0, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0] as const, targetId: casterBase.id, }, activeCastTargetId: casterBase.id, status: "healing" as const, }; const nearby = memberWithPools("sinister-companion-nearby", RESOURCE_TYPES.filter((resource) => resource !== "psi"), 1); const edge = memberWithPools("sinister-companion-edge", ["mana", "rage"], 2); const outside = memberWithPools("sinister-companion-outside", RESOURCE_TYPES, 3); usePartyStore.setState({ members: [caster, nearby, edge, outside] }); registerPartyRuntimePosition(caster.id, [0, 0, 0]); registerPartyRuntimePosition(nearby.id, [5, 0, 0]); registerPartyRuntimePosition(edge.id, [15, 0, 0]); registerPartyRuntimePosition(outside.id, [15.01, 0, 0]); useCombatStore.getState().synchronizeActors(); const beforeOutside = { ...useCombatStore.getState().actors[outside.id].resources }; advanceDungeonPartyCombat(1_000); assertPoolsAreFull(PLAYER_AGGRO_ID, ["mana"]); assertPoolsAreFull(caster.id, RESOURCE_TYPES); assertPoolsAreFull(nearby.id, RESOURCE_TYPES.filter((resource) => resource !== "psi")); expect(useCombatStore.getState().actors[nearby.id].resources.psi).toBeUndefined(); assertPoolsAreFull(edge.id, ["mana", "rage"]); for (const resource of RESOURCE_TYPES) { expect(useCombatStore.getState().actors[outside.id].resources[resource]).toEqual(beforeOutside[resource]); } const events = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "resource" && event.abilityId === abilityId )); expect(events.filter((event) => event.targetActorId === PLAYER_AGGRO_ID)).toHaveLength(1); expect(events.filter((event) => event.targetActorId === caster.id)).toHaveLength(RESOURCE_TYPES.length); expect(events.filter((event) => event.targetActorId === nearby.id)).toHaveLength(RESOURCE_TYPES.length - 1); expect(events.filter((event) => event.targetActorId === edge.id)).toHaveLength(2); expect(events.some((event) => event.targetActorId === outside.id)).toBe(false); expect(events.every((event) => event.sourceActorId === caster.id)).toBe(true); }); });