feat: rewrite lootbox pull results with Components V2 and separate icon/image URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-03-18 21:51:28 +01:00
parent 0517cd638c
commit 86142cba6c
3 changed files with 139 additions and 90 deletions

View File

@@ -3,7 +3,7 @@ import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
import { withCommandErrorHandling } from "@lib/commandUtils"; import { withCommandErrorHandling } from "@lib/commandUtils";
import { getGuildConfig } from "@shared/lib/config"; import { getGuildConfig } from "@shared/lib/config";
@@ -57,9 +57,8 @@ export const use = createCommand({
} }
} }
const { embed, files } = getItemUseResultEmbed(result.results, result.item); const message = getLootboxResultMessage(result.results, result.item);
await interaction.editReply(message as any);
await interaction.editReply({ embeds: [embed], files });
} }
); );
}, },

View File

@@ -1,7 +1,18 @@
import { EmbedBuilder, AttachmentBuilder } from "discord.js"; import {
import type { ItemUsageData } from "@shared/lib/types"; EmbedBuilder,
import { EffectType } from "@shared/lib/constants"; AttachmentBuilder,
ContainerBuilder,
SectionBuilder,
TextDisplayBuilder,
MediaGalleryBuilder,
MediaGalleryItemBuilder,
ThumbnailBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
MessageFlags,
} from "discord.js";
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets"; import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
import { getRarityConfig, defaultName } from "@shared/lib/rarity";
import { join } from "path"; import { join } from "path";
import { existsSync } from "fs"; import { existsSync } from "fs";
@@ -32,109 +43,147 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
} }
/** /**
* Creates an embed showing the results of using an item * Creates a Components V2 message showing the result of opening a lootbox.
* Falls back to a simple embed for non-lootbox item usage.
*/ */
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } { export function getLootboxResultMessage(
const embed = new EmbedBuilder(); results: any[],
item?: { name: string; iconUrl: string | null; imageUrl: string | null; usageData: any }
) {
const files: AttachmentBuilder[] = []; const files: AttachmentBuilder[] = [];
const otherMessages: string[] = []; const otherMessages: string[] = [];
let lootResult: any = null; let lootResult: any = null;
for (const res of results) { for (const res of results) {
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') { if (typeof res === "object" && res.type === "LOOTBOX_RESULT") {
lootResult = res; lootResult = res;
} else { } else {
otherMessages.push(typeof res === 'string' ? `${res}` : `${JSON.stringify(res)}`); otherMessages.push(typeof res === "string" ? `${res}` : `${JSON.stringify(res)}`);
} }
} }
// Default Configuration // If no loot result, fall back to a simple embed (non-lootbox item usage)
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX); if (!lootResult) {
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default const embed = new EmbedBuilder()
embed.setTimestamp(); .setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!")
.setDescription(otherMessages.join("\n") || "Effect applied.")
if (lootResult) { .setColor(0x2ecc71)
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`); .setTimestamp();
return { embeds: [embed], files, components: undefined, flags: undefined };
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
}
// Determine rarity key for theming
let rarityKey = "C";
if (lootResult.rewardType === "ITEM" && lootResult.item) {
rarityKey = lootResult.item.rarity || "C";
} else if (lootResult.rewardType === "CURRENCY") {
rarityKey = "CURRENCY";
} else if (lootResult.rewardType === "XP") {
rarityKey = "XP";
} else { } else {
// Standard item usage rarityKey = "NOTHING";
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!"); }
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
if (isLootbox && item && item.iconUrl) { const config = getRarityConfig(rarityKey);
if (isLocalAssetUrl(item.iconUrl)) { const container = new ContainerBuilder().setAccentColor(config.color);
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
if (existsSync(iconPath)) { // Header: lootbox name
const iconName = defaultName(item.iconUrl); if (item?.name) {
if (!files.find(f => f.name === iconName)) { container.addTextDisplayComponents(
files.push(new AttachmentBuilder(iconPath, { name: iconName })); new TextDisplayBuilder().setContent(`-# Opened: ${item.name}`)
);
}
// Build title and description based on reward type
let title = "";
let description = "";
if (lootResult.rewardType === "ITEM" && lootResult.item) {
const i = lootResult.item;
const amountStr = lootResult.amount > 1 ? ` ×${lootResult.amount}` : "";
title = `${config.emoji} ${config.label}${i.name}${amountStr}`;
description = i.description || "";
if (description) description += "\n";
description += `\n**${config.label}** · ×${lootResult.amount || 1} added to inventory`;
} else if (lootResult.rewardType === "CURRENCY") {
title = `${config.emoji} You found ${lootResult.amount.toLocaleString()} AU!`;
description = "Coins have been added to your balance.";
} else if (lootResult.rewardType === "XP") {
title = `${config.emoji} You gained ${lootResult.amount.toLocaleString()} XP!`;
description = "Experience has been added to your profile.";
} else {
title = `${config.emoji} Empty...`;
description = lootResult.message || "You found nothing inside.";
}
// Main section with optional thumbnail
const section = new SectionBuilder().addTextDisplayComponents(
new TextDisplayBuilder().setContent(`# ${title}`),
new TextDisplayBuilder().setContent(description)
);
// Thumbnail from iconUrl (use reward item's icon for ITEM, lootbox icon otherwise)
let thumbnailUrl: string | null = null;
const iconSource = lootResult.rewardType === "ITEM" ? lootResult.item?.iconUrl : item?.iconUrl;
if (iconSource) {
if (isLocalAssetUrl(iconSource)) {
const iconPath = join(process.cwd(), "bot/assets/graphics", iconSource.replace(/^\/?assets\//, ""));
if (existsSync(iconPath)) {
const iconName = defaultName(iconSource);
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
thumbnailUrl = `attachment://${iconName}`;
}
} else {
thumbnailUrl = resolveAssetUrl(iconSource);
}
}
if (thumbnailUrl) {
section.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
}
container.addSectionComponents(section);
// Media gallery for full item art (if imageUrl differs from iconUrl)
if (lootResult.rewardType === "ITEM" && lootResult.item) {
const imgSource = lootResult.item.imageUrl;
const iconSrc = lootResult.item.iconUrl;
if (imgSource && imgSource !== iconSrc) {
let displayImageUrl: string | null = null;
if (isLocalAssetUrl(imgSource)) {
const imagePath = join(process.cwd(), "bot/assets/graphics", imgSource.replace(/^\/?assets\//, ""));
if (existsSync(imagePath)) {
const imageName = defaultName(imgSource);
if (!files.find(f => f.name === imageName)) {
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
} }
embed.setThumbnail(`attachment://${iconName}`); displayImageUrl = `attachment://${imageName}`;
} }
} else { } else {
const resolvedIconUrl = resolveAssetUrl(item.iconUrl); displayImageUrl = resolveAssetUrl(imgSource);
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl); }
if (displayImageUrl) {
container.addMediaGalleryComponents(
new MediaGalleryBuilder().addItems(
new MediaGalleryItemBuilder().setURL(displayImageUrl)
)
);
} }
} }
} }
if (otherMessages.length > 0 && lootResult) { // Other effects (non-lootbox results like temp roles, XP boosts)
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") }); if (otherMessages.length > 0) {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**Other Effects**\n${otherMessages.join("\n")}`)
);
} }
return { embed, files }; return {
} components: [container] as any,
files,
function defaultName(path: string): string { flags: MessageFlags.IsComponentsV2,
return path.split("/").pop() || "image.png"; embeds: undefined,
};
} }

View File

@@ -146,7 +146,8 @@ export const handleLootbox: EffectHandler = async (userId, effect: Extract<Valid
name: item.name, name: item.name,
rarity: item.rarity, rarity: item.rarity,
description: item.description, description: item.description,
image: item.imageUrl || item.iconUrl iconUrl: item.iconUrl,
imageUrl: item.imageUrl,
}, },
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!` message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
}; };