From 56ad5b49cd4dd5b4af8d87c87e920932c6ceeaca Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 18 Dec 2025 16:09:52 +0100 Subject: [PATCH] feat: Introduce lootdrop functionality, enabling activity-based spawning and interactive claiming, alongside new configuration parameters. --- src/events/interactionCreate.ts | 4 + src/events/messageCreate.ts | 5 + src/lib/config.ts | 12 ++ src/modules/economy/lootdrop.interaction.ts | 44 ++++++ src/modules/economy/lootdrop.service.ts | 154 ++++++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 src/modules/economy/lootdrop.interaction.ts create mode 100644 src/modules/economy/lootdrop.service.ts diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 3963e8e..f924a4a 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -17,6 +17,10 @@ const event: Event = { await import("@/modules/economy/shop.interaction").then(m => m.handleShopInteraction(interaction)); return; } + if (interaction.customId.startsWith("lootdrop_") && interaction.isButton()) { + await import("@/modules/economy/lootdrop.interaction").then(m => m.handleLootdropInteraction(interaction)); + return; + } } if (interaction.isAutocomplete()) { diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 2988879..e838b2a 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -13,6 +13,11 @@ const event: Event = { if (!user) return; levelingService.processChatXp(message.author.id); + + // Activity Tracking for Lootdrops + // We do dynamic import to avoid circular dependency issues if any, though likely not needed here. + // But better safe for modules. Actually direct import is fine if structure is clean. + import("@/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message)); }, }; diff --git a/src/lib/config.ts b/src/lib/config.ts index 9f83678..0e6b068 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -29,6 +29,17 @@ export interface GameConfigType { maxSlots: number; }, commands: Record; + lootdrop: { + activityWindowMs: number; + minMessages: number; + spawnChance: number; + cooldownMs: number; + reward: { + min: number; + max: number; + currency: string; + } + }; } // Initial default config state @@ -60,6 +71,7 @@ export function reloadConfig() { maxStackSize: BigInt(rawConfig.inventory.maxStackSize), }; config.commands = rawConfig.commands || {}; + config.lootdrop = rawConfig.lootdrop; console.log("🔄 Config reloaded from disk."); } diff --git a/src/modules/economy/lootdrop.interaction.ts b/src/modules/economy/lootdrop.interaction.ts new file mode 100644 index 0000000..b11c182 --- /dev/null +++ b/src/modules/economy/lootdrop.interaction.ts @@ -0,0 +1,44 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, EmbedBuilder, ButtonStyle } from "discord.js"; +import { lootdropService } from "./lootdrop.service"; +import { createErrorEmbed } from "@/lib/embeds"; + +export async function handleLootdropInteraction(interaction: ButtonInteraction) { + if (interaction.customId === "lootdrop_claim") { + await interaction.deferReply({ ephemeral: true }); + + const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username); + + if (result.success) { + await interaction.editReply({ + content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!` + }); + + // Update original message to show claimed state + const originalEmbed = interaction.message.embeds[0]; + if (!originalEmbed) return; + + const newEmbed = new EmbedBuilder(originalEmbed.data) + .setDescription(`✅ Claimed by <@${interaction.user.id}> for **${result.amount} ${result.currency}**!`) + .setColor("#00FF00"); + + // Disable button + // We reconstruct the button using builders for safety + const newRow = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("lootdrop_claim_disabled") + .setLabel("CLAIMED") + .setStyle(ButtonStyle.Secondary) + .setEmoji("✅") + .setDisabled(true) + ); + + await interaction.message.edit({ embeds: [newEmbed], components: [newRow] }); + + } else { + await interaction.editReply({ + embeds: [createErrorEmbed(result.error || "Failed to claim.")] + }); + } + } +} diff --git a/src/modules/economy/lootdrop.service.ts b/src/modules/economy/lootdrop.service.ts new file mode 100644 index 0000000..b670b5b --- /dev/null +++ b/src/modules/economy/lootdrop.service.ts @@ -0,0 +1,154 @@ + +import { Message, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from "discord.js"; +import { config } from "@/lib/config"; +import { economyService } from "./economy.service"; + +interface Lootdrop { + messageId: string; + channelId: string; + rewardAmount: number; + currency: string; + claimedBy?: string; + createdAt: Date; +} + +class LootdropService { + private channelActivity: Map = new Map(); + private channelCooldowns: Map = new Map(); + private activeLootdrops: Map = new Map(); // key: messageId + + constructor() { + // Cleanup interval for activity tracking + setInterval(() => this.cleanupActivity(), 60000); + } + + private cleanupActivity() { + const now = Date.now(); + const window = config.lootdrop.activityWindowMs; + + 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); + } + } + } + + public async processMessage(message: Message) { + if (message.author.bot || !message.guild) return; + + const channelId = message.channel.id; + const now = Date.now(); + + // Check cooldown + const cooldown = this.channelCooldowns.get(channelId); + if (cooldown && now < cooldown) return; + + // 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); + // Reset activity for this channel to prevent immediate double spawn? + // Maybe not strictly necessary if cooldown handles it, but good practice. + this.channelActivity.set(channelId, []); + } + } + } + + private async spawnLootdrop(channel: TextChannel) { + const min = config.lootdrop.reward.min; + const max = config.lootdrop.reward.max; + const reward = Math.floor(Math.random() * (max - min + 1)) + min; + const currency = config.lootdrop.reward.currency; + + const embed = new EmbedBuilder() + .setTitle("💰 LOOTDROP!") + .setDescription(`A lootdrop has appeared! Click the button below to claim **${reward} ${currency}**!`) + .setColor("#FFD700") + .setTimestamp(); + + const claimButton = new ButtonBuilder() + .setCustomId("lootdrop_claim") + .setLabel("CLAIM REWARD") + .setStyle(ButtonStyle.Success) + .setEmoji("💸"); + + const row = new ActionRowBuilder() + .addComponents(claimButton); + + try { + const message = await channel.send({ embeds: [embed], components: [row] }); + + this.activeLootdrops.set(message.id, { + messageId: message.id, + channelId: channel.id, + rewardAmount: reward, + currency: currency, + createdAt: new Date() + }); + + // Auto-cleanup unclaimable drops after 10 minutes (optional, prevents memory leak) + setTimeout(() => { + const drop = this.activeLootdrops.get(message.id); + if (drop && !drop.claimedBy) { + this.activeLootdrops.delete(message.id); + // Optionally edit message to say expired + } + }, 600000); + + } 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 }> { + const drop = this.activeLootdrops.get(messageId); + + if (!drop) { + return { success: false, error: "This lootdrop has expired or already been fully processed." }; + } + + if (drop.claimedBy) { + return { success: false, error: "This lootdrop has already been claimed." }; + } + + // Lock it + drop.claimedBy = userId; + this.activeLootdrops.set(messageId, drop); + + try { + await economyService.modifyUserBalance( + userId, + BigInt(drop.rewardAmount), + "LOOTDROP_CLAIM", + `Claimed lootdrop in channel ${drop.channelId}` + ); + + // Clean up from memory + this.activeLootdrops.delete(messageId); + + return { success: true, amount: drop.rewardAmount, currency: drop.currency }; + } catch (error) { + console.error("Error claiming lootdrop:", error); + // Unlock if failed? + drop.claimedBy = undefined; + this.activeLootdrops.set(messageId, drop); + return { success: false, error: "An error occurred while processing the reward." }; + } + } +} + +export const lootdropService = new LootdropService();