Some checks failed
Deploy to Production / test (push) Failing after 31s
- Replace setTimeout race in use-item flow with explicit Back button - Fix collector end handler to re-render current view instead of blanking - Add appendUseBackButton helper to attach navigation to use results - Remove unused isInventoryInteraction import - Fix rarity test type assertions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
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<string, number> = {
|
||
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<StringSelectMenuBuilder>().addComponents(selectMenu)
|
||
);
|
||
|
||
// Pagination buttons
|
||
const navRow = new ActionRowBuilder<ButtonBuilder>().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<ButtonBuilder>().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<ButtonBuilder>().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: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Wraps a use-item result message with a Back button so the user
|
||
* can return to the inventory after seeing the effect result.
|
||
*/
|
||
export function appendUseBackButton(message: any, viewerId: string): any {
|
||
const backRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||
new ButtonBuilder()
|
||
.setCustomId(`inv_use_back_${viewerId}`)
|
||
.setLabel("◀ Back to Inventory")
|
||
.setStyle(ButtonStyle.Primary)
|
||
);
|
||
|
||
// If CV2 message with components array, append to the first container
|
||
if (message.components && message.flags === MessageFlags.IsComponentsV2) {
|
||
const container = message.components[0];
|
||
if (container?.addActionRowComponents) {
|
||
container.addActionRowComponents(backRow);
|
||
}
|
||
return message;
|
||
}
|
||
|
||
// Embed-based fallback — add as a regular component row
|
||
return {
|
||
...message,
|
||
components: [...(message.components || []), backRow],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
};
|
||
}
|