11 Commits

Author SHA1 Message Date
syntaxbullet
3a96b67e89 feat: Allow item effects to specify durations in hours, minutes, or seconds. 2025-12-15 23:26:51 +01:00
syntaxbullet
d3ade218ec feat: add /use command for inventory items with effects, implement XP boosts, and enhance scheduler for temporary role removal. 2025-12-15 23:22:51 +01:00
syntaxbullet
1d4263e178 feat: Introduced an admin listing command and shop interaction module, replacing the sell command, and added a type-checking script. 2025-12-15 22:52:26 +01:00
syntaxbullet
727b63b4dc refactor: trigger application reload by appending to entry file instead of updating its times. 2025-12-15 22:38:32 +01:00
syntaxbullet
d2edde77e6 build: Install git system dependency in Dockerfile. 2025-12-15 22:32:27 +01:00
syntaxbullet
3acb5304f5 feat: Introduce admin webhook and enhanced reload commands with redeploy functionality, implement post-restart notifications, and update Docker container names from Kyoko to Aurora. 2025-12-15 22:29:03 +01:00
syntaxbullet
9333d6ac6c feat: Prevent inventory, profile, balance, and pay commands from targeting bot users. 2025-12-15 22:21:29 +01:00
syntaxbullet
7e986fae5a feat: Implement custom error classes, a Drizzle transaction utility, and update Discord.js ephemeral message flags. 2025-12-15 22:14:17 +01:00
syntaxbullet
3c81fd8396 refactor: rename game.json to config.json and update file path references 2025-12-15 22:02:35 +01:00
syntaxbullet
3984d6112b refactor: rename KyokoClient to BotClient and update all imports. 2025-12-15 22:01:19 +01:00
syntaxbullet
ac6283e60c feat: Introduce dynamic JSON-based configuration for game settings and command toggling via a new admin command. 2025-12-15 21:59:28 +01:00
37 changed files with 862 additions and 456 deletions

View File

@@ -1,6 +1,9 @@
FROM oven/bun:latest AS base
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y git
# Install dependencies
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

1
check.sh Executable file
View File

@@ -0,0 +1 @@
tsc --noEmit

View File

@@ -1,7 +1,7 @@
services:
db:
image: postgres:17-alpine
container_name: kyoko_db
container_name: aurora_db
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
@@ -12,7 +12,7 @@ services:
- ./src/db/data:/var/lib/postgresql/data
- ./src/db/log:/var/log/postgresql
app:
container_name: kyoko_app
container_name: aurora_app
image: kyoko-app
build:
context: .
@@ -38,7 +38,7 @@ services:
command: bun run dev
studio:
container_name: kyoko_studio
container_name: aurora_studio
image: kyoko-app
build:
context: .

View File

