forked from syntaxbullet/AuroraBot-discord
feat: Introduce lootdrop functionality, enabling activity-based spawning and interactive claiming, alongside new configuration parameters.
This commit is contained in:
@@ -17,6 +17,10 @@ const event: Event<Events.InteractionCreate> = {
|
||||
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()) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const event: Event<Events.MessageCreate> = {
|
||||
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));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ export interface GameConfigType {
|
||||
maxSlots: number;
|
||||
},
|
||||
commands: Record<string, boolean>;
|
||||
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.");
|
||||
}
|
||||
|
||||
44
src/modules/economy/lootdrop.interaction.ts
Normal file
44
src/modules/economy/lootdrop.interaction.ts
Normal file
@@ -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<ButtonBuilder>()
|
||||
.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.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
154
src/modules/economy/lootdrop.service.ts
Normal file
154
src/modules/economy/lootdrop.service.ts
Normal file
@@ -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<string, number[]> = new Map();
|
||||
private channelCooldowns: Map<string, number> = new Map();
|
||||
private activeLootdrops: Map<string, Lootdrop> = 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<ButtonBuilder>()
|
||||
.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();
|
||||
Reference in New Issue
Block a user