feat: Implement structured lootbox results with image support and display referenced items in shop listings.
All checks were successful
Deploy to Production / test (push) Successful in 42s

This commit is contained in:
syntaxbullet
2026-02-08 16:07:13 +01:00
parent ee088ad84b
commit 5e8683a19f
9 changed files with 359 additions and 64 deletions

3
.gitignore vendored
View File

@@ -46,4 +46,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data
src/db/log
scratchpad/
tickets/
tickets/
bot/assets/graphics/items

View File

@@ -1,20 +1,18 @@
import { createCommand } from "@shared/lib/utils";
import {
SlashCommandBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type BaseGuildTextChannel,
PermissionFlagsBits,
MessageFlags
} from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { items } from "@db/schema";
import { ilike, isNotNull, and } from "drizzle-orm";
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { getShopListingMessage } from "@/modules/economy/shop.view";
import { EffectType, LootType } from "@shared/lib/constants";
export const listing = createCommand({
data: new SlashCommandBuilder()
@@ -54,14 +52,42 @@ export const listing = createCommand({
return;
}
// Prepare context for lootboxes
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
const usageData = item.usageData as any;
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
if (lootboxEffect && lootboxEffect.pool) {
const itemIds = lootboxEffect.pool
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
.map((drop: any) => drop.itemId);
if (itemIds.length > 0) {
// Remove duplicates
const uniqueIds = [...new Set(itemIds)] as number[];
const referencedItems = await DrizzleClient.select({
id: items.id,
name: items.name,
rarity: items.rarity
}).from(items).where(inArray(items.id, uniqueIds));
for (const ref of referencedItems) {
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
}
}
}
const listingMessage = getShopListingMessage({
...item,
rarity: item.rarity || undefined,
formattedPrice: `${item.price} 🪙`,
price: item.price
});
}, context);
try {
await targetChannel.send(listingMessage);
await targetChannel.send(listingMessage as any);
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
} catch (error: any) {
if (error instanceof UserError) {

View File

@@ -55,9 +55,9 @@ export const use = createCommand({
}
}
const embed = getItemUseResultEmbed(result.results, result.item);
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed] });
await interaction.editReply({ embeds: [embed], files });
} catch (error: any) {
if (error instanceof UserError) {

View File

@@ -1,10 +1,60 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder } from "discord.js";
import { createBaseEmbed } from "@/lib/embeds";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
Colors,
ContainerBuilder,
SectionBuilder,
TextDisplayBuilder,
MediaGalleryBuilder,
MediaGalleryItemBuilder,
ThumbnailBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
MessageFlags
} from "discord.js";
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
import { join } from "path";
import { existsSync } from "fs";
import { LootType, EffectType } from "@shared/lib/constants";
import type { LootTableItem } from "@shared/lib/types";
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
// Rarity Color Map
const RarityColors: Record<string, number> = {
"C": Colors.LightGrey,
"R": Colors.Blue,
"SR": Colors.Purple,
"SSR": Colors.Gold,
"CURRENCY": Colors.Green,
"XP": Colors.Aqua,
"NOTHING": Colors.DarkButNotBlack
};
const TitleMap: Record<string, string> = {
"C": "📦 Common Items",
"R": "📦 Rare Items",
"SR": "✨ Super Rare Items",
"SSR": "🌟 SSR Items",
"CURRENCY": "💰 Currency",
"XP": "🔮 Experience",
"NOTHING": "💨 Empty"
};
export function getShopListingMessage(
item: {
id: number;
name: string;
description: string | null;
formattedPrice: string;
iconUrl: string | null;
imageUrl: string | null;
price: number | bigint;
usageData?: any;
rarity?: string;
},
context?: { referencedItems: Map<number, { name: string; rarity: string }> }
) {
const files: AttachmentBuilder[] = [];
let thumbnailUrl = resolveAssetUrl(item.iconUrl);
let displayImageUrl = resolveAssetUrl(item.imageUrl);
@@ -19,16 +69,14 @@ export function getShopListingMessage(item: { id: number; name: string; descript
}
}
// Handle local image (avoid duplicate attachments if same as icon)
// Handle local image
if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) {
// If image is same as icon, just use the same attachment reference
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
displayImageUrl = thumbnailUrl;
} else {
const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, ""));
if (existsSync(imagePath)) {
const imageName = defaultName(item.imageUrl);
// Check if we already attached this file (by name)
if (!files.find(f => f.name === imageName)) {
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
}
@@ -37,21 +85,122 @@ export function getShopListingMessage(item: { id: number; name: string; descript
}
}
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
.setThumbnail(thumbnailUrl)
.setImage(displayImageUrl)
.setFooter({ text: "Click the button below to purchase instantly." });
const containers: ContainerBuilder[] = [];
// 1. Main Container
const mainContainer = new ContainerBuilder()
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
// Header Section
const infoSection = new SectionBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`# ${item.name}`),
new TextDisplayBuilder().setContent(item.description || "_No description available._"),
new TextDisplayBuilder().setContent(`### 🏷️ Price: ${item.formattedPrice}`)
);
// Set Thumbnail Accessory if we have an icon
if (thumbnailUrl) {
infoSection.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
}
mainContainer.addSectionComponents(infoSection);
// Media Gallery for additional images (if multiple)
const mediaSources: string[] = [];
if (thumbnailUrl) mediaSources.push(thumbnailUrl);
if (displayImageUrl && displayImageUrl !== thumbnailUrl) mediaSources.push(displayImageUrl);
if (mediaSources.length > 1) {
mainContainer.addMediaGalleryComponents(
new MediaGalleryBuilder().addItems(
...mediaSources.map(src => new MediaGalleryItemBuilder().setURL(src))
)
);
}
// 2. Loot Table (if applicable)
if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) {
const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX);
const pool = lootboxEffect.pool as LootTableItem[];
const totalWeight = pool.reduce((sum, i) => sum + i.weight, 0);
mainContainer.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
mainContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent("## 🎁 Potential Rewards"));
const groups: Record<string, string[]> = {};
for (const drop of pool) {
const chance = ((drop.weight / totalWeight) * 100).toFixed(1);
let line = "";
let rarity = "C";
switch (drop.type as any) {
case LootType.CURRENCY:
const currAmount = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} - ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
line = `**${currAmount} 🪙** (${chance}%)`;
rarity = "CURRENCY";
break;
case LootType.XP:
const xpAmount = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} - ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
line = `**${xpAmount} XP** (${chance}%)`;
rarity = "XP";
break;
case LootType.ITEM:
const referencedItems = context?.referencedItems;
if (drop.itemId && referencedItems?.has(drop.itemId)) {
const i = referencedItems.get(drop.itemId)!;
line = `**${i.name}** x${drop.amount || 1} (${chance}%)`;
rarity = i.rarity;
} else {
line = `**Unknown Item** (${chance}%)`;
rarity = "C";
}
break;
case LootType.NOTHING:
line = `**Nothing** (${chance}%)`;
rarity = "NOTHING";
break;
}
if (line) {
if (!groups[rarity]) groups[rarity] = [];
groups[rarity]!.push(line);
}
}
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
for (const rarity of order) {
if (groups[rarity] && groups[rarity]!.length > 0) {
mainContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`### ${TitleMap[rarity] || rarity}`),
new TextDisplayBuilder().setContent(groups[rarity]!.join("\n"))
);
}
}
}
// Purchase Row
const buyButton = new ButtonBuilder()
.setCustomId(`shop_buy_${item.id}`)
.setLabel(`Buy for ${item.price} 🪙`)
.setLabel(`Purchase for ${item.price} 🪙`)
.setStyle(ButtonStyle.Success)
.setEmoji("🛒");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
mainContainer.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
);
return { embeds: [embed], components: [row], files };
containers.push(mainContainer);
return {
components: containers as any,
files,
flags: MessageFlags.IsComponentsV2
};
}
function defaultName(path: string): string {

View File

@@ -86,7 +86,11 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
// Process Winner
if (winner.type === LootType.NOTHING) {
return winner.message || "You found nothing inside.";
return {
type: 'LOOTBOX_RESULT',
rewardType: 'NOTHING',
message: winner.message || "You found nothing inside."
};
}
if (winner.type === LootType.CURRENCY) {
@@ -96,7 +100,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
}
if (amount > 0) {
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
return winner.message || `You found ${amount} 🪙!`;
return {
type: 'LOOTBOX_RESULT',
rewardType: 'CURRENCY',
amount: amount,
message: winner.message || `You found ${amount} 🪙!`
};
}
}
@@ -107,7 +116,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
}
if (amount > 0) {
await levelingService.addXp(userId, BigInt(amount), txFn);
return winner.message || `You gained ${amount} XP!`;
return {
type: 'LOOTBOX_RESULT',
rewardType: 'XP',
amount: amount,
message: winner.message || `You gained ${amount} XP!`
};
}
}
@@ -123,7 +137,18 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
});
if (item) {
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
return {
type: 'LOOTBOX_RESULT',
rewardType: 'ITEM',
amount: Number(quantity),
item: {
name: item.name,
rarity: item.rarity,
description: item.description,
image: item.imageUrl || item.iconUrl
},
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
};
}
} catch (e) {
console.error("Failed to fetch item name for lootbox message", e);

View File

@@ -1,4 +1,4 @@
import type { Transaction } from "@shared/lib/types";
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;

View File

@@ -1,7 +1,9 @@
import { EmbedBuilder } from "discord.js";
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
import type { ItemUsageData } from "@shared/lib/types";
import { EffectType } from "@shared/lib/constants";
import { resolveAssetUrl } from "@shared/lib/assets";
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
import { join } from "path";
import { existsSync } from "fs";
/**
* Inventory entry with item details
@@ -32,25 +34,107 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
/**
* Creates an embed showing the results of using an item
*/
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
const description = results.map(r => `${r}`).join("\n");
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
const embed = new EmbedBuilder();
const files: AttachmentBuilder[] = [];
const otherMessages: string[] = [];
let lootResult: any = null;
// Check if it was a lootbox
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
const embed = new EmbedBuilder()
.setDescription(description)
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
if (isLootbox && item) {
embed.setTitle(`🎁 ${item.name} Opened!`);
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
if (resolvedIconUrl) {
embed.setThumbnail(resolvedIconUrl);
for (const res of results) {
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
lootResult = res;
} else {
otherMessages.push(typeof res === 'string' ? `${res}` : `${JSON.stringify(res)}`);
}
} else {
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
}
return embed;
// Default Configuration
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default
embed.setTimestamp();
if (lootResult) {
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`);
if (lootResult.rewardType === 'ITEM' && lootResult.item) {
const i = lootResult.item;
const amountStr = lootResult.amount > 1 ? `x${lootResult.amount}` : '';
// Rarity Colors
const rarityColors: Record<string, number> = {
'C': 0x95A5A6, // Gray
'R': 0x3498DB, // Blue
'SR': 0x9B59B6, // Purple
'SSR': 0xF1C40F // Gold
};
const rarityKey = i.rarity || 'C';
if (rarityKey in rarityColors) {
embed.setColor(rarityColors[rarityKey] ?? 0x95A5A6);
} else {
embed.setColor(0x95A5A6);
}
if (i.image) {
if (isLocalAssetUrl(i.image)) {
const imagePath = join(process.cwd(), "bot/assets/graphics", i.image.replace(/^\/?assets\//, ""));
if (existsSync(imagePath)) {
const imageName = defaultName(i.image);
if (!files.find(f => f.name === imageName)) {
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
}
embed.setImage(`attachment://${imageName}`);
}
} else {
const imgUrl = resolveAssetUrl(i.image);
if (imgUrl) embed.setImage(imgUrl);
}
}
embed.setDescription(`**You found ${i.name} ${amountStr}!**\n${i.description || '_'}`);
embed.addFields({ name: 'Rarity', value: rarityKey, inline: true });
} else if (lootResult.rewardType === 'CURRENCY') {
embed.setColor(0xF1C40F);
embed.setDescription(`**You found ${lootResult.amount.toLocaleString()} 🪙 AU!**`);
} else if (lootResult.rewardType === 'XP') {
embed.setColor(0x2ECC71); // Green
embed.setDescription(`**You gained ${lootResult.amount.toLocaleString()} XP!**`);
} else {
// Nothing or Message
embed.setDescription(lootResult.message);
embed.setColor(0x95A5A6); // Gray
}
} else {
// Standard item usage
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
if (isLootbox && item && item.iconUrl) {
if (isLocalAssetUrl(item.iconUrl)) {
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
if (existsSync(iconPath)) {
const iconName = defaultName(item.iconUrl);
if (!files.find(f => f.name === iconName)) {
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
}
embed.setThumbnail(`attachment://${iconName}`);
}
} else {
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
}
}
}
if (otherMessages.length > 0 && lootResult) {
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
}
return { embed, files };
}
function defaultName(path: string): string {
return path.split("/").pop() || "image.png";
}

