8 Commits

Author SHA1 Message Date
syntaxbullet
5188d86d61 fix(inventory): address code review findings
Some checks failed
Deploy to Production / test (push) Failing after 31s
- Replace setTimeout race in use-item flow with explicit Back button
- Fix collector end handler to re-render current view instead of blanking
- Add appendUseBackButton helper to attach navigation to use results
- Remove unused isInventoryInteraction import
- Fix rarity test type assertions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:49:12 +01:00
syntaxbullet
6a1498813f feat(inventory): rewrite command with CV2 pagination and detail view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:42:50 +01:00
syntaxbullet
e4f7c03005 feat(inventory): add inventory interaction handler utilities
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:37:30 +01:00
syntaxbullet
38098a02ea feat(inventory): rewrite inventory view with CV2 list and detail builders
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:32:44 +01:00
syntaxbullet
fa09ef25e2 feat(inventory): add squareEmoji to RARITY_CONFIG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:04:07 +01:00
syntaxbullet
ba8afd144e docs: add inventory display redesign implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:01:09 +01:00
syntaxbullet
8ef1873410 docs: add ownership protection to inventory spec
Viewing another user's inventory is read-only — Use and Discard
buttons only render when viewer is the inventory owner, with a
server-side guard in the interaction handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:58:08 +01:00
syntaxbullet
289044e26f docs: add inventory display redesign spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:56:45 +01:00
7 changed files with 1686 additions and 42 deletions

View File

@@ -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(() => {});
}
});
}

View 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;
}

View File

@@ -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);
} }
/** /**

View 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.)

View 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

View File

@@ -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"]!);
}); });
}); });

View File

@@ -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"]!);
} }