@@ -1,135 +0,0 @@
import { DrizzleClient } from "@/lib/DrizzleClient";
import { userService } from "@/modules/user/user.service";
import { questService } from "@/modules/quest/quest.service";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { economyService } from "@/modules/economy/economy.service";
import { classService } from "@/modules/class/class.service";
import { levelingService } from "@/modules/leveling/leveling.service";
import { quests, items, classes, users, inventory } from "@/db/schema";
import { eq } from "drizzle-orm";
const TEST_ID = "999999999";
const TEST_USERNAME = "verification_bot";
const RANDOM_SUFFIX = Math.floor(Math.random() * 10000);
const TEST_CLASS_NAME = `Test Class ${RANDOM_SUFFIX}`;
const TEST_QUEST_NAME = `Test Quest ${RANDOM_SUFFIX}`;
const TEST_ITEM_NAME = `Test Potion ${RANDOM_SUFFIX}`;
const TEST_CLASS_ID = BigInt(10000 + RANDOM_SUFFIX);
const TEST_QUEST_ID = 10000 + RANDOM_SUFFIX;
const TEST_ITEM_ID = 10000 + RANDOM_SUFFIX;
async function verify() {
console.log("Starting verification...");
try {
// Cleanup previous run if checking same ID
try { await userService.deleteUser(TEST_ID); } catch { }
// 1. Setup Data (Class, Quest, Item)
// Ensure we have a class
let [cls] = await DrizzleClient.insert(classes).values({
id: TEST_CLASS_ID,
name: TEST_CLASS_NAME,
balance: 1000n
}).returning();
// Ensure we have a quest
let [quest] = await DrizzleClient.insert(quests).values({
id: TEST_QUEST_ID,
name: TEST_QUEST_NAME,
triggerEvent: "manual",
rewards: { xp: 500, balance: 100 }
}).returning();
// Ensure we have an item
let [item] = await DrizzleClient.insert(items).values({
id: TEST_ITEM_ID,
name: TEST_ITEM_NAME,
price: 50n,
iconUrl: "x",
imageUrl: "x"
}).returning();
// 2. Create User
console.log("Creating user...");
await userService.createUser(TEST_ID, TEST_USERNAME);
let user = await userService.getUserById(TEST_ID);
if (!user) throw new Error("User create failed");
console.log("User created:", user.username);
// 3. Assign Class & Modify Class Balance
console.log("Assigning class...");
await classService.assignClass(TEST_ID, cls!.id);
console.log("Modifying class balance...");
const clsBalBefore = (await DrizzleClient.query.classes.findFirst({ where: eq(classes.id, cls!.id) }))!.balance ?? 0n;
await classService.modifyClassBalance(cls!.id, 50n);
const clsBalAfter = (await DrizzleClient.query.classes.findFirst({ where: eq(classes.id, cls!.id) }))!.balance ?? 0n;
if (clsBalAfter !== clsBalBefore + 50n) throw new Error(`Class balance mismatch: ${clsBalAfter} vs ${clsBalBefore + 50n}`);
console.log("Class balance verified.");
// 4. Assign & Complete Quest (Check Logic)
console.log("Assigning quest...");
await questService.assignQuest(TEST_ID, quest!.id);
console.log("Completing quest...");
// Initial state
const initialXp = user.xp ?? 0n;
const initialBal = user.balance ?? 0n;
const result = await questService.completeQuest(TEST_ID, quest!.id);
if (!result.success) throw new Error("Quest completion failed");
// Refresh User
user = await userService.getUserById(TEST_ID);
if (!user) throw new Error("User lost");
console.log("Quest Rewards:", result.rewards);
console.log("User State:", { xp: user.xp, balance: user.balance, level: user.level });
if (user.balance !== initialBal + BigInt(result.rewards.balance)) throw new Error("Balance reward logic failed");
if (user.xp !== initialXp + BigInt(result.rewards.xp)) throw new Error("XP reward logic failed");
// 5. Buy Item (Check Atomic Logic)
console.log("Buying item...");
const buyResult = await inventoryService.buyItem(TEST_ID, item!.id, 2n);
if (!buyResult.success) throw new Error("Buy item failed");
// Refresh User
user = await userService.getUserById(TEST_ID);
const expectedBal = initialBal + BigInt(result.rewards.balance) - (item!.price! * 2n);
if (user!.balance !== expectedBal) throw new Error(`Buy logic balance mismatch: ${user!.balance} vs ${expectedBal}`);
const inv = await inventoryService.getInventory(TEST_ID);
const invItem = inv.find(i => i.itemId === item!.id);
if (!invItem || invItem.quantity !== 2n) throw new Error("Inventory item mismatch");
console.log("Buy Verification Successful.");
// Cleanup
await userService.deleteUser(TEST_ID);
// Also clean up metadata
await DrizzleClient.delete(inventory).where(eq(inventory.itemId, TEST_ITEM_ID)); // Cascade should handle user link, but manually cleaning item/quest/class
await DrizzleClient.delete(items).where(eq(items.id, TEST_ITEM_ID));
await DrizzleClient.delete(quests).where(eq(quests.id, TEST_QUEST_ID));
await DrizzleClient.delete(classes).where(eq(classes.id, TEST_CLASS_ID));
console.log("Cleanup done. Verification PASSED.");
} catch (e) {
console.error("Verification FAILED:", e);
// Attempt cleanup
try { await userService.deleteUser(TEST_ID); } catch { }
try { if (TEST_ITEM_ID) await DrizzleClient.delete(inventory).where(eq(inventory.itemId, TEST_ITEM_ID)); } catch { }
try { if (TEST_ITEM_ID) await DrizzleClient.delete(items).where(eq(items.id, TEST_ITEM_ID)); } catch { }
try { if (TEST_QUEST_ID) await DrizzleClient.delete(quests).where(eq(quests.id, TEST_QUEST_ID)); } catch { }
try { if (TEST_CLASS_ID) await DrizzleClient.delete(classes).where(eq(classes.id, TEST_CLASS_ID)); } catch { }
process.exit(1);
}
}
verify();

View File

@@ -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/BotClient"; // 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<string, string[]>();
// 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!` });
}
}
});

View File

