2 Commits

8 changed files with 305 additions and 5 deletions

2
.gitignore vendored
View File

@@ -7,7 +7,7 @@ db-data
node_modules node_modules
src/config config/
# output # output
out out

View File

@@ -0,0 +1,68 @@
import { createCommand } from "@lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
import { config, saveConfig } from "@lib/config";
import type { GameConfigType } from "@lib/config";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
export const configCommand = createCommand({
data: new SlashCommandBuilder()
.setName("config")
.setDescription("Edit the bot configuration")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
console.log(`Config command executed by ${interaction.user.tag}`);
const replacer = (key: string, value: any) => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
const currentConfigJson = JSON.stringify(config, replacer, 4);
const modal = new ModalBuilder()
.setCustomId("config-modal")
.setTitle("Edit Configuration");
const jsonInput = new TextInputBuilder()
.setCustomId("json-input")
.setLabel("Configuration JSON")
.setStyle(TextInputStyle.Paragraph)
.setValue(currentConfigJson)
.setRequired(true);
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
modal.addComponents(actionRow);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 300000, // 5 minutes
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
});
const jsonString = submitted.fields.getTextInputValue("json-input");
try {
const newConfig = JSON.parse(jsonString);
saveConfig(newConfig as GameConfigType);
await submitted.reply({
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
});
} catch (parseError) {
await submitted.reply({
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
ephemeral: true
});
}
} catch (error) {
// Timeout or other error handling if needed, usually just ignore timeouts for modals
if (error instanceof Error && error.message.includes('time')) {
// specific timeout handling if desired
}
}
}
});

View File

@@ -17,6 +17,10 @@ const event: Event<Events.InteractionCreate> = {
await import("@/modules/economy/shop.interaction").then(m => m.handleShopInteraction(interaction)); await import("@/modules/economy/shop.interaction").then(m => m.handleShopInteraction(interaction));
return; return;
} }
if (interaction.customId.startsWith("lootdrop_") && interaction.isButton()) {
await import("@/modules/economy/lootdrop.interaction").then(m => m.handleLootdropInteraction(interaction));
return;
}
} }
if (interaction.isAutocomplete()) { if (interaction.isAutocomplete()) {

View File

@@ -13,6 +13,11 @@ const event: Event<Events.MessageCreate> = {
if (!user) return; if (!user) return;
levelingService.processChatXp(message.author.id); 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));
}, },
}; };

View File

@@ -11,4 +11,4 @@ await KyokoClient.deployCommands();
if (!env.DISCORD_BOT_TOKEN) { if (!env.DISCORD_BOT_TOKEN) {
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables."); throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
} }
KyokoClient.login(env.DISCORD_BOT_TOKEN); KyokoClient.login(env.DISCORD_BOT_TOKEN);

View File

@@ -1,7 +1,7 @@
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
const configPath = join(process.cwd(), 'src', 'config', 'config.json'); const configPath = join(import.meta.dir, '..', '..', 'config', 'config.json');
export interface GameConfigType { export interface GameConfigType {
leveling: { leveling: {
@@ -29,6 +29,17 @@ export interface GameConfigType {
maxSlots: number; maxSlots: number;
}, },
commands: Record<string, boolean>; commands: Record<string, boolean>;
lootdrop: {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
}
};
} }
// Initial default config state // Initial default config state
@@ -60,6 +71,7 @@ export function reloadConfig() {
maxStackSize: BigInt(rawConfig.inventory.maxStackSize), maxStackSize: BigInt(rawConfig.inventory.maxStackSize),
}; };
config.commands = rawConfig.commands || {}; config.commands = rawConfig.commands || {};
config.lootdrop = rawConfig.lootdrop;
console.log("🔄 Config reloaded from disk."); console.log("🔄 Config reloaded from disk.");
} }
@@ -69,3 +81,16 @@ reloadConfig();
// Backwards compatibility alias // Backwards compatibility alias
export const GameConfig = config; export const GameConfig = config;
export function saveConfig(newConfig: GameConfigType) {
const replacer = (key: string, value: any) => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
const jsonString = JSON.stringify(newConfig, replacer, 4);
writeFileSync(configPath, jsonString, 'utf-8');
reloadConfig();
}

View 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.")]
});
}
}
}

View 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();