View File

@@ -168,7 +168,7 @@ export const inventoryService = {
throw new UserError("This item cannot be used.");
}
const results: string[] = [];
const results: any[] = [];
// 2. Apply Effects
const { effectHandlers } = await import("@/modules/inventory/effects/registry");

View File

@@ -20,13 +20,15 @@ import { Plus, Trash2, Package, Coins, Sparkles, GripVertical, Percent } from "l
import { cn } from "@/lib/utils";
// Loot drop types
type LootType = "ITEM" | "BALANCE" | "XP";
type LootType = "ITEM" | "CURRENCY" | "XP";
interface LootDrop {
type: LootType;
itemId?: number;
itemName?: string;
amount?: number | [number, number]; // Single value or [min, max]
amount?: number;
minAmount?: number;
maxAmount?: number;
weight: number;
}
@@ -37,7 +39,7 @@ interface LootTableBuilderProps {
const LOOT_TYPES = [
{ value: "ITEM" as LootType, label: "Item", icon: Package, color: "text-purple-400" },
{ value: "BALANCE" as LootType, label: "Balance", icon: Coins, color: "text-amber-400" },
{ value: "CURRENCY" as LootType, label: "Currency", icon: Coins, color: "text-amber-400" },
{ value: "XP" as LootType, label: "XP", icon: Sparkles, color: "text-blue-400" },
];
@@ -45,10 +47,10 @@ const getDefaultDrop = (type: LootType): LootDrop => {
switch (type) {
case "ITEM":
return { type: "ITEM", itemId: 0, itemName: "", weight: 10 };
case "BALANCE":
return { type: "BALANCE", amount: [100, 500], weight: 30 };
case "CURRENCY":
return { type: "CURRENCY", minAmount: 100, maxAmount: 500, weight: 30 };
case "XP":
return { type: "XP", amount: [50, 200], weight: 20 };
return { type: "XP", minAmount: 50, maxAmount: 200, weight: 20 };
}
};
@@ -57,7 +59,7 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) {
const totalWeight = pool.reduce((sum, drop) => sum + drop.weight, 0);
const addDrop = useCallback(() => {
onChange([...pool, getDefaultDrop("BALANCE")]);
onChange([...pool, getDefaultDrop("CURRENCY")]);
}, [pool, onChange]);
const removeDrop = useCallback((index: number) => {
@@ -105,7 +107,7 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) {
const colors: Record<LootType, string> = {
ITEM: "bg-purple-500",
BALANCE: "bg-amber-500",
CURRENCY: "bg-amber-500",
XP: "bg-blue-500",
};
@@ -206,17 +208,21 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) {
</>
)}
{(drop.type === "BALANCE" || drop.type === "XP") && (
{(drop.type === "CURRENCY" || drop.type === "XP") && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Min Amount</Label>
<Input
type="number"
value={Array.isArray(drop.amount) ? drop.amount[0] : drop.amount || 0}
value={drop.minAmount ?? drop.amount ?? 0}
onChange={(e) => {
const min = parseInt(e.target.value) || 0;
const max = Array.isArray(drop.amount) ? drop.amount[1] : min;
updateDrop(index, { amount: [min, Math.max(min, max)] });
const currentMax = drop.maxAmount ?? drop.amount ?? min;
updateDrop(index, {
minAmount: min,
maxAmount: Math.max(min, currentMax),
amount: undefined // Clear amount
});
}}
className="bg-background/50 h-8 text-sm"
/>
@@ -225,11 +231,15 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) {
<Label className="text-xs">Max Amount</Label>
<Input
type="number"
value={Array.isArray(drop.amount) ? drop.amount[1] : drop.amount || 0}
value={drop.maxAmount ?? drop.amount ?? 0}
onChange={(e) => {
const max = parseInt(e.target.value) || 0;
const min = Array.isArray(drop.amount) ? drop.amount[0] : max;
updateDrop(index, { amount: [Math.min(min, max), max] });
const currentMin = drop.minAmount ?? drop.amount ?? max;
updateDrop(index, {
minAmount: Math.min(currentMin, max),
maxAmount: max,
amount: undefined // Clear amount
});
}}
className="bg-background/50 h-8 text-sm"
/>