Files
aurorabot/docs/superpowers/plans/2026-03-28-inventory-display-redesign.md
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

31 KiB
Raw Blame History

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:

/**
 * 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
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 getInventoryEmbedgetInventoryListMessage 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:

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
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
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
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
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
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
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

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
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

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
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
git add -A
git commit -m "fix(inventory): address integration issues from inventory redesign"

(Only if fixes were needed in the previous steps.)