diff --git a/shared/modules/economy/lootdrop.service.test.ts b/shared/modules/economy/lootdrop.service.test.ts index 3fa0c60..74992b4 100644 --- a/shared/modules/economy/lootdrop.service.test.ts +++ b/shared/modules/economy/lootdrop.service.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"; -import { lootdropService } from "@shared/modules/economy/lootdrop.service"; +import { lootdropService, channelActivity, channelCooldowns } from "@shared/modules/economy/lootdrop.service"; import { lootdrops } from "@db/schema"; import { economyService } from "@shared/modules/economy/economy.service"; @@ -67,8 +67,8 @@ describe("lootdropService", () => { mockFrom.mockClear(); // Reset internal state - (lootdropService as any).channelActivity = new Map(); - (lootdropService as any).channelCooldowns = new Map(); + channelActivity.clear(); + channelCooldowns.clear(); // Mock Math.random originalRandom = Math.random; diff --git a/shared/modules/economy/lootdrop.service.ts b/shared/modules/economy/lootdrop.service.ts index 6d1317b..20e144b 100644 --- a/shared/modules/economy/lootdrop.service.ts +++ b/shared/modules/economy/lootdrop.service.ts @@ -7,234 +7,233 @@ import { lootdrops } from "@db/schema"; import { DrizzleClient } from "@shared/db/DrizzleClient"; import { eq, and, isNull, lt } from "drizzle-orm"; -interface Lootdrop { - messageId: string; - channelId: string; - rewardAmount: number; - currency: string; - claimedBy?: string; - createdAt: Date; +// Module-level state (exported for testing) +export const channelActivity: Map = new Map(); +export const channelCooldowns: Map = new Map(); + +// Private helper function +function cleanupActivity() { + const now = Date.now(); + const window = config.lootdrop.activityWindowMs; + + for (const [channelId, timestamps] of channelActivity.entries()) { + const validTimestamps = timestamps.filter(t => now - t < window); + if (validTimestamps.length === 0) { + channelActivity.delete(channelId); + } else { + channelActivity.set(channelId, validTimestamps); + } + } } -class LootdropService { - private channelActivity: Map = new Map(); - private channelCooldowns: Map = new Map(); +// Initialize cleanup interval on module load +setInterval(() => { + cleanupActivity(); + cleanupExpiredLootdrops(true); +}, 60000); - constructor() { - // Cleanup interval for activity tracking and expired lootdrops - setInterval(() => { - this.cleanupActivity(); - this.cleanupExpiredLootdrops(true); - }, 60000); +async function cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise { + try { + const now = new Date(); + const whereClause = includeClaimed + ? lt(lootdrops.expiresAt, now) + : and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now)); + + const result = await DrizzleClient.delete(lootdrops) + .where(whereClause) + .returning(); + + if (result.length > 0) { + console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`); + } + return result.length; + } catch (error) { + console.error("Failed to cleanup lootdrops:", error); + return 0; } +} - private cleanupActivity() { - const now = Date.now(); - const window = config.lootdrop.activityWindowMs; +async function processMessage(message: Message) { + if (message.author.bot || !message.guild) return; - for (const [channelId, timestamps] of this.channelActivity.entries()) { - const validTimestamps = timestamps.filter(t => now - t < window); - if (validTimestamps.length === 0) { - this.channelActivity.delete(channelId); - } else { - this.channelActivity.set(channelId, validTimestamps); - } + const channelId = message.channel.id; + const now = Date.now(); + + // Check cooldown + const cooldown = channelCooldowns.get(channelId); + if (cooldown && now < cooldown) return; + + // Track activity + const timestamps = channelActivity.get(channelId) || []; + timestamps.push(now); + channelActivity.set(channelId, timestamps); + + // Filter for window + const window = config.lootdrop.activityWindowMs; + const recentActivity = timestamps.filter(t => now - t < window); + + 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, []); } } +} - public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise { - try { - const now = new Date(); - const whereClause = includeClaimed - ? lt(lootdrops.expiresAt, now) - : and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now)); +async function spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: 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; - const result = await DrizzleClient.delete(lootdrops) - .where(whereClause) - .returning(); + const { content, files, components } = await getLootdropMessage(reward, currency); - if (result.length > 0) { - console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`); - } - return result.length; - } catch (error) { - console.error("Failed to cleanup lootdrops:", error); - return 0; - } + 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) + }); + + // Trigger Terminal Update + terminalService.update(channel.guildId); + + } catch (error) { + console.error("Failed to spawn lootdrop:", error); } +} - public async processMessage(message: Message) { - if (message.author.bot || !message.guild) return; +async function tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> { + try { + // Atomic update: Try to set claimedBy where it is currently null + // This acts as a lock and check in one query + const result = await DrizzleClient.update(lootdrops) + .set({ claimedBy: BigInt(userId) }) + .where(and( + eq(lootdrops.messageId, messageId), + isNull(lootdrops.claimedBy) + )) + .returning(); - const channelId = message.channel.id; - const now = Date.now(); + if (result.length === 0 || !result[0]) { + // If update affected 0 rows, check if it was because it doesn't exist or is already claimed + const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId)); + if (check.length === 0) { + return { success: false, error: "This lootdrop has expired." }; + } + return { success: false, error: "This lootdrop has already been claimed." }; + } + + const drop = result[0]; + + await economyService.modifyUserBalance( + userId, + BigInt(drop.rewardAmount), + "LOOTDROP_CLAIM", + `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) { + console.error("Error claiming lootdrop:", error); + return { success: false, error: "An error occurred while processing the reward." }; + } +} + +function getLootdropState() { + let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null; + let maxMessages = -1; + + const window = config.lootdrop.activityWindowMs; + const now = Date.now(); + const required = config.lootdrop.minMessages; + + for (const [channelId, timestamps] of channelActivity.entries()) { + // Filter valid just to be sure we are reporting accurate numbers + const validCount = timestamps.filter(t => now - t < window).length; // Check cooldown - const cooldown = this.channelCooldowns.get(channelId); - if (cooldown && now < cooldown) return; + const cooldownUntil = channelCooldowns.get(channelId); + const isOnCooldown = !!(cooldownUntil && now < cooldownUntil); - // Track activity - const timestamps = this.channelActivity.get(channelId) || []; - timestamps.push(now); - this.channelActivity.set(channelId, timestamps); - - // Filter for window - const window = config.lootdrop.activityWindowMs; - const recentActivity = timestamps.filter(t => now - t < window); - - if (recentActivity.length >= config.lootdrop.minMessages) { - // Chance to spawn - if (Math.random() < config.lootdrop.spawnChance) { - await this.spawnLootdrop(message.channel as TextChannel); - // Set cooldown - this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs); - this.channelActivity.set(channelId, []); - } + if (validCount > maxMessages) { + maxMessages = validCount; + hottestChannel = { + id: channelId, + messages: validCount, + progress: Math.min(100, (validCount / required) * 100), + cooldown: isOnCooldown + }; } } - public async spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: 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 { + monitoredChannels: channelActivity.size, + hottestChannel, + config: { + requiredMessages: required, + dropChance: config.lootdrop.spawnChance + } + }; +} - const { content, files, components } = await getLootdropMessage(reward, currency); +async function clearCaches() { + channelActivity.clear(); + channelCooldowns.clear(); + 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 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) - }); - - // Trigger Terminal Update - terminalService.update(channel.guildId); - - } catch (error) { - console.error("Failed to spawn lootdrop:", error); - } - } - - public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> { - try { - // Atomic update: Try to set claimedBy where it is currently null - // This acts as a lock and check in one query - const result = await DrizzleClient.update(lootdrops) - .set({ claimedBy: BigInt(userId) }) - .where(and( - eq(lootdrops.messageId, messageId), - isNull(lootdrops.claimedBy) - )) - .returning(); - - if (result.length === 0 || !result[0]) { - // If update affected 0 rows, check if it was because it doesn't exist or is already claimed - const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId)); - if (check.length === 0) { - return { success: false, error: "This lootdrop has expired." }; - } - return { success: false, error: "This lootdrop has already been claimed." }; - } - - const drop = result[0]; - - await economyService.modifyUserBalance( - userId, - BigInt(drop.rewardAmount), - "LOOTDROP_CLAIM", - `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) { - console.error("Error claiming lootdrop:", error); - return { success: false, error: "An error occurred while processing the reward." }; - } - } - public getLootdropState() { - let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null; - let maxMessages = -1; - - const window = config.lootdrop.activityWindowMs; - const now = Date.now(); - const required = config.lootdrop.minMessages; - - for (const [channelId, timestamps] of this.channelActivity.entries()) { - // Filter valid just to be sure we are reporting accurate numbers - const validCount = timestamps.filter(t => now - t < window).length; - - // Check cooldown - const cooldownUntil = this.channelCooldowns.get(channelId); - const isOnCooldown = !!(cooldownUntil && now < cooldownUntil); - - if (validCount > maxMessages) { - maxMessages = validCount; - hottestChannel = { - id: channelId, - messages: validCount, - progress: Math.min(100, (validCount / required) * 100), - cooldown: isOnCooldown - }; + 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 { - monitoredChannels: this.channelActivity.size, - hottestChannel, - config: { - requiredMessages: required, - dropChance: config.lootdrop.spawnChance - } - }; - } - - public async clearCaches() { - this.channelActivity.clear(); - this.channelCooldowns.clear(); - console.log("[LootdropService] Caches cleared via administrative action."); - } - public async 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; - } + return true; + } catch (error) { + console.error("Error deleting lootdrop:", error); + return false; } } -export const lootdropService = new LootdropService(); +export const lootdropService = { + cleanupExpiredLootdrops, + processMessage, + spawnLootdrop, + tryClaim, + getLootdropState, + clearCaches, + deleteLootdrop, +};