forked from syntaxbullet/AuroraBot-discord
feat: add /use command for inventory items with effects, implement XP boosts, and enhance scheduler for temporary role removal.
This commit is contained in:
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)] });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,18 @@ export interface Event<K extends keyof ClientEvents> {
|
||||
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 }
|
||||
| { 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user