feat(inventory): rewrite inventory view with CV2 list and detail builders
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,37 +9,307 @@ import {
|
|||||||
ThumbnailBuilder,
|
ThumbnailBuilder,
|
||||||
SeparatorBuilder,
|
SeparatorBuilder,
|
||||||
SeparatorSpacingSize,
|
SeparatorSpacingSize,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
StringSelectMenuOptionBuilder,
|
||||||
MessageFlags,
|
MessageFlags,
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||||
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
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 { join } from "path";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
|
|
||||||
/**
|
export const ITEMS_PER_PAGE = 5;
|
||||||
* Inventory entry with item details
|
|
||||||
*/
|
const RARITY_SORT_ORDER: Record<string, number> = {
|
||||||
interface InventoryEntry {
|
SSR: 0,
|
||||||
quantity: bigint | null;
|
SR: 1,
|
||||||
item: {
|
R: 2,
|
||||||
|
C: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
[key: string]: any;
|
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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an embed displaying a user's inventory
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
|
function resolveItemUrl(url: string | null | undefined, files: AttachmentBuilder[]): string | null {
|
||||||
const description = items.map(entry => {
|
if (!url) return null;
|
||||||
return `**${entry.item.name}** x${entry.quantity}`;
|
|
||||||
}).join("\n");
|
|
||||||
|
|
||||||
return new EmbedBuilder()
|
if (isLocalAssetUrl(url)) {
|
||||||
.setTitle(`📦 ${username}'s Inventory`)
|
const filePath = join(process.cwd(), "bot/assets/graphics", stripQuery(url).replace(/^\/?assets\//, ""));
|
||||||
.setDescription(description)
|
if (existsSync(filePath)) {
|
||||||
.setColor(0x3498db); // Blue
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user