import { EmbedBuilder, AttachmentBuilder, ContainerBuilder, SectionBuilder, TextDisplayBuilder, MediaGalleryBuilder, MediaGalleryItemBuilder, ThumbnailBuilder, SeparatorBuilder, SeparatorSpacingSize, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, MessageFlags, } from "discord.js"; import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets"; import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity"; import { ItemType } from "@shared/lib/constants"; import type { ItemUsageData } from "@shared/lib/types"; import { join } from "path"; import { existsSync } from "fs"; export const ITEMS_PER_PAGE = 5; const RARITY_SORT_ORDER: Record = { SSR: 0, SR: 1, R: 2, C: 3, }; export interface InventoryItem { id: number; name: string; description: string | null; rarity: string | null; type: string; price: bigint | null; iconUrl: string; imageUrl: string; usageData: unknown; } export interface InventoryEntry { quantity: bigint | null; item: InventoryItem; } export function sortInventoryItems(entries: InventoryEntry[]): InventoryEntry[] { return [...entries].sort((a, b) => { const rarityA = RARITY_SORT_ORDER[a.item.rarity ?? "C"] ?? 3; const rarityB = RARITY_SORT_ORDER[b.item.rarity ?? "C"] ?? 3; if (rarityA !== rarityB) return rarityA - rarityB; return a.item.name.localeCompare(b.item.name); }); } export function getInventoryListMessage( entries: InventoryEntry[], username: string, page: number, viewerId: string, ownerId: string, ) { const sorted = sortInventoryItems(entries); const totalPages = Math.max(1, Math.ceil(sorted.length / ITEMS_PER_PAGE)); const safePage = Math.min(page, totalPages - 1); const pageItems = sorted.slice(safePage * ITEMS_PER_PAGE, (safePage + 1) * ITEMS_PER_PAGE); // Accent color from highest-rarity item on page const highestRarity = pageItems[0]?.item.rarity ?? "C"; const accentColor = getRarityConfig(highestRarity).color; const container = new ContainerBuilder() .setAccentColor(accentColor) .addTextDisplayComponents( new TextDisplayBuilder().setContent(`# 📦 ${username}'s Inventory`), new TextDisplayBuilder().setContent(`-# ${sorted.length} item${sorted.length !== 1 ? "s" : ""} total`) ); container.addSeparatorComponents( new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small) ); // Item rows const lines = pageItems.map((entry) => { const rc = getRarityConfig(entry.item.rarity ?? "C"); return `${rc.squareEmoji} **${entry.item.name}** — ${rc.label} · ${entry.item.type} · ×${entry.quantity}`; }); container.addTextDisplayComponents( new TextDisplayBuilder().setContent(lines.join("\n")) ); container.addSeparatorComponents( new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small) ); // Select menu with current page items const selectMenu = new StringSelectMenuBuilder() .setCustomId(`inv_select_${viewerId}`) .setPlaceholder("Select an item for details"); for (const entry of pageItems) { const rc = getRarityConfig(entry.item.rarity ?? "C"); selectMenu.addOptions( new StringSelectMenuOptionBuilder() .setLabel(entry.item.name) .setDescription(`${rc.label} · ${entry.item.type}`) .setValue(entry.item.id.toString()) ); } container.addActionRowComponents( new ActionRowBuilder().addComponents(selectMenu) ); // Pagination buttons const navRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`inv_prev_${viewerId}`) .setLabel("◀ Previous") .setStyle(ButtonStyle.Secondary) .setDisabled(safePage <= 0), new ButtonBuilder() .setCustomId(`inv_page_${viewerId}`) .setLabel(`Page ${safePage + 1}/${totalPages}`) .setStyle(ButtonStyle.Secondary) .setDisabled(true), new ButtonBuilder() .setCustomId(`inv_next_${viewerId}`) .setLabel("Next ▶") .setStyle(ButtonStyle.Secondary) .setDisabled(safePage >= totalPages - 1), ); container.addActionRowComponents(navRow); return { components: [container] as any, files: [] as AttachmentBuilder[], flags: MessageFlags.IsComponentsV2, embeds: [], }; } export function getEmptyInventoryMessage(username: string) { const container = new ContainerBuilder() .setAccentColor(0x95A5A6) .addTextDisplayComponents( new TextDisplayBuilder().setContent(`# 📦 ${username}'s Inventory`), new TextDisplayBuilder().setContent("*No items yet. Visit the shop or complete quests to earn items!*") ); return { components: [container] as any, files: [], flags: MessageFlags.IsComponentsV2, embeds: [], }; } export function getItemDetailMessage( entry: InventoryEntry, viewerId: string, ownerId: string, ) { const { item } = entry; const rc = getRarityConfig(item.rarity ?? "C"); const files: AttachmentBuilder[] = []; const container = new ContainerBuilder().setAccentColor(rc.color); // Header section with thumbnail const section = new SectionBuilder().addTextDisplayComponents( new TextDisplayBuilder().setContent(`${rc.squareEmoji} **${item.name}**`), new TextDisplayBuilder().setContent(`-# ${rc.label} · ${item.type}`) ); // Resolve icon thumbnail const iconUrl = resolveItemUrl(item.iconUrl, files); if (iconUrl) { section.setThumbnailAccessory(new ThumbnailBuilder().setURL(iconUrl)); } container.addSectionComponents(section); // Artwork via MediaGallery const imageUrl = resolveItemUrl(item.imageUrl, files); if (imageUrl && item.imageUrl !== item.iconUrl) { container.addMediaGalleryComponents( new MediaGalleryBuilder().addItems( new MediaGalleryItemBuilder().setURL(imageUrl) ) ); } // Description if (item.description) { container.addTextDisplayComponents( new TextDisplayBuilder().setContent(item.description) ); } container.addSeparatorComponents( new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small) ); // Stats row const priceText = item.price ? `${item.price} 🪙` : "Not tradeable"; container.addTextDisplayComponents( new TextDisplayBuilder().setContent( `Owned: **×${entry.quantity}** · Value: **${priceText}**` ) ); // Action buttons const isOwner = viewerId === ownerId; const usageData = item.usageData as ItemUsageData | null; const isUsable = isOwner && item.type === ItemType.CONSUMABLE && usageData?.effects && usageData.effects.length > 0; const actionRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`inv_back_${viewerId}`) .setLabel("◀ Back") .setStyle(ButtonStyle.Primary) ); if (isUsable) { actionRow.addComponents( new ButtonBuilder() .setCustomId(`inv_use_${viewerId}`) .setLabel("🧪 Use") .setStyle(ButtonStyle.Success) ); } if (isOwner) { actionRow.addComponents( new ButtonBuilder() .setCustomId(`inv_discard_${viewerId}`) .setLabel("🗑 Discard") .setStyle(ButtonStyle.Danger) ); } container.addActionRowComponents(actionRow); return { components: [container] as any, files, flags: MessageFlags.IsComponentsV2, embeds: [], }; } export function getDiscardConfirmMessage(entry: InventoryEntry, viewerId: string) { const rc = getRarityConfig(entry.item.rarity ?? "C"); const container = new ContainerBuilder() .setAccentColor(0xED4245) .addTextDisplayComponents( new TextDisplayBuilder().setContent( `Are you sure you want to discard 1× **${entry.item.name}**?` ) ) .addActionRowComponents( new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`inv_discard_confirm_${viewerId}`) .setLabel("Confirm") .setStyle(ButtonStyle.Danger), new ButtonBuilder() .setCustomId(`inv_discard_cancel_${viewerId}`) .setLabel("Cancel") .setStyle(ButtonStyle.Secondary) ) ); return { components: [container] as any, files: [], flags: MessageFlags.IsComponentsV2, embeds: [], }; } /** * Resolves an item URL (icon or image) for use in CV2 components. * Handles both local assets and remote URLs. * Pushes AttachmentBuilders to `files` array for local assets. */ function resolveItemUrl(url: string | null | undefined, files: AttachmentBuilder[]): string | null { if (!url) return null; if (isLocalAssetUrl(url)) { const filePath = join(process.cwd(), "bot/assets/graphics", stripQuery(url).replace(/^\/?assets\//, "")); if (existsSync(filePath)) { const fileName = defaultName(url); if (!files.find(f => f.name === fileName)) { files.push(new AttachmentBuilder(filePath, { name: fileName })); } return `attachment://${fileName}`; } return null; } return resolveAssetUrl(url); } /** * 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 getLootboxResultMessage( results: any[], item?: { name: string; iconUrl: string | null; imageUrl: string | null; usageData: any } ) { const files: AttachmentBuilder[] = []; const otherMessages: string[] = []; let lootResult: any = null; 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)}`); } } // If no loot result, fall back to a simple embed (non-lootbox item usage) if (!lootResult) { const embed = new EmbedBuilder() .setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!") .setDescription(otherMessages.join("\n") || "Effect applied.") .setColor(0x2ecc71) .setTimestamp(); return { embeds: [embed], files, components: undefined, flags: undefined }; } // 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 { rarityKey = "NOTHING"; } const config = getRarityConfig(rarityKey); const container = new ContainerBuilder().setAccentColor(config.color); // Header: lootbox name if (item?.name) { container.addTextDisplayComponents( 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 || ""; description += (description ? "\n\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", stripQuery(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", stripQuery(imgSource).replace(/^\/?assets\//, "")); if (existsSync(imagePath)) { const imageName = defaultName(imgSource); if (!files.find(f => f.name === imageName)) { files.push(new AttachmentBuilder(imagePath, { name: imageName })); } displayImageUrl = `attachment://${imageName}`; } } else { displayImageUrl = resolveAssetUrl(imgSource); } if (displayImageUrl) { container.addMediaGalleryComponents( new MediaGalleryBuilder().addItems( new MediaGalleryItemBuilder().setURL(displayImageUrl) ) ); } } } // Other effects (non-lootbox results like temp roles, XP boosts) if (otherMessages.length > 0) { container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); container.addTextDisplayComponents( new TextDisplayBuilder().setContent(`**Other Effects**\n${otherMessages.join("\n")}`) ); } return { // TODO: remove cast once discord.js types include ContainerBuilder in MessageEditOptions components: [container] as any, files, flags: MessageFlags.IsComponentsV2, embeds: undefined, }; }