# 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 = { 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 = { 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().addComponents(selectMenu) ); // Pagination buttons const navRow = new ActionRowBuilder().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().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().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.)