import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { useCombatStore } from "./combatStore"; import { resetCombatSpatialProvider, registerCombatSpatialProvider } from "./combatSpatial"; import { useDungeonSimulationStore } from "./dungeonSimulationStore"; import { setMobPosition, clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { commitPartyAbilityUse } from "./partyAbilityAi"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; import { ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS, ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA, ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS, ROM_PRIEST_SCOUT_CURING_SHOT_ID, } from "./romPriestScoutCuringShot"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; const CURING_SHOT = ROM_ALL_ABILITIES.find((ability) => ( ability.source === "runewaker" && ability.classId === "rom-priest" && ability.secondaryClassId === "rom-scout" && ability.dbcSpellId === ROM_PRIEST_SCOUT_CURING_SHOT_ID )); const CURING_SHOT_ID = "rom-priest-rom-scout-elite-35"; const TEST_TARGET_ID = "curing-shot-elemental-target"; function priest(name: string, seed: string, index: number, secondaryClassId?: "rom-scout") { return createConfiguredPartyMember({ name, classId: "rom-priest", ...(secondaryClassId ? { secondaryClassId } : {}), specialization: "Primary", role: "damage", }, 80, index, seed); } function initializePlayerParty(seed: string) { useCombatStore.getState().initializeCharacter({ classId: "rom-priest", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80, role: "healer", }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "healer", 5, "rom"); const roster = [ ...usePartyStore.getState().members, priest("Extra modeled ally A", seed, 4), priest("Extra modeled ally B", seed, 5), ]; usePartyStore.setState({ members: roster }); roster.forEach((member, index) => { // Five party actors plus the player are intentionally in range so the // integration catches the old five-recipient fallback cap. registerPartyRuntimePosition(member.id, index < 5 ? [2 + index * 1.5, 0, 0] : [10.01, 0, 0]); usePartyStore.getState().damageMember(member.id, 2_000); }); useCombatStore.getState().damagePlayer(2_000); useCombatStore.getState().synchronizeActors(); registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: () => true, projectGroundPoint: (position) => position, }); useCombatStore.getState().registerMob(TEST_TARGET_ID, { name: "Elemental damage test target", level: 1, maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(TEST_TARGET_ID, [3, 0, 0]); return { roster }; } function applyCuringShotFromCompanion(casterId: string, now: number): void { const caster = usePartyStore.getState().members.find((member) => member.id === casterId); if (!caster || !CURING_SHOT) throw new Error("Missing Priest/Scout Curing Shot companion."); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === casterId ? { ...member, activeCast: { abilityId: CURING_SHOT_ID, school: "holy", mode: "cast", startedAt: now, completesAt: now, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId: casterId, }, activeCastTargetId: casterId, status: "attacking", } : member), })); useCombatStore.getState().synchronizeActors(); const syncedCaster = usePartyStore.getState().members.find((member) => member.id === casterId); const committed = syncedCaster ? commitPartyAbilityUse(syncedCaster, CURING_SHOT, now) : null; if (!committed) throw new Error("Companion could not commit Curing Shot."); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === casterId ? { ...committed, activeCast: member.activeCast, activeCastTargetId: member.activeCastTargetId } : member), })); useCombatStore.getState().commitCompanionActors([committed]); advanceDungeonPartyCombat(now); } function schoolDamage(sourceActorId: string, school: "physical" | "holy" | "fire" | "frost" | "nature" | "shadow" | "arcane", now: number) { return useCombatStore.getState().damageMob( TEST_TARGET_ID, 1_000, now, { actorId: sourceActorId, role: "damage", abilityId: `curing-shot-school-test:${school}` }, school, { attackKind: school === "physical" ? "melee" : "spell", seed: `curing-shot-school-test:${sourceActorId}:${school}:${now}`, rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, }, ).amount; } function curingShotAura(targetId: string) { return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId && aura.definition.id === ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA.id); } describe("CoA Priest/Scout Curing Shot runtime", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider(); useDungeonSimulationStore.setState({ sessionMode: "play" }); }); afterEach(() => resetCombatSpatialProvider()); it("heals every nearby modeled party actor, schedules six HoT ticks, and buffs only the caster's Water/frost analog", () => { if (!CURING_SHOT) throw new Error("Priest/Scout Curing Shot (492636) is missing from the generated catalog."); const { roster } = initializePlayerParty("curing-shot-player-runtime"); const ally = roster[0]!; usePartyStore.getState().selectMember(ally.id); const baseline = new Map(ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS.map((school) => ( [school, schoolDamage(PLAYER_AGGRO_ID, school, 500)] as const ))); const baselineExcluded = new Map(([ "fire", "nature", "arcane", "physical", "holy", "shadow", ] as const).map((school) => [school, schoolDamage(PLAYER_AGGRO_ID, school, 700)] as const)); expect(useCombatStore.getState().castAbility(CURING_SHOT.id, undefined, 1_000)).toMatchObject({ ok: true }); const scheduledHots = useCombatStore.getState().effects.filter((effect) => ( effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id )); expect(scheduledHots).toHaveLength(6); expect(scheduledHots.map((effect) => effect.targetId)).toEqual(expect.arrayContaining([ null, ...roster.slice(0, 5).map((member) => member.id), ])); const directEvents = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "healing" && event.abilityId === CURING_SHOT.id && event.occurredAt === 1_000 )); expect(directEvents.map((event) => event.targetActorId)).toHaveLength(6); expect(directEvents.map((event) => event.targetActorId)).toEqual(expect.arrayContaining([ PLAYER_AGGRO_ID, ...roster.slice(0, 5).map((member) => member.id), ])); expect(directEvents.map((event) => event.targetActorId)).not.toContain(roster[5]!.id); expect(directEvents.every((event) => event.rawAmount > 0)).toBe(true); expect(curingShotAura(PLAYER_AGGRO_ID)).toMatchObject({ expiresAt: 19_000, definition: { modifiers: [ { kind: "damage", direction: "dealt", school: "frost", value: 0.1 }, ] }, }); expect(roster.map((member) => curingShotAura(member.id))).toEqual(Array.from({ length: roster.length }, () => undefined)); expect(useCombatStore.getState().cooldowns[CURING_SHOT.id]).toBe(1_000 + ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS); const playerHotHealing = []; const tickTimes = [4_000, 7_000, 10_000, 13_000, 16_000, 19_000]; for (const [tickIndex, tickAt] of tickTimes.slice(0, 5).entries()) { const playerHealthBeforeTick = useCombatStore.getState().health; const activeHotsBeforeTick = useCombatStore.getState().effects.filter((effect) => ( effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id )); expect(activeHotsBeforeTick).toHaveLength(6); expect(activeHotsBeforeTick.every((effect) => effect.remainingTicks === 6 - tickIndex)).toBe(true); useCombatStore.getState().tick(3, tickAt); playerHotHealing.push(useCombatStore.getState().health - playerHealthBeforeTick); const activeHotsAfterTick = useCombatStore.getState().effects.filter((effect) => ( effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id )); expect(activeHotsAfterTick).toHaveLength(6); expect(activeHotsAfterTick.every((effect) => effect.remainingTicks === 5 - tickIndex)).toBe(true); } for (const school of ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS) { expect(schoolDamage(PLAYER_AGGRO_ID, school, 18_999) / baseline.get(school)!).toBeCloseTo(1.1, 1); } for (const school of ["fire", "nature", "arcane", "physical", "holy", "shadow"] as const) { expect(schoolDamage(PLAYER_AGGRO_ID, school, 18_999)).toBe(baselineExcluded.get(school)); } expect(curingShotAura(PLAYER_AGGRO_ID)?.expiresAt).toBe(19_000); const finalTickAt = tickTimes[5]!; const finalTickHotEffects = useCombatStore.getState().effects.filter((effect) => ( effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id )); expect(finalTickHotEffects).toHaveLength(6); expect(finalTickHotEffects.every((effect) => effect.remainingTicks === 1)).toBe(true); const playerHealthBeforeFinalTick = useCombatStore.getState().health; useCombatStore.getState().tick(3, finalTickAt); playerHotHealing.push(useCombatStore.getState().health - playerHealthBeforeFinalTick); expect(useCombatStore.getState().effects.some((effect) => ( effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id ))).toBe(false); const periodicEvents = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "healing" && event.abilityId === CURING_SHOT.id && event.occurredAt > 1_000 )); // Companion HoTs are emitted as healing events; the player's self-HoT // updates health directly and is verified separately below. expect(periodicEvents).toHaveLength(30); expect(periodicEvents.every((event) => tickTimes.includes(event.occurredAt))).toBe(true); expect(new Set(periodicEvents.map((event) => event.targetActorId))).toEqual(new Set( roster.slice(0, 5).map((member) => member.id), )); expect(periodicEvents.every((event) => event.rawAmount > 0)).toBe(true); expect(playerHotHealing).toHaveLength(6); expect(playerHotHealing.every((amount) => amount > 0)).toBe(true); expect(curingShotAura(PLAYER_AGGRO_ID)).toBeUndefined(); expect(schoolDamage(PLAYER_AGGRO_ID, "frost", 19_000)).toBe(baseline.get("frost")); }); it("lets a Priest/Scout companion heal nearby party actors and own the elemental bonus", () => { if (!CURING_SHOT) throw new Error("Priest/Scout Curing Shot (492636) is missing from the generated catalog."); useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 80, role: "damage", }); useCombatStore.getState().setPlayerPosition([1, 0, 0]); usePartyStore.getState().initializeParty("curing-shot-companion-runtime", 80, useCombatStore.getState().health, "damage", 5, "rom"); const caster = { ...priest("Priest Scout caster", "curing-shot-companion-runtime", 0, "rom-scout"), resource: 500, maxResource: 500, resourcePools: { mana: 500 }, }; const ally = priest("Priest Scout ally", "curing-shot-companion-runtime", 1); usePartyStore.setState({ members: [caster, ally] }); registerPartyRuntimePosition(caster.id, [0, 0, 0]); registerPartyRuntimePosition(ally.id, [5, 0, 0]); usePartyStore.getState().damageMember(caster.id, 2_000); usePartyStore.getState().damageMember(ally.id, 2_000); useCombatStore.getState().damagePlayer(2_000); useCombatStore.getState().synchronizeActors(); registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: () => true, projectGroundPoint: (position) => position, }); useCombatStore.getState().registerMob(TEST_TARGET_ID, { name: "Elemental damage test target", level: 1, maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(TEST_TARGET_ID, [3, 0, 0]); const companionFrostBaseline = schoolDamage(caster.id, "frost", 900); const companionFireBaseline = schoolDamage(caster.id, "fire", 950); applyCuringShotFromCompanion(caster.id, 1_000); expect(useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "healing" && event.abilityId === CURING_SHOT_ID && event.occurredAt === 1_000 )).map((event) => event.targetActorId)).toEqual(expect.arrayContaining([ PLAYER_AGGRO_ID, caster.id, ally.id, ])); expect(curingShotAura(caster.id)?.expiresAt).toBe(19_000); expect(curingShotAura(PLAYER_AGGRO_ID)).toBeUndefined(); expect(curingShotAura(ally.id)).toBeUndefined(); expect(schoolDamage(caster.id, "frost", 1_100) / companionFrostBaseline).toBeCloseTo(1.1, 1); expect(schoolDamage(caster.id, "fire", 1_150)).toBe(companionFireBaseline); const playerHealthBeforeHotTick = useCombatStore.getState().health; useCombatStore.getState().tick(3, 4_000); const periodicEvents = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "healing" && event.abilityId === CURING_SHOT_ID && event.occurredAt === 4_000 )); expect(periodicEvents).toHaveLength(3); expect(new Set(periodicEvents.map((event) => event.targetActorId))).toEqual(new Set([ PLAYER_AGGRO_ID, caster.id, ally.id, ])); expect(useCombatStore.getState().health).toBeGreaterThan(playerHealthBeforeHotTick); }); });