@@ -0,0 +1,77 @@
import { createCommand } from "@/lib/utils";
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type BaseGuildTextChannel,
PermissionFlagsBits,
MessageFlags
} from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
export const listing = createCommand({
data: new SlashCommandBuilder()
.setName("listing")
.setDescription("Post an item listing in the channel for users to buy")
.addNumberOption(option =>
option.setName("itemid")
.setDescription("The ID of the item to list")
.setRequired(true)
)
.addChannelOption(option =>
option.setName("channel")
.setDescription("The channel to post the listing in (defaults to current)")
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const itemId = interaction.options.getNumber("itemid", true);
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
if (!targetChannel || !targetChannel.isSendable()) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
return;
}
const item = await inventoryService.getItem(itemId);
if (!item) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
return;
}
if (!item.price) {
await interaction.editReply({ content: "", embeds: [createWarningEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
return;
}
const embed = new EmbedBuilder()
.setTitle(`Shop: ${item.name}`)
.setDescription(item.description || "No description available.")
.addFields({ name: "Price", value: `${item.price} 🪙`, inline: true })
.setColor("Green")
.setThumbnail(item.iconUrl || null)
.setImage(item.imageUrl || null)
.setFooter({ text: "Click the button below to purchase instantly." });
const buyButton = new ButtonBuilder()
.setCustomId(`shop_buy_${item.id}`)
.setLabel(`Buy for ${item.price} 🪙`)
.setStyle(ButtonStyle.Success)
.setEmoji("🛒");
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
try {
await targetChannel.send({ embeds: [embed], components: [actionRow] });
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
} catch (error) {
console.error("Failed to send listing message:", error);
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Failed to post the listing.")] });
}
}
});

View File

@@ -0,0 +1,82 @@
import { createCommand } from "@lib/utils";
import { KyokoClient } from "@/lib/BotClient";
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed } from "@lib/embeds";
export const reload = createCommand({
data: new SlashCommandBuilder()
.setName("reload")
.setDescription("Reloads all commands")
.addBooleanOption(option =>
option
.setName("redeploy")
.setDescription("Pull latest changes from git and restart the bot")
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const redeploy = interaction.options.getBoolean("redeploy") ?? false;
if (redeploy) {
const { exec } = await import("child_process");
const { promisify } = await import("util");
const { writeFile, utimes } = await import("fs/promises");
const execAsync = promisify(exec);
try {
await interaction.editReply({
embeds: [createWarningEmbed("Pulling latest changes and restarting...", "Redeploy Initiated")]
});
const { stdout, stderr } = await execAsync("git pull");
if (stderr && !stdout) {
throw new Error(stderr);
}
await interaction.editReply({
embeds: [createSuccessEmbed(`Git Pull Output:\n\`\`\`\n${stdout}\n\`\`\`\nRestarting process...`, "Update Successful")]
});
// Write context for post-restart notification
await writeFile(".restart_context.json", JSON.stringify({
channelId: interaction.channelId,
userId: interaction.user.id,
timestamp: Date.now()
}));
// Trigger restart by touching entry point
const { appendFile } = await import("fs/promises");
await appendFile("src/index.ts", " ");
} catch (error) {
console.error(error);
await interaction.editReply({
embeds: [createErrorEmbed(`Failed to redeploy:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Redeploy Failed")]
});
}
return;
}
try {
const start = Date.now();
await KyokoClient.loadCommands(true);
const duration = Date.now() - start;
// Deploy commands
await KyokoClient.deployCommands();
const embed = createSuccessEmbed(
`Successfully reloaded ${KyokoClient.commands.size} commands in ${duration}ms.`,
"System Reloaded"
);
await interaction.editReply({ embeds: [embed] });
} catch (error) {
console.error(error);
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while reloading commands. Check console for details.", "Reload Failed")] });
}
}
});

View File

@@ -1,5 +1,5 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel } from "discord.js";
import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel, MessageFlags } from "discord.js";
import { createErrorEmbed } from "@/lib/embeds";
export const webhook = createCommand({
@@ -13,7 +13,7 @@ export const webhook = createCommand({
.setRequired(true)
),
execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true });
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const payloadString = interaction.options.getString("payload", true);
let payload;

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service";
import { createWarningEmbed } from "@/lib/embeds";
export const balance = createCommand({
data: new SlashCommandBuilder()
@@ -15,6 +16,11 @@ export const balance = createCommand({
await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user;
if (targetUser.bot) {
return; // Wait, I need to send the reply inside the if.
}
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const embed = new EmbedBuilder()

View File

@@ -1,5 +1,5 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js";
import { economyService } from "@/modules/economy/economy.service";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
@@ -25,12 +25,12 @@ export const daily = createCommand({
} catch (error: any) {
if (error.message.includes("Daily already claimed")) {
await interaction.reply({ embeds: [createWarningEmbed(error.message, "Cooldown")], ephemeral: true });
await interaction.reply({ embeds: [createWarningEmbed(error.message, "Cooldown")], flags: MessageFlags.Ephemeral });
return;
}
console.error(error);
await interaction.reply({ embeds: [createErrorEmbed("An error occurred while claiming your daily reward.")], ephemeral: true });
await interaction.reply({ embeds: [createErrorEmbed("An error occurred while claiming your daily reward.")], flags: MessageFlags.Ephemeral });
}
}
});

View File

@@ -1,9 +1,10 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js";
import { economyService } from "@/modules/economy/economy.service";
import { userService } from "@/modules/user/user.service";
import { GameConfig } from "@/config/game";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
import { config } from "@/lib/config";
import { createErrorEmbed, createWarningEmbed } from "@/lib/embeds";
import { UserError } from "@/lib/errors";
export const pay = createCommand({
data: new SlashCommandBuilder()
@@ -22,17 +23,24 @@ export const pay = createCommand({
),
execute: async (interaction) => {
const targetUser = await userService.getOrCreateUser(interaction.options.getUser("user", true).id, interaction.options.getUser("user", true).username);
const discordUser = interaction.options.getUser("user", true);
if (discordUser.bot) {
await interaction.reply({ embeds: [createWarningEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
return;
}
const amount = BigInt(interaction.options.getInteger("amount", true));
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}.`)], flags: MessageFlags.Ephemeral });
return;
}
if (senderId === receiverId) {
await interaction.reply({ embeds: [createWarningEmbed("You cannot pay yourself.")], ephemeral: true });
await interaction.reply({ embeds: [createWarningEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
return;
}
@@ -48,12 +56,12 @@ export const pay = createCommand({
await interaction.reply({ embeds: [embed] });
} catch (error: any) {
if (error.message.includes("Insufficient funds")) {
await interaction.reply({ embeds: [createWarningEmbed("Insufficient funds.")], ephemeral: true });
if (error instanceof UserError) {
await interaction.reply({ embeds: [createWarningEmbed(error.message)], flags: MessageFlags.Ephemeral });
return;
}
console.error(error);
await interaction.reply({ embeds: [createErrorEmbed("Transfer failed.")], ephemeral: true });
await interaction.reply({ embeds: [createErrorEmbed("Transfer failed due to an unexpected error.")], flags: MessageFlags.Ephemeral });
}
}
});

View File

@@ -1,125 +0,0 @@
import { createCommand } from "@/lib/utils";
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ComponentType,
type BaseGuildTextChannel,
type ButtonInteraction,
PermissionFlagsBits,
MessageFlags
} from "discord.js";
import { userService } from "@/modules/user/user.service";
import { inventoryService } from "@/modules/inventory/inventory.service";
import type { items } from "@db/schema";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
export const sell = createCommand({
data: new SlashCommandBuilder()
.setName("sell")
.setDescription("Post an item for sale in the current channel so regular users can buy it")
.addNumberOption(option =>
option.setName("itemid")
.setDescription("The ID of the item to sell")
.setRequired(true)
)
.addChannelOption(option =>
option.setName("channel")
.setDescription("The channel to post the item in")
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const itemId = interaction.options.getNumber("itemid", true);
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
if (!targetChannel || !targetChannel.isSendable()) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
return;
}
const item = await inventoryService.getItem(itemId);
if (!item) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
return;
}
if (!item.price) {
await interaction.editReply({ content: "", embeds: [createWarningEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
return;
}
const embed = new EmbedBuilder()
.setTitle(`Item for sale: ${item.name}`)
.setDescription(item.description || "No description available.")
.addFields({ name: "Price", value: `${item.price} 🪙`, inline: true })
.setColor("Yellow")
.setThumbnail(item.iconUrl || null)
.setImage(item.imageUrl || null);
const buyButton = new ButtonBuilder()
.setCustomId("buy")
.setLabel("Buy")
.setStyle(ButtonStyle.Success);
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
try {
const message = await targetChannel.send({ embeds: [embed], components: [actionRow] });
await interaction.editReply({ content: `Item posted in ${targetChannel}.` });
// Create a collector on the specific message
const collector = message.createMessageComponentCollector({
componentType: ComponentType.Button,
filter: (i) => i.customId === "buy",
});
collector.on("collect", async (i) => {
await handleBuyInteraction(i, item);
});
} catch (error) {
console.error("Failed to send sell message:", error);
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Failed to post the item for sale.")] });
}
}
});
async function handleBuyInteraction(interaction: ButtonInteraction, item: typeof items.$inferSelect) {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userId = interaction.user.id;
const user = await userService.getUserById(userId);
if (!user) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed("User profile not found.")] });
return;
}
if ((user.balance ?? 0n) < (item.price ?? 0n)) {
await interaction.editReply({ content: "", embeds: [createWarningEmbed(`You don't have enough money! You need ${item.price} 🪙.`)] });
return;
}
const result = await inventoryService.buyItem(userId, item.id, 1n);
if (!result.success) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Transaction failed. Please try again.")] });
return;
}
await interaction.editReply({ content: `Successfully bought **${item.name}** for ${item.price} 🪙!` });
} catch (error) {
console.error("Error processing purchase:", error);
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed("An error occurred while processing your purchase.")] });
} else {
await interaction.reply({ embeds: [createErrorEmbed("An error occurred while processing your purchase.")], flags: MessageFlags.Ephemeral });
}
}
}

