Files
aurorabot/bot/modules/inventory/inventory.view.ts
2026-03-28 17:32:44 +01:00

460 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: [],
};
}
/**
* 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,
};
}