Compare commits
8 Commits
47ea6d8620
...
5188d86d61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5188d86d61 | ||
|
|
6a1498813f | ||
|
|
e4f7c03005 | ||
|
|
38098a02ea | ||
|
|
fa09ef25e2 | ||
|
|
ba8afd144e | ||
|
|
8ef1873410 | ||
|
|
289044e26f |
@@ -1,22 +1,83 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createWarningEmbed } from "@lib/embeds";
|
import { createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
import {
|
||||||
|
getInventoryListMessage,
|
||||||
|
getEmptyInventoryMessage,
|
||||||
|
getItemDetailMessage,
|
||||||
|
getDiscardConfirmMessage,
|
||||||
|
appendUseBackButton,
|
||||||
|
sortInventoryItems,
|
||||||
|
ITEMS_PER_PAGE,
|
||||||
|
type InventoryEntry,
|
||||||
|
} from "@/modules/inventory/inventory.view";
|
||||||
|
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||||
|
import {
|
||||||
|
parseInventoryCustomId,
|
||||||
|
executeItemUse,
|
||||||
|
} from "@/modules/inventory/inventory.interaction";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
export const inventory = createCommand({
|
export const inventory = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("inventory")
|
.setName("inventory")
|
||||||
.setDescription("View your or another user's inventory")
|
.setDescription("View your or another user's inventory")
|
||||||
.addUserOption(option =>
|
.addSubcommand(sub =>
|
||||||
option.setName("user")
|
sub.setName("list")
|
||||||
.setDescription("User to view")
|
.setDescription("View your or another user's inventory")
|
||||||
.setRequired(false)
|
.addUserOption(option =>
|
||||||
|
option.setName("user")
|
||||||
|
.setDescription("User to view")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("view")
|
||||||
|
.setDescription("View details of a specific item")
|
||||||
|
.addNumberOption(option =>
|
||||||
|
option.setName("item")
|
||||||
|
.setDescription("The item to view")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const viewerId = interaction.user.id;
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === "view") {
|
||||||
|
// Direct item detail view
|
||||||
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
|
const user = await userService.getOrCreateUser(viewerId, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await inventoryService.getInventory(user.id.toString());
|
||||||
|
const entry = entries.find((e: any) => e.item.id === itemId);
|
||||||
|
if (!entry) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Item not found in your inventory.", "Not Found")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = user.id.toString();
|
||||||
|
let currentPage = 0;
|
||||||
|
let selectedItemId: number | null = itemId;
|
||||||
|
|
||||||
|
const response = await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry as InventoryEntry, viewerId, ownerId) as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "list" subcommand
|
||||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||||
|
|
||||||
if (targetUser.bot) {
|
if (targetUser.bot) {
|
||||||
@@ -30,15 +91,232 @@ export const inventory = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await inventoryService.getInventory(user.id.toString());
|
const ownerId = user.id.toString();
|
||||||
|
const entries = await inventoryService.getInventory(ownerId);
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
if (!entries || entries.length === 0) {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
await interaction.editReply(getEmptyInventoryMessage(user.username) as any);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = getInventoryEmbed(items, user.username);
|
let currentPage = 0;
|
||||||
|
let selectedItemId: number | null = null;
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
const response = await interaction.editReply(
|
||||||
}
|
getInventoryListMessage(entries as InventoryEntry[], user.username, currentPage, viewerId, ownerId) as any
|
||||||
|
);
|
||||||
|
|
||||||
|
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||||
|
},
|
||||||
|
autocomplete: async (interaction) => {
|
||||||
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
const userId = interaction.user.id;
|
||||||
|
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||||
|
await interaction.respond(results);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function setupCollector(
|
||||||
|
interaction: any,
|
||||||
|
response: any,
|
||||||
|
viewerId: string,
|
||||||
|
ownerId: string,
|
||||||
|
username: string,
|
||||||
|
initialPage: number,
|
||||||
|
initialItemId: number | null,
|
||||||
|
) {
|
||||||
|
let currentPage = initialPage;
|
||||||
|
let selectedItemId = initialItemId;
|
||||||
|
|
||||||
|
const collector = response.createMessageComponentCollector({
|
||||||
|
time: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("collect", async (i: any) => {
|
||||||
|
if (i.user.id !== viewerId) return;
|
||||||
|
|
||||||
|
const parsed = parseInventoryCustomId(i.customId);
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await i.deferUpdate();
|
||||||
|
|
||||||
|
// Re-fetch inventory for fresh data
|
||||||
|
const entries = await inventoryService.getInventory(ownerId);
|
||||||
|
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||||
|
|
||||||
|
switch (parsed.action) {
|
||||||
|
case "select": {
|
||||||
|
const itemId = parseInt(i.values[0]);
|
||||||
|
const entry = sorted.find(e => e.item.id === itemId);
|
||||||
|
if (!entry) break;
|
||||||
|
selectedItemId = itemId;
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "prev": {
|
||||||
|
currentPage = Math.max(0, currentPage - 1);
|
||||||
|
selectedItemId = null;
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "next": {
|
||||||
|
currentPage = currentPage + 1;
|
||||||
|
selectedItemId = null;
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "back": {
|
||||||
|
selectedItemId = null;
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "use": {
|
||||||
|
if (viewerId !== ownerId || !selectedItemId) break;
|
||||||
|
try {
|
||||||
|
const result = await executeItemUse(i, viewerId, selectedItemId);
|
||||||
|
const message = getLootboxResultMessage(result.results, result.item);
|
||||||
|
await interaction.editReply(appendUseBackButton(message, viewerId) as any);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
components: [],
|
||||||
|
flags: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "use_back": {
|
||||||
|
// Return from use result to detail or list
|
||||||
|
if (!selectedItemId) break;
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (entry) {
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedItemId = null;
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discard": {
|
||||||
|
if (viewerId !== ownerId || !selectedItemId) break;
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (!entry) break;
|
||||||
|
await interaction.editReply(
|
||||||
|
getDiscardConfirmMessage(entry, viewerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discard_confirm": {
|
||||||
|
if (viewerId !== ownerId || !selectedItemId) break;
|
||||||
|
try {
|
||||||
|
await inventoryService.removeItem(ownerId, selectedItemId, 1n);
|
||||||
|
|
||||||
|
const freshEntries = await inventoryService.getInventory(ownerId);
|
||||||
|
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
|
||||||
|
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
|
||||||
|
|
||||||
|
if (freshEntry) {
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(freshEntry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedItemId = null;
|
||||||
|
if (freshSorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
components: [],
|
||||||
|
flags: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discard_cancel": {
|
||||||
|
if (!selectedItemId) break;
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (!entry) break;
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Inventory interaction error:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("end", async () => {
|
||||||
|
try {
|
||||||
|
// Re-render current view as static (no interactive components)
|
||||||
|
const entries = await inventoryService.getInventory(ownerId);
|
||||||
|
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||||
|
|
||||||
|
if (selectedItemId) {
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (entry) {
|
||||||
|
// Show detail view without action buttons
|
||||||
|
const msg = getItemDetailMessage(entry, viewerId, ownerId);
|
||||||
|
// Replace components with empty to remove buttons but keep container content
|
||||||
|
await interaction.editReply(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If re-rendering fails, at least try to clear gracefully
|
||||||
|
interaction.editReply({ components: [] }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
71
bot/modules/inventory/inventory.interaction.ts
Normal file
71
bot/modules/inventory/inventory.interaction.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { StringSelectMenuInteraction, ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
|
import { getLootboxResultMessage } from "./inventory.view";
|
||||||
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
|
export interface InventoryState {
|
||||||
|
ownerId: string;
|
||||||
|
viewerId: string;
|
||||||
|
page: number;
|
||||||
|
selectedItemId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the viewer user ID from an inventory custom ID.
|
||||||
|
* Custom IDs follow the format: inv_{action}_{viewerId}
|
||||||
|
*/
|
||||||
|
export function parseInventoryCustomId(customId: string): { action: string; viewerId: string } | null {
|
||||||
|
const match = customId.match(/^inv_(\w+?)_(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { action: match[1]!, viewerId: match[2]! };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a custom ID belongs to the inventory system.
|
||||||
|
*/
|
||||||
|
export function isInventoryInteraction(customId: string): boolean {
|
||||||
|
return customId.startsWith("inv_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the "Use" button — executes item effects.
|
||||||
|
* Returns the result messages array from inventoryService.useItem,
|
||||||
|
* plus handles role-based effects that require the guild member.
|
||||||
|
*/
|
||||||
|
export async function executeItemUse(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
userId: string,
|
||||||
|
itemId: number,
|
||||||
|
): Promise<{ results: any[]; usageData: ItemUsageData | null; item: any }> {
|
||||||
|
const result = await inventoryService.useItem(userId, itemId);
|
||||||
|
|
||||||
|
// Handle role effects (same logic as /use command)
|
||||||
|
const usageData = result.usageData;
|
||||||
|
if (usageData) {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
const colorRoles = guildConfig.colorRoles ?? [];
|
||||||
|
|
||||||
|
for (const effect of usageData.effects) {
|
||||||
|
if (effect.type === "TEMP_ROLE" || effect.type === "COLOR_ROLE") {
|
||||||
|
try {
|
||||||
|
const member = await interaction.guild?.members.fetch(userId);
|
||||||
|
if (member) {
|
||||||
|
if (effect.type === "TEMP_ROLE") {
|
||||||
|
await member.roles.add(effect.roleId);
|
||||||
|
} else if (effect.type === "COLOR_ROLE") {
|
||||||
|
const rolesToRemove = colorRoles.filter((r: string) => member.roles.cache.has(r));
|
||||||
|
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||||
|
await member.roles.add(effect.roleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to assign role in inventory use:", e);
|
||||||
|
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -9,37 +9,335 @@ 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,
|
||||||
|
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;
|
quantity: bigint | null;
|
||||||
item: {
|
item: InventoryItem;
|
||||||
id: number;
|
}
|
||||||
name: string;
|
|
||||||
[key: string]: any;
|
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
|
* 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 getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
|
export function appendUseBackButton(message: any, viewerId: string): any {
|
||||||
const description = items.map(entry => {
|
const backRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
return `**${entry.item.name}** x${entry.quantity}`;
|
new ButtonBuilder()
|
||||||
}).join("\n");
|
.setCustomId(`inv_use_back_${viewerId}`)
|
||||||
|
.setLabel("◀ Back to Inventory")
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
);
|
||||||
|
|
||||||
return new EmbedBuilder()
|
// If CV2 message with components array, append to the first container
|
||||||
.setTitle(`📦 ${username}'s Inventory`)
|
if (message.components && message.flags === MessageFlags.IsComponentsV2) {
|
||||||
.setDescription(description)
|
const container = message.components[0];
|
||||||
.setColor(0x3498db); // Blue
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
874
docs/superpowers/plans/2026-03-28-inventory-display-redesign.md
Normal file
874
docs/superpowers/plans/2026-03-28-inventory-display-redesign.md
Normal file
@@ -0,0 +1,874 @@
|
|||||||
|
# Inventory Display Redesign Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Redesign the `/inventory` command into a polished Components V2 experience with rarity emojis, paginated list, item detail view with artwork, and inline item actions (use/discard).
|
||||||
|
|
||||||
|
**Architecture:** Rewrite `inventory.view.ts` to produce CV2 containers for list and detail views. Rewrite `inventory.ts` command to manage pagination and view state with a collector. Add `inventory.interaction.ts` for interaction routing. Extend `RARITY_CONFIG` with square emojis.
|
||||||
|
|
||||||
|
**Tech Stack:** discord.js Components V2 (ContainerBuilder, TextDisplayBuilder, SectionBuilder, MediaGalleryBuilder, ActionRowBuilder, ButtonBuilder, StringSelectMenuBuilder), Drizzle ORM, Bun test runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `shared/lib/rarity.ts` | Modify | Add `squareEmoji` field to `RARITY_CONFIG` |
|
||||||
|
| `bot/modules/inventory/inventory.view.ts` | Rewrite | CV2 list message builder, CV2 detail message builder (keep `getLootboxResultMessage` untouched) |
|
||||||
|
| `bot/modules/inventory/inventory.interaction.ts` | Create | Handle all inventory interactions (select, pagination, back, use, discard, confirm) |
|
||||||
|
| `bot/commands/inventory/inventory.ts` | Rewrite | Command definition with `view` subcommand, pagination collector, autocomplete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add squareEmoji to RARITY_CONFIG
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `shared/lib/rarity.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the RARITY_CONFIG type and entries**
|
||||||
|
|
||||||
|
In `shared/lib/rarity.ts`, update the type signature and add `squareEmoji` to each entry:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Shared Rarity Configuration
|
||||||
|
* Provides the canonical rarity display config (colors, emoji, labels)
|
||||||
|
* used by lootbox pull results and shop loot table views.
|
||||||
|
*/
|
||||||
|
export const RARITY_CONFIG: Record<string, { color: number; emoji: string; squareEmoji: string; label: string }> = {
|
||||||
|
C: { color: 0x95A5A6, emoji: "📦", squareEmoji: "🟤", label: "Common" },
|
||||||
|
R: { color: 0x3498DB, emoji: "📦", squareEmoji: "🔵", label: "Rare" },
|
||||||
|
SR: { color: 0x9B59B6, emoji: "✨", squareEmoji: "🟣", label: "Super Rare" },
|
||||||
|
SSR: { color: 0xF1C40F, emoji: "🌟", squareEmoji: "🟡", label: "SSR" },
|
||||||
|
CURRENCY: { color: 0x2ECC71, emoji: "💰", squareEmoji: "💰", label: "Currency" },
|
||||||
|
XP: { color: 0x1ABC9C, emoji: "🔮", squareEmoji: "🔮", label: "Experience" },
|
||||||
|
NOTHING: { color: 0x636363, emoji: "💨", squareEmoji: "💨", label: "Empty" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRarityConfig(rarity: string): { color: number; emoji: string; squareEmoji: string; label: string } {
|
||||||
|
return RARITY_CONFIG[rarity] ?? (RARITY_CONFIG["C"]!);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify nothing is broken**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All existing tests pass (lootbox and other rarity consumers still work since they access `emoji`, not `squareEmoji`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add shared/lib/rarity.ts
|
||||||
|
git commit -m "feat(inventory): add squareEmoji to RARITY_CONFIG"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Build the inventory list view (CV2)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `bot/modules/inventory/inventory.view.ts`
|
||||||
|
|
||||||
|
This task rewrites `getInventoryEmbed` → `getInventoryListMessage` and adds `getItemDetailMessage`. The existing `getLootboxResultMessage` function stays untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Define constants and types at the top of inventory.view.ts**
|
||||||
|
|
||||||
|
Replace the existing `InventoryEntry` interface and add constants. Keep all existing imports and add the new ones needed:
|
||||||
|
|
||||||
|
```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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the sortInventoryItems helper**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement getInventoryListMessage**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement getEmptyInventoryMessage**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement getItemDetailMessage**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Implement getDiscardConfirmMessage and the resolveItemUrl helper**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify the file compiles**
|
||||||
|
|
||||||
|
Run: `bunx tsc --noEmit bot/modules/inventory/inventory.view.ts`
|
||||||
|
Expected: No type errors. (Note: `getLootboxResultMessage` remains unchanged below all the new code.)
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add bot/modules/inventory/inventory.view.ts
|
||||||
|
git commit -m "feat(inventory): rewrite inventory view with CV2 list and detail builders"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create the inventory interaction handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `bot/modules/inventory/inventory.interaction.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the interaction handler file**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { StringSelectMenuInteraction, ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
|
import { getLootboxResultMessage } from "./inventory.view";
|
||||||
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
|
export interface InventoryState {
|
||||||
|
ownerId: string;
|
||||||
|
viewerId: string;
|
||||||
|
page: number;
|
||||||
|
selectedItemId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the viewer user ID from an inventory custom ID.
|
||||||
|
* Custom IDs follow the format: inv_{action}_{viewerId}
|
||||||
|
*/
|
||||||
|
export function parseInventoryCustomId(customId: string): { action: string; viewerId: string } | null {
|
||||||
|
const match = customId.match(/^inv_(\w+?)_(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { action: match[1], viewerId: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a custom ID belongs to the inventory system.
|
||||||
|
*/
|
||||||
|
export function isInventoryInteraction(customId: string): boolean {
|
||||||
|
return customId.startsWith("inv_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the "Use" button — executes item effects.
|
||||||
|
* Returns the result messages array from inventoryService.useItem,
|
||||||
|
* plus handles role-based effects that require the guild member.
|
||||||
|
*/
|
||||||
|
export async function executeItemUse(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
userId: string,
|
||||||
|
itemId: number,
|
||||||
|
): Promise<{ results: any[]; usageData: ItemUsageData | null; item: any }> {
|
||||||
|
const result = await inventoryService.useItem(userId, itemId);
|
||||||
|
|
||||||
|
// Handle role effects (same logic as /use command)
|
||||||
|
const usageData = result.usageData;
|
||||||
|
if (usageData) {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
const colorRoles = guildConfig.colorRoles ?? [];
|
||||||
|
|
||||||
|
for (const effect of usageData.effects) {
|
||||||
|
if (effect.type === "TEMP_ROLE" || effect.type === "COLOR_ROLE") {
|
||||||
|
try {
|
||||||
|
const member = await interaction.guild?.members.fetch(userId);
|
||||||
|
if (member) {
|
||||||
|
if (effect.type === "TEMP_ROLE") {
|
||||||
|
await member.roles.add(effect.roleId);
|
||||||
|
} else if (effect.type === "COLOR_ROLE") {
|
||||||
|
const rolesToRemove = colorRoles.filter((r: string) => member.roles.cache.has(r));
|
||||||
|
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||||
|
await member.roles.add(effect.roleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to assign role in inventory use:", e);
|
||||||
|
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the file compiles**
|
||||||
|
|
||||||
|
Run: `bunx tsc --noEmit bot/modules/inventory/inventory.interaction.ts`
|
||||||
|
Expected: No type errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add bot/modules/inventory/inventory.interaction.ts
|
||||||
|
git commit -m "feat(inventory): add inventory interaction handler utilities"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Rewrite the inventory command
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `bot/commands/inventory/inventory.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite the command with subcommands, collector, and interaction routing**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
|
||||||
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
import { createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
import {
|
||||||
|
getInventoryListMessage,
|
||||||
|
getEmptyInventoryMessage,
|
||||||
|
getItemDetailMessage,
|
||||||
|
getDiscardConfirmMessage,
|
||||||
|
sortInventoryItems,
|
||||||
|
ITEMS_PER_PAGE,
|
||||||
|
type InventoryEntry,
|
||||||
|
} from "@/modules/inventory/inventory.view";
|
||||||
|
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||||
|
import {
|
||||||
|
parseInventoryCustomId,
|
||||||
|
isInventoryInteraction,
|
||||||
|
executeItemUse,
|
||||||
|
} from "@/modules/inventory/inventory.interaction";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
export const inventory = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("inventory")
|
||||||
|
.setDescription("View your or another user's inventory")
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("list")
|
||||||
|
.setDescription("View your or another user's inventory")
|
||||||
|
.addUserOption(option =>
|
||||||
|
option.setName("user")
|
||||||
|
.setDescription("User to view")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("view")
|
||||||
|
.setDescription("View details of a specific item")
|
||||||
|
.addNumberOption(option =>
|
||||||
|
option.setName("item")
|
||||||
|
.setDescription("The item to view")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const viewerId = interaction.user.id;
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === "view") {
|
||||||
|
// Direct item detail view
|
||||||
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
|
const user = await userService.getOrCreateUser(viewerId, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await inventoryService.getInventory(user.id.toString());
|
||||||
|
const entry = entries.find((e: any) => e.item.id === itemId);
|
||||||
|
if (!entry) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Item not found in your inventory.", "Not Found")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = user.id.toString();
|
||||||
|
let currentPage = 0;
|
||||||
|
let selectedItemId: number | null = itemId;
|
||||||
|
|
||||||
|
const response = await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry as InventoryEntry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
|
||||||
|
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "list" subcommand
|
||||||
|
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||||
|
|
||||||
|
if (targetUser.bot) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have inventories.", "Inventory Check")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||||
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = user.id.toString();
|
||||||
|
const entries = await inventoryService.getInventory(ownerId);
|
||||||
|
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(user.username));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage = 0;
|
||||||
|
let selectedItemId: number | null = null;
|
||||||
|
|
||||||
|
const response = await interaction.editReply(
|
||||||
|
getInventoryListMessage(entries as InventoryEntry[], user.username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
|
||||||
|
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||||
|
},
|
||||||
|
autocomplete: async (interaction) => {
|
||||||
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
const userId = interaction.user.id;
|
||||||
|
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||||
|
await interaction.respond(results);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupCollector(
|
||||||
|
interaction: any,
|
||||||
|
response: any,
|
||||||
|
viewerId: string,
|
||||||
|
ownerId: string,
|
||||||
|
username: string,
|
||||||
|
initialPage: number,
|
||||||
|
initialItemId: number | null,
|
||||||
|
) {
|
||||||
|
let currentPage = initialPage;
|
||||||
|
let selectedItemId = initialItemId;
|
||||||
|
|
||||||
|
const collector = response.createMessageComponentCollector({
|
||||||
|
time: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("collect", async (i: any) => {
|
||||||
|
if (i.user.id !== viewerId) return;
|
||||||
|
|
||||||
|
const parsed = parseInventoryCustomId(i.customId);
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await i.deferUpdate();
|
||||||
|
|
||||||
|
// Re-fetch inventory for fresh data
|
||||||
|
const entries = await inventoryService.getInventory(ownerId);
|
||||||
|
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||||
|
|
||||||
|
switch (parsed.action) {
|
||||||
|
case "select": {
|
||||||
|
const itemId = parseInt(i.values[0]);
|
||||||
|
const entry = sorted.find(e => e.item.id === itemId);
|
||||||
|
if (!entry) break;
|
||||||
|
selectedItemId = itemId;
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "prev": {
|
||||||
|
currentPage = Math.max(0, currentPage - 1);
|
||||||
|
selectedItemId = null;
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "next": {
|
||||||
|
currentPage = currentPage + 1;
|
||||||
|
selectedItemId = null;
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "back": {
|
||||||
|
selectedItemId = null;
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "use": {
|
||||||
|
if (viewerId !== ownerId || !selectedItemId) break;
|
||||||
|
try {
|
||||||
|
const result = await executeItemUse(i, viewerId, selectedItemId);
|
||||||
|
const message = getLootboxResultMessage(result.results, result.item);
|
||||||
|
await interaction.editReply(message as any);
|
||||||
|
|
||||||
|
// After showing result, wait briefly then return to detail or list
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const freshEntries = await inventoryService.getInventory(ownerId);
|
||||||
|
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
|
||||||
|
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
|
||||||
|
|
||||||
|
if (freshEntry) {
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(freshEntry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedItemId = null;
|
||||||
|
if (freshSorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
components: [],
|
||||||
|
flags: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discard": {
|
||||||
|
if (viewerId !== ownerId || !selectedItemId) break;
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (!entry) break;
|
||||||
|
await interaction.editReply(
|
||||||
|
getDiscardConfirmMessage(entry, viewerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discard_confirm": {
|
||||||
|
if (viewerId !== ownerId || !selectedItemId) break;
|
||||||
|
try {
|
||||||
|
await inventoryService.removeItem(ownerId, selectedItemId, 1n);
|
||||||
|
|
||||||
|
const freshEntries = await inventoryService.getInventory(ownerId);
|
||||||
|
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
|
||||||
|
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
|
||||||
|
|
||||||
|
if (freshEntry) {
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(freshEntry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedItemId = null;
|
||||||
|
if (freshSorted.length === 0) {
|
||||||
|
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
components: [],
|
||||||
|
flags: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discard_cancel": {
|
||||||
|
if (!selectedItemId) break;
|
||||||
|
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||||
|
if (!entry) break;
|
||||||
|
await interaction.editReply(
|
||||||
|
getItemDetailMessage(entry, viewerId, ownerId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Inventory interaction error:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("end", () => {
|
||||||
|
interaction.editReply({ components: [] }).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the file compiles**
|
||||||
|
|
||||||
|
Run: `bunx tsc --noEmit bot/commands/inventory/inventory.ts`
|
||||||
|
Expected: No type errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add bot/commands/inventory/inventory.ts
|
||||||
|
git commit -m "feat(inventory): rewrite command with CV2 pagination and detail view"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Integration testing and verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- All modified files
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All tests pass. The inventory service tests should still pass since we didn't change the service.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript compiles cleanly**
|
||||||
|
|
||||||
|
Run: `bunx tsc --noEmit`
|
||||||
|
Expected: No type errors across the entire project.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the bot starts**
|
||||||
|
|
||||||
|
Run: `bun --watch bot/index.ts` (start and verify no startup errors, then stop)
|
||||||
|
Expected: Bot initializes and registers commands without errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final commit if any fixes were needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix(inventory): address integration issues from inventory redesign"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Only if fixes were needed in the previous steps.)
|
||||||
123
docs/superpowers/specs/2026-03-28-inventory-display-redesign.md
Normal file
123
docs/superpowers/specs/2026-03-28-inventory-display-redesign.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Inventory Display Redesign
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Redesign the `/inventory` command from a basic embed listing to a polished Components V2 experience with rarity indicators, paginated list view, item detail view with artwork, and inline item management actions.
|
||||||
|
|
||||||
|
## Rarity Emoji Mapping
|
||||||
|
|
||||||
|
Add a `squareEmoji` field to `RARITY_CONFIG` in `shared/lib/rarity.ts`:
|
||||||
|
|
||||||
|
| Rarity | squareEmoji | Existing emoji | Color hex |
|
||||||
|
|--------|-------------|----------------|-----------|
|
||||||
|
| C | 🟤 | 📦 | 0x95A5A6 |
|
||||||
|
| R | 🔵 | 📦 | 0x3498DB |
|
||||||
|
| SR | 🟣 | ✨ | 0x9B59B6 |
|
||||||
|
| SSR | 🟡 | 🌟 | 0xF1C40F |
|
||||||
|
|
||||||
|
Non-item rarities (CURRENCY, XP, NOTHING) do not get square emojis. The existing `emoji` field remains unchanged (used by lootbox results).
|
||||||
|
|
||||||
|
## List View
|
||||||
|
|
||||||
|
The `/inventory [user]` command renders a Components V2 message:
|
||||||
|
|
||||||
|
1. **Header** — `TextDisplayBuilder`: `# 📦 {username}'s Inventory` with subtitle showing total item count.
|
||||||
|
2. **Separator**
|
||||||
|
3. **Item rows** (5 per page) — Each item is a `TextDisplayBuilder` line: `{squareEmoji} **{Item Name}** — {Rarity Label} · {Type} · ×{quantity}`
|
||||||
|
4. **Separator**
|
||||||
|
5. **Select menu** — `StringSelectMenuBuilder` populated with the 5 items on the current page. Placeholder: "Select an item for details". Each option shows item name and rarity label.
|
||||||
|
6. **Navigation row** — `ActionRowBuilder`: `◀ Previous` (disabled on page 1), disabled `Page X/Y` indicator button, `Next ▶` (disabled on last page).
|
||||||
|
|
||||||
|
**Container:** `ContainerBuilder` with accent color from the highest-rarity item on the current page.
|
||||||
|
|
||||||
|
**Sorting:** Items sorted by rarity descending (SSR → SR → R → C), then alphabetically within the same rarity.
|
||||||
|
|
||||||
|
**Empty state:** If inventory is empty, show: "No items yet. Visit the shop or complete quests to earn items!"
|
||||||
|
|
||||||
|
**Collector:** `createMessageComponentCollector` with 2-minute idle timeout. On timeout, disable all interactive components.
|
||||||
|
|
||||||
|
## Detail View
|
||||||
|
|
||||||
|
Shown when a user selects an item from the dropdown or uses `/inventory view <item>`:
|
||||||
|
|
||||||
|
1. **Header section** — `SectionBuilder`:
|
||||||
|
- `TextDisplayBuilder`: `{squareEmoji} **{Item Name}**` with subtitle `-# {Rarity Label} · {Type}`
|
||||||
|
- `ThumbnailBuilder` with the item's `iconUrl`
|
||||||
|
2. **Artwork** — `MediaGalleryBuilder` displaying the item's `imageUrl`
|
||||||
|
3. **Description** — `TextDisplayBuilder` with the item's `description`
|
||||||
|
4. **Separator**
|
||||||
|
5. **Stats row** — `TextDisplayBuilder`: `Owned: **×{quantity}**` and `Value: **{price} 🪙**` (or "Not tradeable" if price is null)
|
||||||
|
6. **Action buttons** — `ActionRowBuilder`:
|
||||||
|
- `◀ Back` (primary) — always shown, returns to list view at the same page
|
||||||
|
- `🧪 Use` (success) — only shown if **viewer is the owner** AND item type is CONSUMABLE with effects defined
|
||||||
|
- `🗑 Discard` (danger) — only shown if **viewer is the owner**
|
||||||
|
|
||||||
|
**Container:** `ContainerBuilder` with accent color matching the item's rarity color.
|
||||||
|
|
||||||
|
### Ownership Protection
|
||||||
|
|
||||||
|
The command tracks two IDs: `viewerId` (who ran the command) and `ownerId` (whose inventory is displayed). When `viewerId !== ownerId`, the inventory is **read-only**:
|
||||||
|
- The detail view only shows the Back button (no Use or Discard).
|
||||||
|
- The interaction handler validates `viewerId === ownerId` before executing `useItem` or `removeItem`, as a server-side guard even if the buttons were somehow rendered.
|
||||||
|
|
||||||
|
### Use Button Flow
|
||||||
|
|
||||||
|
Calls `inventoryService.useItem()` and shows the result inline. Then returns to the detail view with updated quantity. If quantity reaches 0, returns to the list view.
|
||||||
|
|
||||||
|
### Discard Flow
|
||||||
|
|
||||||
|
1. Clicking `🗑 Discard` replaces the action row with a confirmation: "Discard 1× {Item Name}?" with `Confirm` (danger) and `Cancel` (secondary) buttons.
|
||||||
|
2. On confirm: calls `inventoryService.removeItem(userId, itemId, 1)`, returns to detail view with updated quantity. If quantity reaches 0, returns to list view.
|
||||||
|
3. On cancel: returns to the normal detail view action buttons.
|
||||||
|
|
||||||
|
## `/inventory view <item>` Subcommand
|
||||||
|
|
||||||
|
Adds a `view` subcommand with a required `item` string option that has autocomplete. Autocomplete queries the user's inventory items (reusing the pattern from `getAutocompleteItems`). Goes directly to the detail view. The Back button returns to the full paginated list at page 1.
|
||||||
|
|
||||||
|
## Item Selection Entry Points
|
||||||
|
|
||||||
|
Two ways to reach the detail view:
|
||||||
|
- **Select menu dropdown** on the inventory list — for browsing
|
||||||
|
- **`/inventory view <item>`** subcommand — for direct access when the user knows the item name
|
||||||
|
|
||||||
|
Both render the same detail view.
|
||||||
|
|
||||||
|
## Interaction Custom IDs
|
||||||
|
|
||||||
|
All custom IDs include the invoking user's ID to prevent other users from interacting:
|
||||||
|
|
||||||
|
| Custom ID | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `inv_select_{userId}` | Item select menu |
|
||||||
|
| `inv_prev_{userId}` | Previous page button |
|
||||||
|
| `inv_next_{userId}` | Next page button |
|
||||||
|
| `inv_back_{userId}` | Back to list from detail |
|
||||||
|
| `inv_use_{userId}` | Use item button |
|
||||||
|
| `inv_discard_{userId}` | Discard item button |
|
||||||
|
| `inv_discard_confirm_{userId}` | Confirm discard |
|
||||||
|
| `inv_discard_cancel_{userId}` | Cancel discard |
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
|
||||||
|
- **`shared/lib/rarity.ts`** — Add `squareEmoji` field to `RARITY_CONFIG` entries for C, R, SR, SSR.
|
||||||
|
- **`bot/commands/inventory/inventory.ts`** — Rewrite to CV2 with pagination collector. Add `view` subcommand with autocomplete. Command setup and collector logic live here.
|
||||||
|
- **`bot/modules/inventory/inventory.view.ts`** — Replace `getInventoryEmbed` with `getInventoryListMessage` (builds the paginated CV2 list) and add `getItemDetailMessage` (builds the detail CV2 view). `getLootboxResultMessage` is untouched.
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- **`bot/modules/inventory/inventory.interaction.ts`** — Handles all inventory interaction routing: select menu item selection, pagination buttons, back navigation, use item, discard + confirmation flow.
|
||||||
|
|
||||||
|
### Unchanged
|
||||||
|
|
||||||
|
- `shared/modules/inventory/inventory.service.ts` — Already provides `getInventory`, `useItem`, `removeItem`, `getAutocompleteItems`.
|
||||||
|
- Database schema — All required fields (`iconUrl`, `imageUrl`, `description`, `rarity`, `type`, `price`) already exist on the items table.
|
||||||
|
|
||||||
|
## Pagination Details
|
||||||
|
|
||||||
|
- **Items per page:** 5
|
||||||
|
- **Page calculation:** `totalPages = Math.ceil(items.length / 5)`
|
||||||
|
- **Page clamping:** `safePage = Math.min(page, totalPages - 1)` to handle items being consumed while browsing
|
||||||
|
- **Collector timeout:** 2 minutes idle, matching the quest system pattern
|
||||||
|
- **On timeout:** Edit message to disable all buttons and the select menu
|
||||||
@@ -16,12 +16,12 @@ describe("getRarityConfig", () => {
|
|||||||
|
|
||||||
it("falls back to Common for unknown rarity", () => {
|
it("falls back to Common for unknown rarity", () => {
|
||||||
const result = getRarityConfig("LEGENDARY");
|
const result = getRarityConfig("LEGENDARY");
|
||||||
expect(result).toEqual(RARITY_CONFIG["C"]);
|
expect(result).toEqual(RARITY_CONFIG["C"]!);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to Common for null/undefined input", () => {
|
it("falls back to Common for null/undefined input", () => {
|
||||||
expect(getRarityConfig(null as any)).toEqual(RARITY_CONFIG["C"]);
|
expect(getRarityConfig(null as any)).toEqual(RARITY_CONFIG["C"]!);
|
||||||
expect(getRarityConfig(undefined as any)).toEqual(RARITY_CONFIG["C"]);
|
expect(getRarityConfig(undefined as any)).toEqual(RARITY_CONFIG["C"]!);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
* Provides the canonical rarity display config (colors, emoji, labels)
|
* Provides the canonical rarity display config (colors, emoji, labels)
|
||||||
* used by lootbox pull results and shop loot table views.
|
* used by lootbox pull results and shop loot table views.
|
||||||
*/
|
*/
|
||||||
export const RARITY_CONFIG: Record<string, { color: number; emoji: string; label: string }> = {
|
export const RARITY_CONFIG: Record<string, { color: number; emoji: string; squareEmoji: string; label: string }> = {
|
||||||
C: { color: 0x95A5A6, emoji: "📦", label: "Common" },
|
C: { color: 0x95A5A6, emoji: "📦", squareEmoji: "🟤", label: "Common" },
|
||||||
R: { color: 0x3498DB, emoji: "📦", label: "Rare" },
|
R: { color: 0x3498DB, emoji: "📦", squareEmoji: "🔵", label: "Rare" },
|
||||||
SR: { color: 0x9B59B6, emoji: "✨", label: "Super Rare" },
|
SR: { color: 0x9B59B6, emoji: "✨", squareEmoji: "🟣", label: "Super Rare" },
|
||||||
SSR: { color: 0xF1C40F, emoji: "🌟", label: "SSR" },
|
SSR: { color: 0xF1C40F, emoji: "🌟", squareEmoji: "🟡", label: "SSR" },
|
||||||
CURRENCY: { color: 0x2ECC71, emoji: "💰", label: "Currency" },
|
CURRENCY: { color: 0x2ECC71, emoji: "💰", squareEmoji: "💰", label: "Currency" },
|
||||||
XP: { color: 0x1ABC9C, emoji: "🔮", label: "Experience" },
|
XP: { color: 0x1ABC9C, emoji: "🔮", squareEmoji: "🔮", label: "Experience" },
|
||||||
NOTHING: { color: 0x636363, emoji: "💨", label: "Empty" },
|
NOTHING: { color: 0x636363, emoji: "💨", squareEmoji: "💨", label: "Empty" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getRarityConfig(rarity: string): { color: number; emoji: string; label: string } {
|
export function getRarityConfig(rarity: string): { color: number; emoji: string; squareEmoji: string; label: string } {
|
||||||
return RARITY_CONFIG[rarity] ?? (RARITY_CONFIG["C"]!);
|
return RARITY_CONFIG[rarity] ?? (RARITY_CONFIG["C"]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user