View File

@@ -1,5 +1,5 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration } from "discord.js";
import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
import { TradeService } from "@/modules/trade/trade.service";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
@@ -16,19 +16,19 @@ export const trade = createCommand({
const targetUser = interaction.options.getUser("user", true);
if (targetUser.id === interaction.user.id) {
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], ephemeral: true });
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], flags: MessageFlags.Ephemeral });
return;
}
if (targetUser.bot) {
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], ephemeral: true });
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], flags: MessageFlags.Ephemeral });
return;
}
// Create Thread
const channel = interaction.channel;
if (!channel || channel.type === ChannelType.DM) {
await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], ephemeral: true });
await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], flags: MessageFlags.Ephemeral });
return;
}
@@ -53,7 +53,7 @@ export const trade = createCommand({
} catch (err) {
console.error("Failed to delete setup message", err);
}
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], ephemeral: true });
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], flags: MessageFlags.Ephemeral });
return;
}

View File

@@ -17,6 +17,12 @@ export const inventory = createCommand({
await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user;
if (targetUser.bot) {
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have inventories.", "Inventory Check")] });
return;
}
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const items = await inventoryService.getInventory(user.id);

View File

@@ -0,0 +1,95 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { inventory, items } from "@/db/schema";
import { eq, and, like } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import type { ItemUsageData } from "@/lib/types";
export const use = createCommand({
data: new SlashCommandBuilder()
.setName("use")
.setDescription("Use an item from your inventory")
.addNumberOption(option =>
option.setName("item")
.setDescription("The item to use")
.setRequired(true)
.setAutocomplete(true)
),
execute: async (interaction) => {
if (!interaction.isChatInputCommand()) {
if (interaction.isAutocomplete()) {
const focusedValue = interaction.options.getFocused();
const userId = interaction.user.id;
// Fetch owned items that are usable
const userInventory = await DrizzleClient.query.inventory.findMany({
where: eq(inventory.userId, BigInt(userId)),
with: {
item: true
},
limit: 10
});
const filtered = userInventory.filter(entry => {
const matchName = entry.item.name.toLowerCase().includes(focusedValue.toLowerCase());
const usageData = entry.item.usageData as ItemUsageData | null;
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
return matchName && isUsable;
});
await interaction.respond(
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
);
}
return;
}
await interaction.deferReply();
const itemId = interaction.options.getNumber("item", true);
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
try {
const result = await inventoryService.useItem(user.id, itemId);
// Check for side effects like Role assignment that need Discord API access
// The service returns the usageData, so we can re-check simple effects or just check the results log?
// Actually, we put "TEMP_ROLE" inside results log, AND we can check usageData here for strict role assignment if we want to separate concerns.
// But for now, let's rely on the service to have handled database state, and we handle Discord state here if needed?
// WAIT - I put the role assignment placeholder in the service but it returned a result string.
// The service cannot assign the role directly because it doesn't have the member object easily (requires fetching).
// So we should iterate results or usageData here.
const usageData = result.usageData;
if (usageData) {
for (const effect of usageData.effects) {
if (effect.type === 'TEMP_ROLE') {
try {
const member = await interaction.guild?.members.fetch(user.id);
if (member) {
await member.roles.add(effect.roleId);
}
} catch (e) {
console.error("Failed to assign role in /use command:", e);
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
}
}
}
}
const embed = createSuccessEmbed(
result.results.map(r => `${r}`).join("\n"),
`Used ${result.usageData.effects.length > 0 ? 'Item' : 'Item'}` // Generic title, improves below
);
embed.setTitle("Item Used!");
await interaction.editReply({ embeds: [embed] });
} catch (error: any) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
}
}
});

View File

@@ -1,5 +1,5 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js";
import { questService } from "@/modules/quest/quest.service";
import { createWarningEmbed } from "@lib/embeds";
@@ -8,7 +8,7 @@ export const quests = createCommand({
.setName("quests")
.setDescription("View your active quests"),
execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true });
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userQuests = await questService.getUserQuests(interaction.user.id);

View File

@@ -1,30 +0,0 @@
import { createCommand } from "@lib/utils";
import { KyokoClient } from "@lib/KyokoClient";
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from "discord.js";
import { createErrorEmbed } from "@lib/embeds";
export const reload = createCommand({
data: new SlashCommandBuilder()
.setName("reload")
.setDescription("Reloads all commands")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true });
try {
await KyokoClient.loadCommands(true);
const embed = new EmbedBuilder()
.setTitle("✅ System Reloaded")
.setDescription(`Successfully reloaded ${KyokoClient.commands.size} commands.`)
.setColor("Green");
// Deploy commands
await KyokoClient.deployCommands();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
console.error(error);
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while reloading commands. Check console for details.", "Reload Failed")] });
}
}
});

