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

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

View File

@@ -0,0 +1,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)