From 599684cde8542c717a473f712a130909fbc53dfd Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 5 Jan 2026 12:52:34 +0100 Subject: [PATCH] feat: Add lootbox item type with weighted rewards and dedicated UI for item usage results. --- src/commands/inventory/use.ts | 2 +- src/lib/types.ts | 13 +++- src/modules/inventory/effects/handlers.ts | 74 ++++++++++++++++++++++ src/modules/inventory/effects/registry.ts | 6 +- src/modules/inventory/inventory.service.ts | 3 +- src/modules/inventory/inventory.view.ts | 21 ++++-- 6 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/commands/inventory/use.ts b/src/commands/inventory/use.ts index e17567e..59f3d80 100644 --- a/src/commands/inventory/use.ts +++ b/src/commands/inventory/use.ts @@ -58,7 +58,7 @@ export const use = createCommand({ } } - const embed = getItemUseResultEmbed(result.results); + const embed = getItemUseResultEmbed(result.results, result.item); await interaction.editReply({ embeds: [embed] }); diff --git a/src/lib/types.ts b/src/lib/types.ts index b9400f7..0b6d748 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,7 +19,18 @@ export type ItemEffect = | { 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 } - | { type: 'COLOR_ROLE'; roleId: string }; + | { type: 'COLOR_ROLE'; roleId: string } + | { type: 'LOOTBOX'; pool: LootTableItem[] }; + +export interface LootTableItem { + type: 'CURRENCY' | 'ITEM' | 'XP' | 'NOTHING'; + weight: number; + amount?: number; // For CURRENCY, XP + itemId?: number; // For ITEM + minAmount?: number; // Optional range for CURRENCY/XP + maxAmount?: number; // Optional range for CURRENCY/XP + message?: string; // Optional custom message for this outcome +} export interface ItemUsageData { consume: boolean; diff --git a/src/modules/inventory/effects/handlers.ts b/src/modules/inventory/effects/handlers.ts index 372a59f..846d378 100644 --- a/src/modules/inventory/effects/handlers.ts +++ b/src/modules/inventory/effects/handlers.ts @@ -2,6 +2,10 @@ import { levelingService } from "@/modules/leveling/leveling.service"; import { economyService } from "@/modules/economy/economy.service"; import { userTimers } from "@/db/schema"; import type { EffectHandler } from "./types"; +import type { LootTableItem } from "@/lib/types"; +import { inventoryService } from "@/modules/inventory/inventory.service"; +import { inventory, items } from "@/db/schema"; + // Helper to extract duration in seconds const getDuration = (effect: any): number => { @@ -60,3 +64,73 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => { export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => { return "Color Role Equipped"; }; + +export const handleLootbox: EffectHandler = async (userId, effect, txFn) => { + const pool = effect.pool as LootTableItem[]; + if (!pool || pool.length === 0) return "The box is empty..."; + + const totalWeight = pool.reduce((sum, item) => sum + item.weight, 0); + let random = Math.random() * totalWeight; + + let winner: LootTableItem | null = null; + for (const item of pool) { + if (random < item.weight) { + winner = item; + break; + } + random -= item.weight; + } + + if (!winner) return "The box is empty..."; // Should not happen + + // Process Winner + if (winner.type === 'NOTHING') { + return winner.message || "You found nothing inside."; + } + + if (winner.type === 'CURRENCY') { + let amount = winner.amount || 0; + if (winner.minAmount && winner.maxAmount) { + amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount; + } + if (amount > 0) { + await economyService.modifyUserBalance(userId, BigInt(amount), 'LOOTBOX', 'Lootbox Reward', null, txFn); + return winner.message || `You found ${amount} 🪙!`; + } + } + + if (winner.type === 'XP') { + let amount = winner.amount || 0; + if (winner.minAmount && winner.maxAmount) { + amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount; + } + if (amount > 0) { + await levelingService.addXp(userId, BigInt(amount), txFn); + return winner.message || `You gained ${amount} XP!`; + } + } + + if (winner.type === 'ITEM') { + if (winner.itemId) { + const quantity = BigInt(winner.amount || 1); + + await inventoryService.addItem(userId, winner.itemId, quantity, txFn); + + // Try to fetch item name for the message + try { + const item = await txFn.query.items.findFirst({ + where: (items, { eq }) => eq(items.id, winner.itemId!) + }); + if (item) { + return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`; + } + } catch (e) { + console.error("Failed to fetch item name for lootbox message", e); + } + + return winner.message || `You found an item! (ID: ${winner.itemId})`; + } + } + + return "You found nothing suitable inside."; +}; diff --git a/src/modules/inventory/effects/registry.ts b/src/modules/inventory/effects/registry.ts index fcdde94..31165a5 100644 --- a/src/modules/inventory/effects/registry.ts +++ b/src/modules/inventory/effects/registry.ts @@ -4,7 +4,8 @@ import { handleReplyMessage, handleXpBoost, handleTempRole, - handleColorRole + handleColorRole, + handleLootbox } from "./handlers"; import type { EffectHandler } from "./types"; @@ -14,5 +15,6 @@ export const effectHandlers: Record = { 'REPLY_MESSAGE': handleReplyMessage, 'XP_BOOST': handleXpBoost, 'TEMP_ROLE': handleTempRole, - 'COLOR_ROLE': handleColorRole + 'COLOR_ROLE': handleColorRole, + 'LOOTBOX': handleLootbox }; diff --git a/src/modules/inventory/inventory.service.ts b/src/modules/inventory/inventory.service.ts index 7e0cf07..9f4ad26 100644 --- a/src/modules/inventory/inventory.service.ts +++ b/src/modules/inventory/inventory.service.ts @@ -159,7 +159,6 @@ export const inventoryService = { const results: string[] = []; - // 2. Apply Effects // 2. Apply Effects const { effectHandlers } = await import("./effects/registry"); @@ -179,7 +178,7 @@ export const inventoryService = { await inventoryService.removeItem(userId, itemId, 1n, txFn); } - return { success: true, results, usageData }; + return { success: true, results, usageData, item }; }, tx); } }; diff --git a/src/modules/inventory/inventory.view.ts b/src/modules/inventory/inventory.view.ts index f796de0..5569b20 100644 --- a/src/modules/inventory/inventory.view.ts +++ b/src/modules/inventory/inventory.view.ts @@ -30,11 +30,24 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em /** * Creates an embed showing the results of using an item */ -export function getItemUseResultEmbed(results: string[], itemName?: string): EmbedBuilder { +export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder { const description = results.map(r => `• ${r}`).join("\n"); - return new EmbedBuilder() - .setTitle("✅ Item Used!") + // Check if it was a lootbox + const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === 'LOOTBOX'); + + const embed = new EmbedBuilder() .setDescription(description) - .setColor(0x2ecc71); // Green/Success + .setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise + + if (isLootbox && item) { + embed.setTitle(`🎁 ${item.name} Opened!`); + if (item.iconUrl) { + embed.setThumbnail(item.iconUrl); + } + } else { + embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!"); + } + + return embed; }