View File

@@ -2,6 +2,7 @@ import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service";
import { generateStudentIdCard } from "@/graphics/studentID";
import { createWarningEmbed } from "@/lib/embeds";
export const profile = createCommand({
data: new SlashCommandBuilder()
@@ -16,6 +17,12 @@ export const profile = createCommand({
await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user;
if (targetUser.bot) {
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have profiles.", "Profile Check")] });
return;
}
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const cardBuffer = await generateStudentIdCard({

33
src/config/config.json Normal file
View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -1,10 +1,11 @@
import { Events } from "discord.js";
import type { Event } from "@lib/types";
// Visitor role
const event: Event<Events.GuildMemberAdd> = {
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);
},

View File

@@ -1,5 +1,5 @@
import { Events, MessageFlags } from "discord.js";
import { KyokoClient } from "@lib/KyokoClient";
import { KyokoClient } from "@/lib/BotClient";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import type { Event } from "@lib/types";
@@ -13,6 +13,10 @@ const event: Event<Events.InteractionCreate> = {
await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction));
return;
}
if (interaction.customId.startsWith("shop_buy_") && interaction.isButton()) {
await import("@/modules/economy/shop.interaction").then(m => m.handleShopInteraction(interaction));
return;
}
}
if (!interaction.isChatInputCommand()) return;

View File

@@ -8,6 +8,29 @@ const event: Event<Events.ClientReady> = {
execute: async (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`);
schedulerService.start();
// Check for restart context
const { readFile, unlink } = await import("fs/promises");
const { createSuccessEmbed } = await import("@lib/embeds");
try {
const contextData = await readFile(".restart_context.json", "utf-8");
const context = JSON.parse(contextData);
// Validate context freshness (e.g., ignore if older than 5 minutes)
if (Date.now() - context.timestamp < 5 * 60 * 1000) {
const channel = await c.channels.fetch(context.channelId);
if (channel && channel.isSendable()) {
await channel.send({
embeds: [createSuccessEmbed("Bot is back online! Redeploy successful.", "System Online")]
});
}
}
await unlink(".restart_context.json");
} catch (error) {
// Ignore errors (file not found, etc.)
}
},
};

View File

@@ -1,4 +1,4 @@
import { KyokoClient } from "@lib/KyokoClient";
import { KyokoClient } from "@/lib/BotClient";
import { env } from "@lib/env";
// Load commands & events

View File

@@ -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 {

71
src/lib/config.ts Normal file
View File

@@ -0,0 +1,71 @@
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
const configPath = join(process.cwd(), 'src', 'config', 'config.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<string, boolean>;
}
// 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;

20
src/lib/configManager.ts Normal file
View File

@@ -0,0 +1,20 @@
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { KyokoClient } from '@/lib/BotClient';
const configPath = join(process.cwd(), 'src', 'config', 'config.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));
}
};

15
src/lib/db.ts Normal file
View File

@@ -0,0 +1,15 @@
import { DrizzleClient } from "./DrizzleClient";
import type { Transaction } from "./types";
export const withTransaction = async <T>(
callback: (tx: Transaction) => Promise<T>,
tx?: Transaction
): Promise<T> => {
if (tx) {
return await callback(tx);
} else {
return await DrizzleClient.transaction(async (newTx) => {
return await callback(newTx);
});
}
};

18
src/lib/errors.ts Normal file
View File

@@ -0,0 +1,18 @@
export class ApplicationError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
export class UserError extends ApplicationError {
constructor(message: string) {
super(message);
}
}
export class SystemError extends ApplicationError {
constructor(message: string) {
super(message);
}
}

View File

@@ -3,6 +3,7 @@ import type { ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, Sl
export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
category?: string;
}
export interface Event<K extends keyof ClientEvents> {
@@ -10,3 +11,20 @@ export interface Event<K extends keyof ClientEvents> {
once?: boolean;
execute: (...args: ClientEvents[K]) => Promise<void> | void;
}
export type ItemEffect =
| { type: 'ADD_XP'; amount: number }
| { type: 'ADD_BALANCE'; amount: number }
| { type: 'XP_BOOST'; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'TEMP_ROLE'; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'REPLY_MESSAGE'; message: string };
export interface ItemUsageData {
consume: boolean;
effects: ItemEffect[];
}
import { DrizzleClient } from "./DrizzleClient";
export type DbClient = typeof DrizzleClient;
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];

View File

@@ -1,30 +1,32 @@
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";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types";
import { UserError } from "@/lib/errors";
export const economyService = {
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: any) => {
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
if (amount <= 0n) {
throw new Error("Amount must be positive");
throw new UserError("Amount must be positive");
}
if (fromUserId === toUserId) {
throw new Error("Cannot transfer to self");
throw new UserError("Cannot transfer to self");
}
const execute = async (txFn: any) => {
return await withTransaction(async (txFn) => {
// Check sender balance
const sender = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(fromUserId)),
});
if (!sender) {
throw new Error("Sender not found");
throw new UserError("Sender not found");
}
if ((sender.balance ?? 0n) < amount) {
throw new Error("Insufficient funds");
throw new UserError("Insufficient funds");
}
// Deduct from sender
@@ -59,19 +61,11 @@ export const economyService = {
});
return { success: true, amount };
};
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
});
}
}, tx);
},
claimDaily: async (userId: string, tx?: any) => {
const execute = async (txFn: any) => {
claimDaily: async (userId: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const now = new Date();
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
@@ -86,7 +80,7 @@ export const economyService = {
});
if (cooldown && cooldown.expiresAt > now) {
throw new Error(`Daily already claimed. Ready at ${cooldown.expiresAt}`);
throw new UserError(`Daily already claimed. Ready at ${cooldown.expiresAt}`);
}
// Get user for streak logic
@@ -95,7 +89,7 @@ export const economyService = {
});
if (!user) {
throw new Error("User not found");
throw new Error("User not found"); // This might be system error because user should exist if authenticated, but keeping simple for now
}
let streak = (user.dailyStreak || 0) + 1;
@@ -110,9 +104,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 +116,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({
@@ -145,26 +139,18 @@ export const economyService = {
});
return { claimed: true, amount: totalReward, streak, nextReadyAt };
};
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
});
}
}, tx);
},
modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: any) => {
const execute = async (txFn: any) => {
modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
if (amount < 0n) {
// Check sufficient funds if removing
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id))
});
if (!user || (user.balance ?? 0n) < -amount) {
throw new Error("Insufficient funds");
throw new UserError("Insufficient funds");
}
}
@@ -184,14 +170,6 @@ export const economyService = {
});
return user;
};
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
});
}
}, tx);
},
};

View File

@@ -0,0 +1,40 @@
import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed, createWarningEmbed } from "@/lib/embeds";
export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return;
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
if (isNaN(itemId)) {
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Item ID.")] });
return;
}
const item = await inventoryService.getItem(itemId);
if (!item || !item.price) {
await interaction.editReply({ embeds: [createErrorEmbed("Item not found or not for sale.")] });
return;
}
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
// Double check balance here too, although service handles it, we want a nice message
if ((user.balance ?? 0n) < item.price) {
await interaction.editReply({ embeds: [createWarningEmbed(`You need ${item.price} 🪙 to buy this item. You have ${user.balance} 🪙.`)] });
return;
}
const result = await inventoryService.buyItem(user.id, item.id, 1n);
await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` });
} catch (error: any) {
console.error("Shop Purchase Error:", error);
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An error occurred while processing your purchase.")] });
}
}

