diff --git a/scripts/verify_logic.ts b/scripts/verify_logic.ts deleted file mode 100644 index efd8cf5..0000000 --- a/scripts/verify_logic.ts +++ /dev/null @@ -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(); diff --git a/src/commands/inventory/use.ts b/src/commands/inventory/use.ts new file mode 100644 index 0000000..e44be34 --- /dev/null +++ b/src/commands/inventory/use.ts @@ -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)] }); + } + } +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index c1f9e5a..731b1d8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,6 +12,18 @@ export interface Event { execute: (...args: ClientEvents[K]) => Promise | void; } +export type ItemEffect = + | { type: 'ADD_XP'; amount: number } + | { type: 'ADD_BALANCE'; amount: number } + | { type: 'XP_BOOST'; multiplier: number; durationSeconds: number } + | { type: 'TEMP_ROLE'; roleId: string; durationSeconds: number } + | { type: 'REPLY_MESSAGE'; message: string }; + +export interface ItemUsageData { + consume: boolean; + effects: ItemEffect[]; +} + import { DrizzleClient } from "./DrizzleClient"; export type DbClient = typeof DrizzleClient; diff --git a/src/modules/inventory/inventory.service.ts b/src/modules/inventory/inventory.service.ts index 8878f4f..ebba8e3 100644 --- a/src/modules/inventory/inventory.service.ts +++ b/src/modules/inventory/inventory.service.ts @@ -1,10 +1,11 @@ -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 { levelingService } from "@/modules/leveling/leveling.service"; import { config } from "@/lib/config"; import { withTransaction } from "@/lib/db"; -import type { Transaction } from "@/lib/types"; +import type { Transaction, ItemUsageData } from "@/lib/types"; export const inventoryService = { addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => { @@ -130,4 +131,84 @@ 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 expiresAt = new Date(Date.now() + effect.durationSeconds * 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(effect.durationSeconds / 60)}m`); + break; + case 'TEMP_ROLE': + const roleExpiresAt = new Date(Date.now() + effect.durationSeconds * 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 (or here if we had client, but service shouldn't depend on client ideally) + // We return a flag to let the interaction handler know it needs to assign a role. + results.push(`Temporary Role granted for ${Math.floor(effect.durationSeconds / 60)}m`); + break; + } + } + + // 3. Consume + if (usageData.consume) { + await inventoryService.removeItem(userId, itemId, 1n, txFn); + } + + return { success: true, results, usageData }; + }, tx); + } }; diff --git a/src/modules/leveling/leveling.service.ts b/src/modules/leveling/leveling.service.ts index d112bbd..de8bdea 100644 --- a/src/modules/leveling/leveling.service.ts +++ b/src/modules/leveling/leveling.service.ts @@ -65,7 +65,21 @@ export const levelingService = { } // Calculate random XP - const amount = BigInt(Math.floor(Math.random() * (config.leveling.chat.maxXp - config.leveling.chat.minXp + 1)) + config.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); diff --git a/src/modules/system/scheduler.ts b/src/modules/system/scheduler.ts index df1462c..d65e0df 100644 --- a/src/modules/system/scheduler.ts +++ b/src/modules/system/scheduler.ts @@ -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)