import { config } from "@shared/lib/config"; import { economyService } from "./economy.service"; import { lootdrops } from "@db/schema"; import { DrizzleClient } from "@shared/db/DrizzleClient"; import { eq, and, isNull, lt } from "drizzle-orm"; // 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); } } } // Initialize cleanup interval on module load setInterval(() => { cleanupActivity(); 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; } } /** * 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 { shouldSpawn: false }; // 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) { // Set cooldown channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs); channelActivity.set(channelId, []); return { shouldSpawn: true }; } } return { shouldSpawn: false }; } /** * 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 }; } /** * 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 { // First fetch it to get channel info const drop = await DrizzleClient.query.lootdrops.findFirst({ where: eq(lootdrops.messageId, messageId) }); if (!drop) return null; // Delete from DB await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId)); return { channelId: drop.channelId }; } catch (error) { console.error("Error removing lootdrop:", error); return null; } } 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(); 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}` ); 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 cooldownUntil = 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 }; } } return { monitoredChannels: channelActivity.size, hottestChannel, config: { requiredMessages: required, dropChance: config.lootdrop.spawnChance } }; } async function clearCaches() { channelActivity.clear(); channelCooldowns.clear(); console.log("[LootdropService] Caches cleared via administrative action."); } export const lootdropService = { /** Delete expired lootdrops from the database; optionally includes already-claimed ones. */ cleanupExpiredLootdrops, /** Record a message in a channel and return whether a lootdrop should spawn based on activity and RNG. */ trackActivity, /** Calculate a random lootdrop reward amount and currency, with optional overrides. */ calculateReward, /** Save a spawned lootdrop to the database with a 10-minute expiration. */ persistLootdrop, /** Remove a lootdrop by message ID and return its channel ID for Discord cleanup. */ removeLootdrop, /** Atomically claim a lootdrop for a user; credits reward to their balance. */ tryClaim, /** Get current lootdrop system state including the most active channel and spawn config. */ getLootdropState, /** Clear all in-memory activity tracking and cooldown caches. */ clearCaches, };