View File

@@ -1,13 +1,22 @@
import { inventory, items, users } from "@/db/schema";
import { inventory, items, users, userTimers } 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 { levelingService } from "@/modules/leveling/leveling.service";
import { config } from "@/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction, ItemUsageData } from "@/lib/types";
// Helper to extract duration in seconds
const getDuration = (effect: any): number => {
if (effect.durationHours) return effect.durationHours * 3600;
if (effect.durationMinutes) return effect.durationMinutes * 60;
return effect.durationSeconds || 60; // Default to 60s if nothing provided
};
export const inventoryService = {
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => {
const execute = async (txFn: any) => {
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// Check if item exists in inventory
const existing = await txFn.query.inventory.findFirst({
where: and(
@@ -18,8 +27,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 +48,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 && 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)
@@ -56,12 +65,11 @@ export const inventoryService = {
.returning();
return entry;
}
};
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, tx);
},
removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => {
const execute = async (txFn: any) => {
removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const existing = await txFn.query.inventory.findFirst({
where: and(
eq(inventory.userId, BigInt(userId)),
@@ -93,8 +101,7 @@ export const inventoryService = {
.returning();
return entry;
}
};
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, tx);
},
getInventory: async (userId: string) => {
@@ -106,8 +113,8 @@ export const inventoryService = {
});
},
buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => {
const execute = async (txFn: any) => {
buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const item = await txFn.query.items.findFirst({
where: eq(items.id, itemId),
});
@@ -123,9 +130,7 @@ export const inventoryService = {
await inventoryService.addItem(userId, itemId, quantity, txFn);
return { success: true, item, totalPrice };
};
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, tx);
},
getItem: async (itemId: number) => {
@@ -133,4 +138,85 @@ export const inventoryService = {
where: eq(items.id, itemId),
});
},
useItem: async (userId: string, itemId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// 1. Check Ownership & Quantity
const entry = await txFn.query.inventory.findFirst({
where: and(
eq(inventory.userId, BigInt(userId)),
eq(inventory.itemId, itemId)
),
with: { item: true }
});
if (!entry || (entry.quantity ?? 0n) < 1n) {
throw new Error("You do not own this item.");
}
const item = entry.item;
const usageData = item.usageData as ItemUsageData | null;
if (!usageData || !usageData.effects || usageData.effects.length === 0) {
throw new Error("This item cannot be used.");
}
const results: string[] = [];
// 2. Apply Effects
for (const effect of usageData.effects) {
switch (effect.type) {
case 'ADD_XP':
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
results.push(`Gained ${effect.amount} XP`);
break;
case 'ADD_BALANCE':
await economyService.modifyUserBalance(userId, BigInt(effect.amount), 'ITEM_USE', `Used ${item.name}`, null, txFn);
results.push(`Gained ${effect.amount} 🪙`);
break;
case 'REPLY_MESSAGE':
results.push(effect.message);
break;
case 'XP_BOOST':
const boostDuration = getDuration(effect);
const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: 'EFFECT',
key: 'xp_boost',
expiresAt: expiresAt,
metadata: { multiplier: effect.multiplier }
}).onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: expiresAt, metadata: { multiplier: effect.multiplier } }
});
results.push(`XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`);
break;
case 'TEMP_ROLE':
const roleDuration = getDuration(effect);
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: 'ACCESS',
key: `role_${effect.roleId}`,
expiresAt: roleExpiresAt,
metadata: { roleId: effect.roleId }
}).onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: roleExpiresAt }
});
// Actual role assignment happens in the Command layer
results.push(`Temporary Role granted for ${Math.floor(roleDuration / 60)}m`);
break;
}
}
// 3. Consume
if (usageData.consume) {
await inventoryService.removeItem(userId, itemId, 1n, txFn);
}
return { success: true, results, usageData };
}, tx);
}
};

