feat: add /use command for inventory items with effects, implement XP boosts, and enhance scheduler for temporary role removal.

This commit is contained in:
syntaxbullet
2025-12-15 23:22:51 +01:00
parent 1d4263e178
commit d3ade218ec
6 changed files with 230 additions and 140 deletions

View File

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

View File

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

View File

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

View File

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

View File

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