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

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 {