View File

@@ -1,17 +1,18 @@
import { users, userTimers } from "@/db/schema";
import { eq, sql, and } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { GameConfig } from "@/config/game";
import { withTransaction } from "@/lib/db";
import { config } from "@/lib/config";
import type { Transaction } from "@/lib/types";
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
addXp: async (id: string, amount: bigint, tx?: any) => {
const execute = async (txFn: any) => {
addXp: async (id: string, amount: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// Get current state
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id)),
@@ -43,20 +44,12 @@ export const levelingService = {
.returning();
return { user: updatedUser, levelUp, currentLevel };
};
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
})
}
}, tx);
},
// Handle chat XP with cooldowns
processChatXp: async (id: string, tx?: any) => {
const execute = async (txFn: any) => {
processChatXp: async (id: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// check if an xp cooldown is in place
const cooldown = await txFn.query.userTimers.findFirst({
where: and(
@@ -72,13 +65,27 @@ 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);
let amount = BigInt(Math.floor(Math.random() * (config.leveling.chat.maxXp - config.leveling.chat.minXp + 1)) + config.leveling.chat.minXp);
// Check for XP Boost
const xpBoost = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, 'EFFECT'),
eq(userTimers.key, 'xp_boost')
)
});
if (xpBoost && xpBoost.expiresAt > now) {
const multiplier = (xpBoost.metadata as any)?.multiplier || 1;
amount = BigInt(Math.floor(Number(amount) * multiplier));
}
// 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({
@@ -93,14 +100,6 @@ export const levelingService = {
});
return { awarded: true, amount, ...result };
};
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
})
}
}, tx);
}
};

