From ac6283e60c3a50c206de6d7fff74b87a645bf47f Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 15 Dec 2025 21:59:28 +0100 Subject: [PATCH] feat: Introduce dynamic JSON-based configuration for game settings and command toggling via a new admin command. --- src/commands/admin/features.ts | 96 ++++++++++++++++++++++ src/commands/economy/pay.ts | 6 +- src/config/game.json | 33 ++++++++ src/config/game.ts | 29 ------- src/events/guildMemberAdd.ts | 3 +- src/lib/KyokoClient.ts | 16 ++++ src/lib/config.ts | 71 ++++++++++++++++ src/lib/configManager.ts | 20 +++++ src/lib/types.ts | 1 + src/modules/economy/economy.service.ts | 8 +- src/modules/inventory/inventory.service.ts | 14 ++-- src/modules/leveling/leveling.service.ts | 8 +- 12 files changed, 257 insertions(+), 48 deletions(-) create mode 100644 src/commands/admin/features.ts create mode 100644 src/config/game.json delete mode 100644 src/config/game.ts create mode 100644 src/lib/config.ts create mode 100644 src/lib/configManager.ts diff --git a/src/commands/admin/features.ts b/src/commands/admin/features.ts new file mode 100644 index 0000000..8dc6309 --- /dev/null +++ b/src/commands/admin/features.ts @@ -0,0 +1,96 @@ +import { createCommand } from "@/lib/utils"; +import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from "discord.js"; +import { configManager } from "@/lib/configManager"; +import { config, reloadConfig } from "@/lib/config"; +import { KyokoClient } from "@/lib/KyokoClient"; // Import directly from lib, avoiding circular dep with index + +export const features = createCommand({ + data: new SlashCommandBuilder() + .setName("features") + .setDescription("Manage bot features and commands") + .addSubcommand(sub => + sub.setName("list") + .setDescription("List all commands and their status") + ) + .addSubcommand(sub => + sub.setName("toggle") + .setDescription("Enable or disable a command") + .addStringOption(option => + option.setName("command") + .setDescription("The name of the command") + .setRequired(true) + ) + .addBooleanOption(option => + option.setName("enabled") + .setDescription("Whether the command should be enabled") + .setRequired(true) + ) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === "list") { + const activeCommands = KyokoClient.commands; + const categories = new Map(); + + // Group active commands + activeCommands.forEach(cmd => { + const cat = cmd.category || 'Uncategorized'; + if (!categories.has(cat)) categories.set(cat, []); + categories.get(cat)!.push(cmd.data.name); + }); + + // Config overrides + const overrides = Object.entries(config.commands) + .map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`); + + const embed = new EmbedBuilder() + .setTitle("Command Features") + .setColor("Blue"); + + // Add fields for each category + const sortedCategories = [...categories.keys()].sort(); + for (const cat of sortedCategories) { + const cmds = categories.get(cat)!.sort(); + const cmdList = cmds.map(name => { + const isOverride = config.commands[name] !== undefined; + return isOverride ? `**${name}** (See Overrides)` : `**${name}**`; + }).join(", "); + + embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" }); + } + + if (overrides.length > 0) { + embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") }); + } else { + embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." }); + } + + // Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level) + if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { + await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral }); + return; + } + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } else if (subcommand === "toggle") { + const commandName = interaction.options.getString("command", true); + const enabled = interaction.options.getBoolean("enabled", true); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + configManager.toggleCommand(commandName, enabled); + + await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` }); + + // Reload config from disk (which was updated by configManager) + reloadConfig(); + + await KyokoClient.loadCommands(true); + await KyokoClient.deployCommands(); + + await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` }); + } + } +}); diff --git a/src/commands/economy/pay.ts b/src/commands/economy/pay.ts index 2603f9c..3b68480 100644 --- a/src/commands/economy/pay.ts +++ b/src/commands/economy/pay.ts @@ -2,7 +2,7 @@ import { createCommand } from "@/lib/utils"; import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { economyService } from "@/modules/economy/economy.service"; import { userService } from "@/modules/user/user.service"; -import { GameConfig } from "@/config/game"; +import { config } from "@/lib/config"; import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; export const pay = createCommand({ @@ -26,8 +26,8 @@ export const pay = createCommand({ const senderId = interaction.user.id; const receiverId = targetUser.id; - if (amount < GameConfig.economy.transfers.minAmount) { - await interaction.reply({ embeds: [createWarningEmbed(`Amount must be at least ${GameConfig.economy.transfers.minAmount}.`)], ephemeral: true }); + if (amount < config.economy.transfers.minAmount) { + await interaction.reply({ embeds: [createWarningEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], ephemeral: true }); return; } diff --git a/src/config/game.json b/src/config/game.json new file mode 100644 index 0000000..c5601d3 --- /dev/null +++ b/src/config/game.json @@ -0,0 +1,33 @@ +{ + "leveling": { + "base": 100, + "exponent": 2.5, + "chat": { + "cooldownMs": 60000, + "minXp": 15, + "maxXp": 25 + } + }, + "economy": { + "daily": { + "amount": "100", + "streakBonus": "10", + "cooldownMs": 86400000 + }, + "transfers": { + "allowSelfTransfer": false, + "minAmount": "1" + } + }, + "inventory": { + "maxStackSize": "999", + "maxSlots": 50 + }, + "commands": { + "daily": true, + "quests": false, + "inventory": false, + "trade": false, + "balance": false + } +} \ No newline at end of file diff --git a/src/config/game.ts b/src/config/game.ts deleted file mode 100644 index 516b723..0000000 --- a/src/config/game.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const GameConfig = { - leveling: { - // Curve: Base * (Level ^ Exponent) - base: 100, - exponent: 2.5, - - chat: { - cooldownMs: 60000, // 1 minute - minXp: 15, - maxXp: 25, - } - }, - economy: { - daily: { - amount: 100n, - streakBonus: 10n, - cooldownMs: 24 * 60 * 60 * 1000, // 24 hours - }, - transfers: { - // Future use - allowSelfTransfer: false, - minAmount: 1n, - } - }, - inventory: { - maxStackSize: 999n, - maxSlots: 50, - } -} as const; diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 4f0d890..2423c7f 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,10 +1,11 @@ import { Events } from "discord.js"; import type { Event } from "@lib/types"; +// Visitor role const event: Event = { name: Events.GuildMemberAdd, execute: async (member) => { - const role = member.guild.roles.cache.find(role => role.name === "Visitor"); + const role = member.guild.roles.cache.find(role => role.id === "1449859380269940947"); if (!role) return; await member.roles.add(role); }, diff --git a/src/lib/KyokoClient.ts b/src/lib/KyokoClient.ts index 8a3e39c..331d3a7 100644 --- a/src/lib/KyokoClient.ts +++ b/src/lib/KyokoClient.ts @@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises"; import { join } from "node:path"; import type { Command, Event } from "@lib/types"; import { env } from "@lib/env"; +import { config } from "@lib/config"; class Client extends DiscordClient { @@ -56,8 +57,23 @@ class Client extends DiscordClient { continue; } + // Extract category from parent directory name + // filePath is like /path/to/commands/admin/features.ts + // we want "admin" + const pathParts = filePath.split('/'); + const category = pathParts[pathParts.length - 2]; + for (const command of commands) { if (this.isValidCommand(command)) { + command.category = category; // Inject category + + const isEnabled = config.commands[command.data.name] !== false; // Default true if undefined + + if (!isEnabled) { + console.log(`🚫 Skipping disabled command: ${command.data.name}`); + continue; + } + this.commands.set(command.data.name, command); console.log(`✅ Loaded command: ${command.data.name}`); } else { diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..d692842 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,71 @@ +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +const configPath = join(process.cwd(), 'src', 'config', 'game.json'); + +export interface GameConfigType { + leveling: { + base: number; + exponent: number; + chat: { + cooldownMs: number; + minXp: number; + maxXp: number; + } + }, + economy: { + daily: { + amount: bigint; + streakBonus: bigint; + cooldownMs: number; + }, + transfers: { + allowSelfTransfer: boolean; + minAmount: bigint; + } + }, + inventory: { + maxStackSize: bigint; + maxSlots: number; + }, + commands: Record; +} + +// Initial default config state +export const config: GameConfigType = {} as GameConfigType; + +export function reloadConfig() { + if (!existsSync(configPath)) { + throw new Error(`Config file not found at ${configPath}`); + } + + const raw = readFileSync(configPath, 'utf-8'); + const rawConfig = JSON.parse(raw); + + // Update config object in place + config.leveling = rawConfig.leveling; + config.economy = { + daily: { + ...rawConfig.economy.daily, + amount: BigInt(rawConfig.economy.daily.amount), + streakBonus: BigInt(rawConfig.economy.daily.streakBonus), + }, + transfers: { + ...rawConfig.economy.transfers, + minAmount: BigInt(rawConfig.economy.transfers.minAmount), + } + }; + config.inventory = { + ...rawConfig.inventory, + maxStackSize: BigInt(rawConfig.inventory.maxStackSize), + }; + config.commands = rawConfig.commands || {}; + + console.log("🔄 Config reloaded from disk."); +} + +// Initial load +reloadConfig(); + +// Backwards compatibility alias +export const GameConfig = config; diff --git a/src/lib/configManager.ts b/src/lib/configManager.ts new file mode 100644 index 0000000..e27cb10 --- /dev/null +++ b/src/lib/configManager.ts @@ -0,0 +1,20 @@ +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { KyokoClient } from '@/lib/KyokoClient'; + +const configPath = join(process.cwd(), 'src', 'config', 'game.json'); + +export const configManager = { + toggleCommand: (commandName: string, enabled: boolean) => { + const raw = readFileSync(configPath, 'utf-8'); + const data = JSON.parse(raw); + + if (!data.commands) { + data.commands = {}; + } + + data.commands[commandName] = enabled; + + writeFileSync(configPath, JSON.stringify(data, null, 4)); + } +}; diff --git a/src/lib/types.ts b/src/lib/types.ts index 3d1d082..502db2a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3,6 +3,7 @@ import type { ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, Sl export interface Command { data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder; execute: (interaction: ChatInputCommandInteraction) => Promise | void; + category?: string; } export interface Event { diff --git a/src/modules/economy/economy.service.ts b/src/modules/economy/economy.service.ts index bb5ef1c..e48ae3b 100644 --- a/src/modules/economy/economy.service.ts +++ b/src/modules/economy/economy.service.ts @@ -1,7 +1,7 @@ import { users, transactions, userTimers } from "@/db/schema"; import { eq, sql, and } from "drizzle-orm"; import { DrizzleClient } from "@/lib/DrizzleClient"; -import { GameConfig } from "@/config/game"; +import { config } from "@/lib/config"; export const economyService = { transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: any) => { @@ -110,9 +110,9 @@ export const economyService = { streak = 1; } - const bonus = (BigInt(streak) - 1n) * GameConfig.economy.daily.streakBonus; + const bonus = (BigInt(streak) - 1n) * config.economy.daily.streakBonus; - const totalReward = GameConfig.economy.daily.amount + bonus; + const totalReward = config.economy.daily.amount + bonus; await txFn.update(users) .set({ balance: sql`${users.balance} + ${totalReward}`, @@ -122,7 +122,7 @@ export const economyService = { .where(eq(users.id, BigInt(userId))); // Set new cooldown (now + 24h) - const nextReadyAt = new Date(now.getTime() + GameConfig.economy.daily.cooldownMs); + const nextReadyAt = new Date(now.getTime() + config.economy.daily.cooldownMs); await txFn.insert(userTimers) .values({ diff --git a/src/modules/inventory/inventory.service.ts b/src/modules/inventory/inventory.service.ts index 8e2fe28..200ffc3 100644 --- a/src/modules/inventory/inventory.service.ts +++ b/src/modules/inventory/inventory.service.ts @@ -3,7 +3,7 @@ import { inventory, items, users } from "@/db/schema"; import { eq, and, sql, count } from "drizzle-orm"; import { DrizzleClient } from "@/lib/DrizzleClient"; import { economyService } from "@/modules/economy/economy.service"; -import { GameConfig } from "@/config/game"; +import { config } from "@/lib/config"; export const inventoryService = { addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => { @@ -18,8 +18,8 @@ export const inventoryService = { if (existing) { const newQuantity = (existing.quantity ?? 0n) + quantity; - if (newQuantity > GameConfig.inventory.maxStackSize) { - throw new Error(`Cannot exceed max stack size of ${GameConfig.inventory.maxStackSize}`); + if (newQuantity > config.inventory.maxStackSize) { + throw new Error(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`); } const [entry] = await txFn.update(inventory) @@ -39,12 +39,12 @@ export const inventoryService = { .from(inventory) .where(eq(inventory.userId, BigInt(userId))); - if (inventoryCount.count >= GameConfig.inventory.maxSlots) { - throw new Error(`Inventory full (Max ${GameConfig.inventory.maxSlots} slots)`); + if (inventoryCount.count >= config.inventory.maxSlots) { + throw new Error(`Inventory full (Max ${config.inventory.maxSlots} slots)`); } - if (quantity > GameConfig.inventory.maxStackSize) { - throw new Error(`Cannot exceed max stack size of ${GameConfig.inventory.maxStackSize}`); + if (quantity > config.inventory.maxStackSize) { + throw new Error(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`); } const [entry] = await txFn.insert(inventory) diff --git a/src/modules/leveling/leveling.service.ts b/src/modules/leveling/leveling.service.ts index d462b38..dafc950 100644 --- a/src/modules/leveling/leveling.service.ts +++ b/src/modules/leveling/leveling.service.ts @@ -1,12 +1,12 @@ import { users, userTimers } from "@/db/schema"; import { eq, sql, and } from "drizzle-orm"; import { DrizzleClient } from "@/lib/DrizzleClient"; -import { GameConfig } from "@/config/game"; +import { config } from "@/lib/config"; export const levelingService = { // Calculate XP required for a specific level getXpForLevel: (level: number) => { - return Math.floor(GameConfig.leveling.base * Math.pow(level, GameConfig.leveling.exponent)); + return Math.floor(config.leveling.base * Math.pow(level, config.leveling.exponent)); }, // Pure XP addition - No cooldown checks @@ -72,13 +72,13 @@ export const levelingService = { } // Calculate random XP - const amount = BigInt(Math.floor(Math.random() * (GameConfig.leveling.chat.maxXp - GameConfig.leveling.chat.minXp + 1)) + GameConfig.leveling.chat.minXp); + const amount = BigInt(Math.floor(Math.random() * (config.leveling.chat.maxXp - config.leveling.chat.minXp + 1)) + config.leveling.chat.minXp); // Add XP const result = await levelingService.addXp(id, amount, txFn); // Update/Set Cooldown - const nextReadyAt = new Date(now.getTime() + GameConfig.leveling.chat.cooldownMs); + const nextReadyAt = new Date(now.getTime() + config.leveling.chat.cooldownMs); await txFn.insert(userTimers) .values({