diff --git a/api/src/routes/lootdrops.routes.ts b/api/src/routes/lootdrops.routes.ts index f528860..d54a81d 100644 --- a/api/src/routes/lootdrops.routes.ts +++ b/api/src/routes/lootdrops.routes.ts @@ -73,7 +73,7 @@ async function handler(ctx: RouteContext): Promise { */ if (pathname === "/api/lootdrops" && method === "POST") { return withErrorHandling(async () => { - const { lootdropService } = await import("@shared/modules/economy/lootdrop.service"); + const { spawnLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler"); const { AuroraClient } = await import("../../../bot/lib/BotClient"); const { TextChannel } = await import("discord.js"); @@ -89,7 +89,7 @@ async function handler(ctx: RouteContext): Promise { return errorResponse("Invalid channel. Must be a TextChannel.", 400); } - await lootdropService.spawnLootdrop(channel, data.amount, data.currency); + await spawnLootdrop(channel, data.amount, data.currency); return jsonResponse({ success: true }, 201); }, "spawn lootdrop"); @@ -110,8 +110,8 @@ async function handler(ctx: RouteContext): Promise { if (!messageId) return null; return withErrorHandling(async () => { - const { lootdropService } = await import("@shared/modules/economy/lootdrop.service"); - const success = await lootdropService.deleteLootdrop(messageId); + const { deleteLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler"); + const success = await deleteLootdrop(messageId); if (!success) { return errorResponse("Lootdrop not found", 404); diff --git a/bot/commands/admin/prune.ts b/bot/commands/admin/prune.ts index d710f7f..bf09e4f 100644 --- a/bot/commands/admin/prune.ts +++ b/bot/commands/admin/prune.ts @@ -1,7 +1,7 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js"; import { config } from "@shared/lib/config"; -import { pruneService } from "@shared/modules/moderation/prune.service"; +import { pruneService } from "@modules/moderation/prune.service"; import { getConfirmationMessage, getProgressEmbed, diff --git a/bot/commands/admin/terminal.ts b/bot/commands/admin/terminal.ts index 7b26015..8d1168d 100644 --- a/bot/commands/admin/terminal.ts +++ b/bot/commands/admin/terminal.ts @@ -1,7 +1,7 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js"; -import { terminalService } from "@shared/modules/terminal/terminal.service"; +import { terminalService } from "@modules/system/terminal.service"; import { createErrorEmbed } from "@/lib/embeds"; import { withCommandErrorHandling } from "@lib/commandUtils"; diff --git a/bot/events/messageCreate.ts b/bot/events/messageCreate.ts index 59cdefe..309a8cd 100644 --- a/bot/events/messageCreate.ts +++ b/bot/events/messageCreate.ts @@ -15,7 +15,7 @@ const event: Event = { levelingService.processChatXp(message.author.id); // Activity Tracking for Lootdrops - import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message)); + import("@modules/economy/lootdrop.handler").then(m => m.processLootdropMessage(message)); }, }; diff --git a/bot/modules/economy/lootdrop.handler.ts b/bot/modules/economy/lootdrop.handler.ts new file mode 100644 index 0000000..3c21e52 --- /dev/null +++ b/bot/modules/economy/lootdrop.handler.ts @@ -0,0 +1,60 @@ +import { Message, TextChannel } from "discord.js"; +import { lootdropService } from "@shared/modules/economy/lootdrop.service"; +import { getLootdropMessage } from "./lootdrop.view"; +import { terminalService } from "@modules/system/terminal.service"; + +/** + * Process a Discord message for lootdrop activity tracking. + * Called from messageCreate event handler. + */ +export async function processLootdropMessage(message: Message): Promise { + if (message.author.bot || !message.guild) return; + + const { shouldSpawn } = lootdropService.trackActivity(message.channel.id); + + if (shouldSpawn) { + await spawnLootdrop(message.channel as TextChannel); + } +} + +/** + * Spawn a lootdrop in a Discord channel. + * Used by both bot events and API routes. + */ +export async function spawnLootdrop( + channel: TextChannel, + overrideReward?: number, + overrideCurrency?: string +): Promise { + const { reward, currency } = lootdropService.calculateReward(overrideReward, overrideCurrency); + const { content, files, components } = await getLootdropMessage(reward, currency); + + try { + const sentMessage = await channel.send({ content, files, components }); + await lootdropService.persistLootdrop(sentMessage.id, channel.id, reward, currency); + terminalService.update(channel.guildId); + } catch (error) { + console.error("Failed to spawn lootdrop:", error); + } +} + +/** + * Delete a lootdrop from DB and Discord. + */ +export async function deleteLootdrop(messageId: string): Promise { + const result = await lootdropService.removeLootdrop(messageId); + if (!result) return false; + + try { + const { AuroraClient } = await import("@/lib/BotClient"); + const channel = await AuroraClient.channels.fetch(result.channelId) as TextChannel; + if (channel) { + const message = await channel.messages.fetch(messageId); + if (message) await message.delete(); + } + } catch (e) { + console.warn("Could not delete lootdrop message from Discord:", e); + } + + return true; +} diff --git a/bot/modules/economy/lootdrop.interaction.ts b/bot/modules/economy/lootdrop.interaction.ts index fae70c3..e2a64fb 100644 --- a/bot/modules/economy/lootdrop.interaction.ts +++ b/bot/modules/economy/lootdrop.interaction.ts @@ -2,6 +2,7 @@ import { ButtonInteraction } from "discord.js"; import { lootdropService } from "@shared/modules/economy/lootdrop.service"; import { UserError } from "@shared/lib/errors"; import { getLootdropClaimedMessage } from "./lootdrop.view"; +import { terminalService } from "@modules/system/terminal.service"; export async function handleLootdropInteraction(interaction: ButtonInteraction) { if (interaction.customId === "lootdrop_claim") { @@ -13,6 +14,9 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction) throw new UserError(result.error || "Failed to claim."); } + // Update terminal display after successful claim + terminalService.update(); + await interaction.editReply({ content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!` }); diff --git a/shared/modules/moderation/prune.service.ts b/bot/modules/moderation/prune.service.ts similarity index 100% rename from shared/modules/moderation/prune.service.ts rename to bot/modules/moderation/prune.service.ts diff --git a/bot/modules/system/scheduler.ts b/bot/modules/system/scheduler.ts index 1b05d54..61c09d5 100644 --- a/bot/modules/system/scheduler.ts +++ b/bot/modules/system/scheduler.ts @@ -1,5 +1,5 @@ import { temporaryRoleService } from "@shared/modules/system/temp-role.service"; -import { terminalService } from "@shared/modules/terminal/terminal.service"; +import { terminalService } from "./terminal.service"; export const schedulerService = { start: () => { diff --git a/shared/modules/terminal/terminal.service.ts b/bot/modules/system/terminal.service.ts similarity index 99% rename from shared/modules/terminal/terminal.service.ts rename to bot/modules/system/terminal.service.ts index 87674d1..d165ec8 100644 --- a/shared/modules/terminal/terminal.service.ts +++ b/bot/modules/system/terminal.service.ts @@ -70,7 +70,7 @@ export const terminalService = { } const guildConfig = await getGuildConfig(effectiveGuildId); - + if (!guildConfig.terminal?.channelId || !guildConfig.terminal?.messageId) { return; } diff --git a/shared/modules/economy/lootdrop.service.test.ts b/shared/modules/economy/lootdrop.service.test.ts index 74992b4..92ca6c0 100644 --- a/shared/modules/economy/lootdrop.service.test.ts +++ b/shared/modules/economy/lootdrop.service.test.ts @@ -82,92 +82,81 @@ describe("lootdropService", () => { mockModifyUserBalance.mockRestore(); }); - describe("processMessage", () => { - it("should track activity but not spawn if minMessages not reached", async () => { - const mockChannel = { id: "chan1", send: mock() }; - const mockMessage = { - author: { bot: false }, - guild: {}, - channel: mockChannel - }; + describe("trackActivity", () => { + it("should track activity but not spawn if minMessages not reached", () => { + const result1 = lootdropService.trackActivity("chan1"); + const result2 = lootdropService.trackActivity("chan1"); - await lootdropService.processMessage(mockMessage as any); - await lootdropService.processMessage(mockMessage as any); - - // Expect no spawn attempt - expect(mockChannel.send).not.toHaveBeenCalled(); - // Internal state check if possible, or just behavior + expect(result1.shouldSpawn).toBe(false); + expect(result2.shouldSpawn).toBe(false); }); - it("should spawn lootdrop if minMessages reached and chance hits", async () => { - const mockChannel = { id: "chan1", send: mock() }; - const mockMessage = { - author: { bot: false }, - guild: {}, - channel: mockChannel - }; - - mockChannel.send.mockResolvedValue({ id: "msg1" }); + it("should spawn lootdrop if minMessages reached and chance hits", () => { Math.random = () => 0.01; // Force hit (0.01 < 0.5) // Send 3 messages - await lootdropService.processMessage(mockMessage as any); - await lootdropService.processMessage(mockMessage as any); - await lootdropService.processMessage(mockMessage as any); + lootdropService.trackActivity("chan1"); + lootdropService.trackActivity("chan1"); + const result = lootdropService.trackActivity("chan1"); - expect(mockChannel.send).toHaveBeenCalled(); - expect(mockInsert).toHaveBeenCalledWith(lootdrops); - - // Verify DB insert - expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ - channelId: "chan1", - messageId: "msg1", - currency: "GOLD" - })); + expect(result.shouldSpawn).toBe(true); }); - it("should not spawn if chance fails", async () => { - const mockChannel = { id: "chan1", send: mock() }; - const mockMessage = { - author: { bot: false }, - guild: {}, - channel: mockChannel - }; - + it("should not spawn if chance fails", () => { Math.random = () => 0.99; // Force fail (0.99 > 0.5) - await lootdropService.processMessage(mockMessage as any); - await lootdropService.processMessage(mockMessage as any); - await lootdropService.processMessage(mockMessage as any); + lootdropService.trackActivity("chan1"); + lootdropService.trackActivity("chan1"); + const result = lootdropService.trackActivity("chan1"); - expect(mockChannel.send).not.toHaveBeenCalled(); + expect(result.shouldSpawn).toBe(false); }); - it("should respect cooldowns", async () => { - const mockChannel = { id: "chan1", send: mock() }; - const mockMessage = { - author: { bot: false }, - guild: {}, - channel: mockChannel - }; - mockChannel.send.mockResolvedValue({ id: "msg1" }); - + it("should respect cooldowns", () => { Math.random = () => 0.01; // Force hit // Trigger spawn - await lootdropService.processMessage(mockMessage as any); - await lootdropService.processMessage(mockMessage as any); - await lootdropService.processMessage(mockMessage as any); + lootdropService.trackActivity("chan1"); + lootdropService.trackActivity("chan1"); + const result1 = lootdropService.trackActivity("chan1"); - expect(mockChannel.send).toHaveBeenCalledTimes(1); - mockChannel.send.mockClear(); + expect(result1.shouldSpawn).toBe(true); // Try again immediately (cooldown active) - await lootdropService.processMessage(mockMessage as any); - await lootdropService.processMessage(mockMessage as any); - await lootdropService.processMessage(mockMessage as any); + lootdropService.trackActivity("chan1"); + lootdropService.trackActivity("chan1"); + const result2 = lootdropService.trackActivity("chan1"); - expect(mockChannel.send).not.toHaveBeenCalled(); + expect(result2.shouldSpawn).toBe(false); + }); + }); + + describe("calculateReward", () => { + it("should return override values when provided", () => { + const result = lootdropService.calculateReward(500, "SILVER"); + expect(result.reward).toBe(500); + expect(result.currency).toBe("SILVER"); + }); + + it("should return random reward within range when no override", () => { + const result = lootdropService.calculateReward(); + expect(result.reward).toBeGreaterThanOrEqual(10); + expect(result.reward).toBeLessThanOrEqual(100); + expect(result.currency).toBe("GOLD"); + }); + }); + + describe("persistLootdrop", () => { + it("should insert lootdrop into database", async () => { + await lootdropService.persistLootdrop("msg1", "chan1", 50, "GOLD"); + + expect(mockInsert).toHaveBeenCalledWith(lootdrops); + expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ + messageId: "msg1", + channelId: "chan1", + rewardAmount: 50, + currency: "GOLD" + })); }); }); diff --git a/shared/modules/economy/lootdrop.service.ts b/shared/modules/economy/lootdrop.service.ts index 20e144b..3773288 100644 --- a/shared/modules/economy/lootdrop.service.ts +++ b/shared/modules/economy/lootdrop.service.ts @@ -1,8 +1,5 @@ -import { Message, TextChannel } from "discord.js"; -import { getLootdropMessage } from "@/modules/economy/lootdrop.view"; import { config } from "@shared/lib/config"; import { economyService } from "./economy.service"; -import { terminalService } from "@shared/modules/terminal/terminal.service"; import { lootdrops } from "@db/schema"; import { DrizzleClient } from "@shared/db/DrizzleClient"; import { eq, and, isNull, lt } from "drizzle-orm"; @@ -53,15 +50,16 @@ async function cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise } } -async function processMessage(message: Message) { - if (message.author.bot || !message.guild) return; - - const channelId = message.channel.id; +/** + * Track channel activity and determine if a lootdrop should spawn. + * Returns shouldSpawn: true if conditions are met (activity threshold + random chance). + */ +function trackActivity(channelId: string): { shouldSpawn: boolean } { const now = Date.now(); // Check cooldown const cooldown = channelCooldowns.get(channelId); - if (cooldown && now < cooldown) return; + if (cooldown && now < cooldown) return { shouldSpawn: false }; // Track activity const timestamps = channelActivity.get(channelId) || []; @@ -75,41 +73,61 @@ async function processMessage(message: Message) { if (recentActivity.length >= config.lootdrop.minMessages) { // Chance to spawn if (Math.random() < config.lootdrop.spawnChance) { - await spawnLootdrop(message.channel as TextChannel); // Set cooldown channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs); channelActivity.set(channelId, []); + return { shouldSpawn: true }; } } + + return { shouldSpawn: false }; } -async function spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) { +/** + * Calculate lootdrop reward amount and currency. + */ +function calculateReward(overrideReward?: number, overrideCurrency?: string): { reward: number; currency: string } { const min = config.lootdrop.reward.min; const max = config.lootdrop.reward.max; const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min); const currency = overrideCurrency ?? config.lootdrop.reward.currency; + return { reward, currency }; +} - const { content, files, components } = await getLootdropMessage(reward, currency); +/** + * Persist a spawned lootdrop to the database. + */ +async function persistLootdrop(messageId: string, channelId: string, reward: number, currency: string): Promise { + await DrizzleClient.insert(lootdrops).values({ + messageId, + channelId, + rewardAmount: reward, + currency: currency, + createdAt: new Date(), + // Expire after 10 mins + expiresAt: new Date(Date.now() + 600000) + }); +} +/** + * Remove a lootdrop from the database. Returns the channelId for Discord cleanup. + */ +async function removeLootdrop(messageId: string): Promise<{ channelId: string } | null> { try { - const message = await channel.send({ content, files, components }); - - // Persist to DB - await DrizzleClient.insert(lootdrops).values({ - messageId: message.id, - channelId: channel.id, - rewardAmount: reward, - currency: currency, - createdAt: new Date(), - // Expire after 10 mins - expiresAt: new Date(Date.now() + 600000) + // First fetch it to get channel info + const drop = await DrizzleClient.query.lootdrops.findFirst({ + where: eq(lootdrops.messageId, messageId) }); - // Trigger Terminal Update - terminalService.update(channel.guildId); + if (!drop) return null; + // Delete from DB + await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId)); + + return { channelId: drop.channelId }; } catch (error) { - console.error("Failed to spawn lootdrop:", error); + console.error("Error removing lootdrop:", error); + return null; } } @@ -143,9 +161,6 @@ async function tryClaim(messageId: string, userId: string, username: string): Pr `Claimed lootdrop in channel ${drop.channelId}` ); - // Trigger Terminal Update (uses primary guild from env) - terminalService.update(); - return { success: true, amount: drop.rewardAmount, currency: drop.currency }; } catch (error) { @@ -197,43 +212,13 @@ async function clearCaches() { console.log("[LootdropService] Caches cleared via administrative action."); } -async function deleteLootdrop(messageId: string): Promise { - try { - // First fetch it to get channel info so we can delete the message - const drop = await DrizzleClient.query.lootdrops.findFirst({ - where: eq(lootdrops.messageId, messageId) - }); - - if (!drop) return false; - - // Delete from DB - await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId)); - - // Try to delete from Discord - try { - const { AuroraClient } = await import("../../../bot/lib/BotClient"); - const channel = await AuroraClient.channels.fetch(drop.channelId) as TextChannel; - if (channel) { - const message = await channel.messages.fetch(messageId); - if (message) await message.delete(); - } - } catch (e) { - console.warn("Could not delete lootdrop message from Discord:", e); - } - - return true; - } catch (error) { - console.error("Error deleting lootdrop:", error); - return false; - } -} - export const lootdropService = { cleanupExpiredLootdrops, - processMessage, - spawnLootdrop, + trackActivity, + calculateReward, + persistLootdrop, + removeLootdrop, tryClaim, getLootdropState, clearCaches, - deleteLootdrop, }; diff --git a/bot/modules/inventory/effect.handlers.ts b/shared/modules/inventory/effect.handlers.ts similarity index 100% rename from bot/modules/inventory/effect.handlers.ts rename to shared/modules/inventory/effect.handlers.ts diff --git a/bot/modules/inventory/effect.registry.ts b/shared/modules/inventory/effect.registry.ts similarity index 100% rename from bot/modules/inventory/effect.registry.ts rename to shared/modules/inventory/effect.registry.ts diff --git a/bot/modules/inventory/effect.types.ts b/shared/modules/inventory/effect.types.ts similarity index 100% rename from bot/modules/inventory/effect.types.ts rename to shared/modules/inventory/effect.types.ts diff --git a/shared/modules/inventory/inventory.service.ts b/shared/modules/inventory/inventory.service.ts index 9061f19..ee11739 100644 --- a/shared/modules/inventory/inventory.service.ts +++ b/shared/modules/inventory/inventory.service.ts @@ -170,7 +170,7 @@ export const inventoryService = { const results: any[] = []; // 2. Apply Effects - const { validateAndExecuteEffect } = await import("@/modules/inventory/effect.registry"); + const { validateAndExecuteEffect } = await import("./effect.registry"); for (const effect of usageData.effects) { const result = await validateAndExecuteEffect(effect, userId, txFn);