View File

@@ -4,10 +4,12 @@ import { eq, and, sql } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { economyService } from "@/modules/economy/economy.service";
import { levelingService } from "@/modules/leveling/leveling.service";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types";
export const questService = {
assignQuest: async (userId: string, questId: number, tx?: any) => {
const execute = async (txFn: any) => {
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
return await txFn.insert(userQuests)
.values({
userId: BigInt(userId),
@@ -16,12 +18,11 @@ export const questService = {
})
.onConflictDoNothing() // Ignore if already assigned
.returning();
};
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, tx);
},
updateProgress: async (userId: string, questId: number, progress: number, tx?: any) => {
const execute = async (txFn: any) => {
updateProgress: async (userId: string, questId: number, progress: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
return await txFn.update(userQuests)
.set({ progress: progress })
.where(and(
@@ -29,12 +30,11 @@ export const questService = {
eq(userQuests.questId, questId)
))
.returning();
};
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, tx);
},
completeQuest: async (userId: string, questId: number, tx?: any) => {
const execute = async (txFn: any) => {
completeQuest: async (userId: string, questId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const userQuest = await txFn.query.userQuests.findFirst({
where: and(
eq(userQuests.userId, BigInt(userId)),
@@ -73,9 +73,7 @@ export const questService = {
}
return { success: true, rewards: results };
};
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, tx);
},
getUserQuests: async (userId: string) => {

View File

@@ -1,6 +1,8 @@
import { userTimers } from "@/db/schema";
import { eq, and, lt } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { KyokoClient } from "@/lib/BotClient";
import { env } from "@/lib/env";
/**
* The Janitor responsible for cleaning up expired ACCESS timers
@@ -38,9 +40,30 @@ export const schedulerService = {
console.log(`🧹 Janitor: Found ${expiredAccess.length} expired access timers.`);
for (const timer of expiredAccess) {
// TODO: Here we would call Discord API to remove roles/overwrites.
const meta = timer.metadata as any;
console.log(`🚫 Revoking access for User ${timer.userId}: Key=${timer.key} (Channel: ${meta?.channelId || 'N/A'})`);
const userIdStr = timer.userId.toString();
// Specific Handling for Roles
if (timer.key.startsWith('role_')) {
try {
const roleId = meta?.roleId || timer.key.replace('role_', '');
const guildId = env.DISCORD_GUILD_ID;
if (guildId) {
// We try to fetch, if bot is not in guild or lacks perms, it will catch
const guild = await KyokoClient.guilds.fetch(guildId);
const member = await guild.members.fetch(userIdStr);
await member.roles.remove(roleId);
console.log(`👋 Removed temporary role ${roleId} from ${member.user.tag}`);
}
} catch (err) {
console.error(`Failed to remove role for user ${userIdStr}:`, err);
// We still delete the timer so we don't loop forever on a left user
}
} else {
console.log(`🚫 Revoking access for User ${timer.userId}: Key=${timer.key} (Channel: ${meta?.channelId || 'N/A'})`);
// TODO: Generic channel permission removal if needed
}
// Delete the timer row
await DrizzleClient.delete(userTimers)

View File

@@ -3,6 +3,7 @@ import { DrizzleClient } from "@/lib/DrizzleClient";
import { economyService } from "@/modules/economy/economy.service";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { itemTransactions } from "@/db/schema";
import type { Transaction } from "@/lib/types";
export class TradeService {
private static sessions = new Map<string, TradeSession>();
@@ -136,7 +137,7 @@ export class TradeService {
this.endSession(threadId);
}
private static async processTransfer(tx: any, from: TradeParticipant, to: TradeParticipant, threadId: string) {
private static async processTransfer(tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) {
// 1. Money
if (from.offer.money > 0n) {
await economyService.modifyUserBalance(