forked from syntaxbullet/AuroraBot-discord
Compare commits
11 Commits
1eace32aa1
...
3a96b67e89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a96b67e89 | ||
|
|
d3ade218ec | ||
|
|
1d4263e178 | ||
|
|
727b63b4dc | ||
|
|
d2edde77e6 | ||
|
|
3acb5304f5 | ||
|
|
9333d6ac6c | ||
|
|
7e986fae5a | ||
|
|
3c81fd8396 | ||
|
|
3984d6112b | ||
|
|
ac6283e60c |
@@ -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,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: .
|
||||
|
||||
@@ -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();
|
||||
96
src/commands/admin/features.ts
Normal file
96
src/commands/admin/features.ts
Normal 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!` });
|
||||
}
|
||||
}
|
||||
});
|
||||
77
src/commands/admin/listing.ts
Normal file
77
src/commands/admin/listing.ts
Normal 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.")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
82
src/commands/admin/reload.ts
Normal file
82
src/commands/admin/reload.ts
Normal 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")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
95
src/commands/inventory/use.ts
Normal file
95
src/commands/inventory/use.ts
Normal 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)] });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
33
src/config/config.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KyokoClient } from "@lib/KyokoClient";
|
||||
import { KyokoClient } from "@/lib/BotClient";
|
||||
import { env } from "@lib/env";
|
||||
|
||||
// Load commands & events
|
||||
|
||||
@@ -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
71
src/lib/config.ts
Normal 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
20
src/lib/configManager.ts
Normal 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
15
src/lib/db.ts
Normal 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
18
src/lib/errors.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
40
src/modules/economy/shop.interaction.ts
Normal file
40
src/modules/economy/shop.interaction.ts
Normal 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.")] });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user