import { beforeEach, describe, expect, it } from "vitest"; import { abilitiesForCharacter } from "./abilityCatalog"; import { PLAYER_AURA_ENTITY_ID, useCombatStore, type ActiveCast } from "./combatStore"; import { applyAura, resolveDamageValue, resolveStatValue } from "./combatAuras"; import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial"; import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry"; import { resetPartyRuntimeClock } from "./partyRuntime"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { romNativeGearReference } from "./romNativeGear"; import { deriveCharacterStats, hastedDuration } from "./wotlkStats"; import { nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount"; import { raceAffixHasteMultiplier } from "./dungeonRaceAffixBuffs"; import { ROM_SCOUT_AUTO_SHOT_ABILITY_ID, ROM_SCOUT_AUTO_SHOT_SOURCE_SKILL_ID, } from "./romScoutAutoShot"; const MAX_TARGET_HEALTH = 10_000_000; const AUTO_SHOT_ID = ROM_SCOUT_AUTO_SHOT_ABILITY_ID; const BLOOD_ARROW_ID = "rom-scout-general-4"; function registerTarget(id: string, x = 2): void { useCombatStore.getState().registerMob(id, { name: id, level: 1, maxHealth: MAX_TARGET_HEALTH, xpReward: 0, }); setMobPosition(id, [x, 0, 0]); } function rangedItem(instanceId: string, options: { native?: boolean; speedMs?: number } = {}) { const native = options.native ? romNativeGearReference("rom-scout", "ranged", 70) : null; if (options.native && !native) throw new Error("The native Scout ranged gear reference is missing."); return { instanceId, itemId: options.native ? 9_000_071 : 90_071, itemKey: instanceId, name: "Test Bow", quality: "rare" as const, inventorySlot: "ranged", armorType: null, weaponType: "bow", itemLevel: 70, sourceItemLevel: 70, stats: { ...EMPTY_ITEM_STATS, rangedWeapon: { damageMin: options.native ? 7_000 : 500, damageMax: options.native ? 9_000 : 700, damageSchool: "physical" as const, speedMs: options.speedMs ?? (options.native ? 4_900 : 4_000), }, ...(native ? { romNative: native } : {}), }, quantity: 1, sourceBossKey: "test-bow", sourceBossName: "Auto Shot fixture", acquiredAt: 0, }; } function initializeScout(item = rangedItem("test-bow"), statRuleset: "rom" | "wow" = "rom"): void { useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId: "rom-rogue", raceId: "rom-human", statRuleset, level: 70, inventory: [item], equipment: { player: { ranged: item.instanceId } }, }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); } function initializeScoutWithEmptyInventory(secondaryClassId: "rom-rogue" | "rom-warrior" = "rom-rogue"): void { useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId, raceId: "rom-human", statRuleset: "rom", level: 70, inventory: [], equipment: {}, }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); } function activateAutoShot(targetId: string, now = 1_000) { useCombatStore.getState().selectMob(targetId); return useCombatStore.getState().castAbility(AUTO_SHOT_ID, { targetActorId: targetId, origin: [0, 0, 0], now, }); } function autoShotEvents(targetId?: string) { return useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.sourceActorId === PLAYER_AURA_ENTITY_ID && event.abilityId === AUTO_SHOT_ID && (!targetId || event.targetActorId === targetId)); } function giveRangedHaste(value = 50, stat = "ranged-haste"): void { const result = applyAura(useCombatStore.getState().auras, { definition: { id: `test-ranged-haste-${stat}`, name: "Test ranged haste", disposition: "buff", durationMs: null, modifiers: [{ kind: "stat", stat, operation: "flat", value }], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now: 1_000, }); useCombatStore.setState({ auras: result.auras }); } function giveBloodArrowAura(now = 900): void { const state = useCombatStore.getState(); const ability = state.abilities.find((candidate) => candidate.id === BLOOD_ARROW_ID); const effect = ability?.effects.find((candidate) => candidate.kind === "apply-aura" && candidate.recipient === "caster" && candidate.aura.utilityTags?.includes("rom-self-health-drain")); if (!ability || effect?.kind !== "apply-aura") throw new Error("Scout Blood Arrow aura was missing."); const result = applyAura(state.auras, { definition: effect.aura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, }); useCombatStore.setState({ auras: result.auras }); } function setSpatialProvider(options: { yaw?: number; visible?: boolean } = {}): void { registerCombatSpatialProvider({ actor: (id) => id === PLAYER_AURA_ENTITY_ID ? { id, position: useCombatStore.getState().playerPosition, yaw: options.yaw ?? Math.PI / 2 } : { id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 }, hasLineOfSight: () => options.visible ?? true, projectGroundPoint: (point) => point, }); } function activeCastFixture(targetId: string, now: number): ActiveCast { return { id: 1, abilityId: "rom-scout-primary-1", school: "physical", mode: "cast", startedAt: now, completesAt: now + 1_000, originalDurationMs: 1_000, pushbackHits: 0, canCastWhileMoving: false, origin: [0, 0, 0], targetId, groundPosition: null, totalTicks: 1, ticksCompleted: 0, nextTickAt: now + 1_000, }; } describe("Scout/Rogue Auto Shot player integration", () => { beforeEach(() => { resetCombatSpatialProvider(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useCombatStore.getState().initializeCharacter({ classId: "mage", level: 70 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty("rom-auto-shot", 70, useCombatStore.getState().health, "damage", 1); }); it("learns only the Scout Primary skill and exposes a plain toggle tooltip", () => { const scoutRogue = abilitiesForCharacter("rom-scout", "rom-rogue"); const autoShot = scoutRogue.find((ability) => ability.id === AUTO_SHOT_ID); expect(autoShot).toMatchObject({ dbcSpellId: ROM_SCOUT_AUTO_SHOT_SOURCE_SKILL_ID, source: "runewaker", classId: "rom-scout", sourceAttackKind: "ranged", target: "hostile", range: { min: 0, max: 6 }, targeting: { shape: "unit", requiresFacing: true, requiresLineOfSight: true }, }); expect(autoShot?.secondaryClassId).toBeUndefined(); expect(autoShot?.description).toContain("Use this ability again to stop Auto Shot"); expect(autoShot?.ranks).toHaveLength(1); expect(abilitiesForCharacter("rom-knight", "rom-scout").some((ability) => ability.id === AUTO_SHOT_ID)).toBe(false); initializeScout(); expect(useCombatStore.getState().abilities.some((ability) => ability.id === AUTO_SHOT_ID)).toBe(true); }); it.each(["haste", "ranged-haste", "attack-speed"])( "uses native ranged DPS and cadence independently of the compatibility budget (%s)", (hasteStat) => { const item = rangedItem("native-test-bow", { native: true, speedMs: 4_900 }); initializeScout(item, "rom"); registerTarget("native-shot-target"); giveRangedHaste(50, hasteStat); const state = useCombatStore.getState(); const derived = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset); const nativeAuto = nativeTrainingAutoAttackProfile(derived.romNative, "ranged"); expect(nativeAuto).toBeDefined(); if (!nativeAuto) throw new Error("Native ranged Auto Attack profile was unsupported."); const expectedDamage = Math.max(1, Math.round(nativeAuto.damage / (nativeAuto.intervalMs / 1_000) * 0.8)); const expectedHaste = ["haste", "ranged-haste", "attack-speed"].reduce( (current, stat) => resolveStatValue(current, useCombatStore.getState().auras, stat), derived.rangedHastePercent, ); const expectedInterval = Math.max(500, Math.round( hastedDuration(nativeAuto.intervalMs, expectedHaste) / raceAffixHasteMultiplier(1_000), )); const compatibilityDps = ((item.stats.rangedWeapon!.damageMin + item.stats.rangedWeapon!.damageMax) / 2) / (item.stats.rangedWeapon!.speedMs / 1_000); expect(Math.round(compatibilityDps * 0.8)).not.toBe(expectedDamage); expect(activateAutoShot("native-shot-target")).toMatchObject({ ok: true, damage: 0 }); expect(useCombatStore.getState().nextAutoShotAt).toBe(1_000 + expectedInterval); expect(useCombatStore.getState().mobs["native-shot-target"]?.health).toBe(MAX_TARGET_HEALTH); const due = useCombatStore.getState().nextAutoShotAt; useCombatStore.getState().tick(0.1, due); const expectedModifiedDamage = Math.round(resolveDamageValue(expectedDamage, useCombatStore.getState().auras, { direction: "dealt", school: "physical", attackKind: "ranged", })); expect(autoShotEvents("native-shot-target")).toContainEqual(expect.objectContaining({ occurredAt: due, rawAmount: expectedModifiedDamage, school: "physical", })); expect(useCombatStore.getState().nextAutoShotAt).toBe(due + expectedInterval); }); it("uses compatibility ranged DPS and haste when the native training profile is unsupported", () => { const item = rangedItem("custom-test-bow", { speedMs: 4_000 }); initializeScout(item, "wow"); registerTarget("compat-shot-target"); giveRangedHaste(); const state = useCombatStore.getState(); const derived = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset); const effectiveHaste = ["haste", "ranged-haste", "attack-speed"].reduce( (current, stat) => resolveStatValue(current, useCombatStore.getState().auras, stat), derived.rangedHastePercent, ); const expectedDamage = Math.max(1, Math.round( ((item.stats.rangedWeapon!.damageMin + item.stats.rangedWeapon!.damageMax) / 2) / (item.stats.rangedWeapon!.speedMs / 1_000) * 0.8, )); const expectedInterval = Math.max(500, Math.round( hastedDuration(item.stats.rangedWeapon!.speedMs, effectiveHaste) / raceAffixHasteMultiplier(1_000), )); expect(activateAutoShot("compat-shot-target")).toMatchObject({ ok: true, damage: 0 }); expect(useCombatStore.getState().nextAutoShotAt).toBe(1_000 + expectedInterval); const due = useCombatStore.getState().nextAutoShotAt; useCombatStore.getState().tick(0.1, due); const expectedModifiedDamage = Math.round(resolveDamageValue(expectedDamage, useCombatStore.getState().auras, { direction: "dealt", school: "physical", attackKind: "ranged", })); expect(autoShotEvents("compat-shot-target")).toContainEqual(expect.objectContaining({ occurredAt: due, rawAmount: expectedModifiedDamage, school: "physical", })); }); it("uses the automatically supplied Scout Training Gear bow with an empty inventory", () => { initializeScoutWithEmptyInventory(); expect(useCombatStore.getState().inventory).toHaveLength(0); expect(useCombatStore.getState().equipment.player?.ranged).toBeUndefined(); registerTarget("automatic-training-bow-target"); expect(activateAutoShot("automatic-training-bow-target")).toMatchObject({ ok: true }); expect(useCombatStore.getState().autoShotTargetId).toBe("automatic-training-bow-target"); const due = useCombatStore.getState().nextAutoShotAt; useCombatStore.getState().tick(0.1, due); expect(autoShotEvents("automatic-training-bow-target")).toHaveLength(1); }); it("falls back to the automatic training bow when a custom ranged item is unequipped", () => { initializeScout(rangedItem("unequipped-custom-bow", { speedMs: 4_000 }), "wow"); registerTarget("unequipped-custom-bow-target"); expect(activateAutoShot("unequipped-custom-bow-target")).toMatchObject({ ok: true }); expect(useCombatStore.getState().unequipItem("player", "ranged")).toBe(true); expect(useCombatStore.getState().equipment.player?.ranged).toBeUndefined(); const due = useCombatStore.getState().nextAutoShotAt; useCombatStore.getState().tick(0.1, due); expect(autoShotEvents("unequipped-custom-bow-target")).toHaveLength(1); expect(useCombatStore.getState().autoShotTargetId).toBe("unequipped-custom-bow-target"); expect(useCombatStore.getState().nextAutoShotAt).toBeGreaterThan(due); }); it("retains represented haste when unsupported native gear uses the compatibility weapon fallback", () => { const sourceItem = rangedItem("custom-native-test-bow", { native: true, speedMs: 4_300 }); const sourceNative = sourceItem.stats.romNative!; const customItem = { ...sourceItem, stats: { ...sourceItem.stats, romNative: { ...sourceNative, physicalAttack: (sourceNative.physicalAttack ?? 0) + 1, } }, }; initializeScout(customItem, "rom"); registerTarget("custom-native-shot-target"); giveRangedHaste(50, "attack-speed"); const state = useCombatStore.getState(); const derived = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset); expect(nativeTrainingAutoAttackProfile(derived.romNative, "ranged")).toBeUndefined(); const effectiveHaste = ["haste", "ranged-haste", "attack-speed"].reduce( (current, stat) => resolveStatValue(current, state.auras, stat), derived.rangedHastePercent, ); const expectedInterval = Math.max(500, Math.round( hastedDuration(customItem.stats.rangedWeapon!.speedMs, effectiveHaste) / raceAffixHasteMultiplier(1_000), )); expect(activateAutoShot("custom-native-shot-target")).toMatchObject({ ok: true }); expect(useCombatStore.getState().nextAutoShotAt).toBe(1_000 + expectedInterval); }); it("fires repeatedly on schedule without direct activation damage or parallel melee/offhand swings", () => { initializeScout(); registerTarget("repeat-shot-target"); giveBloodArrowAura(); useCombatStore.setState({ autoAttackTargetId: "repeat-shot-target", nextAutoAttackAt: 1_000, nextOffhandAutoAttackAt: 1_000, }); expect(activateAutoShot("repeat-shot-target")).toMatchObject({ ok: true, damage: 0 }); expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: "repeat-shot-target", autoAttackTargetId: null, nextAutoAttackAt: 0, nextOffhandAutoAttackAt: 0, }); expect(autoShotEvents()).toHaveLength(0); const resource = useCombatStore.getState().resource; const firstDue = useCombatStore.getState().nextAutoShotAt; const interval = firstDue - 1_000; useCombatStore.getState().tick(0.1, firstDue - 1); expect(autoShotEvents()).toHaveLength(0); useCombatStore.getState().tick(0.1, firstDue); expect(autoShotEvents()).toHaveLength(1); useCombatStore.getState().tick(0.1, firstDue + interval - 1); expect(autoShotEvents()).toHaveLength(1); useCombatStore.getState().tick(0.1, firstDue + interval); expect(autoShotEvents()).toHaveLength(2); expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage" && event.sourceActorId === PLAYER_AURA_ENTITY_ID && ["player-auto-attack", "player-offhand-auto-attack"].includes(event.abilityId ?? ""))).toBe(false); expect(useCombatStore.getState().resource).toBe(resource); const healthAfterSecondShot = useCombatStore.getState().health; const thirdDue = firstDue + interval * 2; const stopped = useCombatStore.getState().castAbility(AUTO_SHOT_ID, { targetActorId: "repeat-shot-target", origin: [0, 0, 0], now: thirdDue, }); expect(stopped).toMatchObject({ ok: true, damage: 0 }); expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 }); // The old third-shot deadline passes with the owner's mode stopped: no shot or Blood Arrow cost. useCombatStore.getState().tick(0.1, thirdDue); expect(autoShotEvents()).toHaveLength(2); expect(useCombatStore.getState().health).toBeGreaterThanOrEqual(healthAfterSecondShot); // Reusing the ordinary ability after the initial GCD starts a fresh cadence for the same target. const restartAt = thirdDue + 1_500; expect(activateAutoShot("repeat-shot-target", restartAt)).toMatchObject({ ok: true, damage: 0 }); const restartDue = useCombatStore.getState().nextAutoShotAt; expect(restartDue).toBe(restartAt + interval); expect(autoShotEvents()).toHaveLength(2); useCombatStore.getState().tick(0.1, restartDue - 1); expect(autoShotEvents()).toHaveLength(2); useCombatStore.getState().tick(0.1, restartDue); expect(autoShotEvents()).toHaveLength(3); expect(autoShotEvents("repeat-shot-target").at(-1)?.occurredAt).toBe(restartDue); expect(useCombatStore.getState().nextAutoShotAt).toBe(restartDue + interval); }); it("charges Blood Arrow once per due shot, skips a lethal cost, and lets the owner stop in any state", () => { initializeScout(); registerTarget("paid-shot-target"); giveBloodArrowAura(); const startHealth = useCombatStore.getState().health; expect(activateAutoShot("paid-shot-target")).toMatchObject({ ok: true }); const firstDue = useCombatStore.getState().nextAutoShotAt; const cost = Math.round(useCombatStore.getState().maxHealth * 0.02); useCombatStore.getState().tick(0.1, firstDue); expect(useCombatStore.getState().health).toBe(startHealth - cost); expect(autoShotEvents("paid-shot-target")).toHaveLength(1); expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.sourceActorId === PLAYER_AURA_ENTITY_ID && event.targetActorId === PLAYER_AURA_ENTITY_ID)).toHaveLength(0); const secondDue = useCombatStore.getState().nextAutoShotAt; useCombatStore.setState({ health: 1 }); useCombatStore.getState().tick(0.1, secondDue); expect(useCombatStore.getState().health).toBeGreaterThan(0); expect(autoShotEvents("paid-shot-target")).toHaveLength(1); expect(useCombatStore.getState().autoShotTargetId).toBe("paid-shot-target"); setMobPosition("paid-shot-target", [20, 0, 0]); setSpatialProvider({ yaw: -Math.PI / 2, visible: false }); useCombatStore.setState({ activeCast: activeCastFixture("paid-shot-target", secondDue) }); expect(useCombatStore.getState().castAbility(AUTO_SHOT_ID, { now: secondDue + 1 })).toMatchObject({ ok: true, damage: 0, }); expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 }); expect(useCombatStore.getState().activeCast?.abilityId).toBe("rom-scout-primary-1"); }); it("blocks scheduled shots for range, line of sight, facing, control, or casting without draining health", () => { initializeScout(); registerTarget("blocked-shot-target"); giveBloodArrowAura(); expect(activateAutoShot("blocked-shot-target")).toMatchObject({ ok: true }); const due = useCombatStore.getState().nextAutoShotAt; const startHealth = useCombatStore.getState().health; setSpatialProvider({ yaw: -Math.PI / 2, visible: false }); setMobPosition("blocked-shot-target", [20, 0, 0]); useCombatStore.getState().tick(0.1, due); expect(autoShotEvents()).toHaveLength(0); expect(useCombatStore.getState().health).toBe(startHealth); expect(useCombatStore.getState().nextAutoShotAt).toBe(due); setMobPosition("blocked-shot-target", [2, 0, 0]); setSpatialProvider({ yaw: -Math.PI / 2, visible: false }); useCombatStore.getState().tick(0.1, due); expect(autoShotEvents()).toHaveLength(0); setSpatialProvider({ yaw: -Math.PI / 2, visible: true }); useCombatStore.getState().tick(0.1, due); expect(autoShotEvents()).toHaveLength(0); setSpatialProvider({ yaw: Math.PI / 2, visible: true }); useCombatStore.setState({ controlledUntil: due + 100, controlMechanic: "stun" }); useCombatStore.getState().tick(0.1, due); expect(autoShotEvents()).toHaveLength(0); useCombatStore.setState({ controlledUntil: 0, controlMechanic: null, activeCast: activeCastFixture("blocked-shot-target", due), }); useCombatStore.getState().tick(0.1, due); expect(autoShotEvents()).toHaveLength(0); expect(useCombatStore.getState().health).toBe(startHealth); }); it("follows target selection, remains active through movement, and clears on deselection, death, and reset", () => { initializeScoutWithEmptyInventory("rom-warrior"); registerTarget("unsupported-scout-secondary-target"); expect(useCombatStore.getState().castAbility(AUTO_SHOT_ID, { targetActorId: "unsupported-scout-secondary-target", now: 1_000, })).toMatchObject({ ok: false }); initializeScout(); registerTarget("switch-first-target"); registerTarget("switch-second-target", 3); expect(activateAutoShot("switch-first-target")).toMatchObject({ ok: true }); const due = useCombatStore.getState().nextAutoShotAt; useCombatStore.getState().selectMob("switch-second-target"); expect(useCombatStore.getState().autoShotTargetId).toBe("switch-second-target"); expect(useCombatStore.getState().nextAutoShotAt).toBe(due); useCombatStore.getState().setPlayerPosition([1, 0, 0]); expect(useCombatStore.getState().autoShotTargetId).toBe("switch-second-target"); useCombatStore.getState().tick(0.1, due); expect(autoShotEvents("switch-second-target")).toHaveLength(1); expect(autoShotEvents("switch-first-target")).toHaveLength(0); useCombatStore.getState().clearTarget(); expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 }); activateAutoShot("switch-first-target", due + 1_000); useCombatStore.getState().damageMob("switch-first-target", MAX_TARGET_HEALTH, due + 2_000); expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 }); activateAutoShot("switch-second-target", due + 3_000); useCombatStore.getState().resetEncounter(); expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 }); }); it("clears Auto Shot immediately when a periodic effect kills the player during the same tick", () => { initializeScout(); registerTarget("same-tick-death-target"); giveBloodArrowAura(); expect(activateAutoShot("same-tick-death-target")).toMatchObject({ ok: true }); useCombatStore.setState({ health: 50, nextAutoShotAt: 10_000, effects: [{ id: "auto-shot-same-tick-player-death", kind: "dot", sourceAbilityId: BLOOD_ARROW_ID, sourceActorId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, coefficient: 0, fixedAmount: 100_000, remainingTicks: 1, intervalMs: 1_000, nextTickAt: 1_500, damageSchool: "physical", tag: "rom-self-health-drain", }], }); useCombatStore.getState().tick(0.1, 1_500); expect(useCombatStore.getState().health).toBe(0); expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 }); }); });