diff --git a/docs/superpowers/plans/2026-03-18-lootbox-ux-overhaul.md b/docs/superpowers/plans/2026-03-18-lootbox-ux-overhaul.md new file mode 100644 index 0000000..adf26b3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-lootbox-ux-overhaul.md @@ -0,0 +1,548 @@ +# Lootbox UX Overhaul 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:** Overhaul lootbox pull results and shop loot table displays using Discord Components V2 with rarity-driven theming. + +**Architecture:** Extract shared rarity config and asset helpers into `shared/lib/rarity.ts`. Modify `effect.handlers.ts` to return separate `iconUrl`/`imageUrl` fields. Rewrite `inventory.view.ts` pull result builder and `shop.view.ts` loot table section to use Components V2 containers with rarity-themed accent colors. + +**Tech Stack:** TypeScript, Discord.js (Components V2: ContainerBuilder, SectionBuilder, TextDisplayBuilder, MediaGalleryBuilder, SeparatorBuilder), Bun test + +**Spec:** `docs/superpowers/specs/2026-03-18-lootbox-ux-overhaul-design.md` + +**Note on color changes:** The new `RARITY_CONFIG` uses slightly different hex values than the previous Discord.js `Colors` enum values for Common (`0x95A5A6` vs `Colors.LightGrey` = `0xBCC0C0`) and Nothing (`0x636363` vs `Colors.DarkButNotBlack` = `0x2C2F33`). This is intentional per the design spec. + +**Note on `useItem` return shape:** `inventoryService.useItem()` returns the full item from the Drizzle relation query (`with: { item: true }`), which already includes both `iconUrl` and `imageUrl` columns from the `items` schema. No changes to the service are needed. + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `shared/lib/rarity.ts` | Create | `RARITY_CONFIG` map, `defaultName` helper, rarity lookup with fallback | +| `shared/lib/rarity.test.ts` | Create | Tests for rarity config lookup and defaultName | +| `shared/modules/inventory/effect.handlers.ts` | Modify | Return separate `iconUrl` and `imageUrl` in ITEM lootbox results | +| `bot/modules/inventory/inventory.view.ts` | Modify | Replace `getItemUseResultEmbed()` with Components V2 `getLootboxResultMessage()`, remove local `defaultName` | +| `bot/commands/inventory/use.ts` | Modify | Switch from embed reply to Components V2 message | +| `bot/modules/economy/shop.view.ts` | Modify | Rework loot table into separate Container 2, replace local constants with shared imports, remove local `defaultName` | + +--- + +### Task 1: Create shared rarity config + +**Files:** +- Create: `shared/lib/rarity.ts` +- Create: `shared/lib/rarity.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +// shared/lib/rarity.test.ts +import { describe, it, expect } from "bun:test"; +import { getRarityConfig, RARITY_CONFIG, defaultName } from "./rarity"; + +describe("getRarityConfig", () => { + it("returns correct config for known rarities", () => { + expect(getRarityConfig("SSR").color).toBe(0xF1C40F); + expect(getRarityConfig("SSR").emoji).toBe("๐ŸŒŸ"); + expect(getRarityConfig("SSR").label).toBe("SSR"); + }); + + it("returns correct config for loot types", () => { + expect(getRarityConfig("CURRENCY").color).toBe(0x2ECC71); + expect(getRarityConfig("XP").color).toBe(0x1ABC9C); + expect(getRarityConfig("NOTHING").color).toBe(0x636363); + }); + + it("falls back to Common for unknown rarity", () => { + const result = getRarityConfig("LEGENDARY"); + expect(result).toEqual(RARITY_CONFIG["C"]); + }); +}); + +describe("defaultName", () => { + it("extracts filename from path", () => { + expect(defaultName("/assets/items/sword.png")).toBe("sword.png"); + }); + + it("returns image.png for empty path", () => { + expect(defaultName("")).toBe("image.png"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test shared/lib/rarity.test.ts` +Expected: FAIL โ€” module not found + +- [ ] **Step 3: Write the implementation** + +```typescript +// shared/lib/rarity.ts + +export const RARITY_CONFIG: Record = { + C: { color: 0x95A5A6, emoji: "๐Ÿ“ฆ", label: "Common" }, + R: { color: 0x3498DB, emoji: "๐Ÿ“ฆ", label: "Rare" }, + SR: { color: 0x9B59B6, emoji: "โœจ", label: "Super Rare" }, + SSR: { color: 0xF1C40F, emoji: "๐ŸŒŸ", label: "SSR" }, + CURRENCY: { color: 0x2ECC71, emoji: "๐Ÿ’ฐ", label: "Currency" }, + XP: { color: 0x1ABC9C, emoji: "๐Ÿ”ฎ", label: "Experience" }, + NOTHING: { color: 0x636363, emoji: "๐Ÿ’จ", label: "Empty" }, +}; + +export function getRarityConfig(rarity: string): { color: number; emoji: string; label: string } { + return RARITY_CONFIG[rarity] ?? RARITY_CONFIG["C"]; +} + +export function defaultName(path: string): string { + return path.split("/").pop() || "image.png"; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test shared/lib/rarity.test.ts` +Expected: PASS โ€” all 4 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add shared/lib/rarity.ts shared/lib/rarity.test.ts +git commit -m "feat: add shared rarity config and helpers" +``` + +--- + +### Task 2: Update effect handler and pull result display (atomic change) + +These changes are done together because the effect handler return shape change and the view layer consumer update must stay in sync โ€” splitting them would leave a broken intermediate state. + +**Files:** +- Modify: `shared/modules/inventory/effect.handlers.ts:141-153` (handleLootbox ITEM result) +- Modify: `bot/modules/inventory/inventory.view.ts` (replace `getItemUseResultEmbed` with `getLootboxResultMessage`) +- Modify: `bot/commands/inventory/use.ts:6,60-62` (update import and reply call) + +- [ ] **Step 1: Update the ITEM result in `effect.handlers.ts` to return separate `iconUrl` and `imageUrl`** + +In `shared/modules/inventory/effect.handlers.ts`, find the ITEM result return block (lines 141-153). Change the `item` object: + +```typescript +// OLD (line 145-149) +item: { + name: item.name, + rarity: item.rarity, + description: item.description, + image: item.imageUrl || item.iconUrl +}, + +// NEW +item: { + name: item.name, + rarity: item.rarity, + description: item.description, + iconUrl: item.iconUrl, + imageUrl: item.imageUrl, +}, +``` + +- [ ] **Step 2: Rewrite `inventory.view.ts` โ€” replace `getItemUseResultEmbed` with `getLootboxResultMessage`** + +Replace the entire `getItemUseResultEmbed` function (lines 37-136) and the local `defaultName` helper (lines 138-140). Update the imports at the top of the file. Keep `getInventoryEmbed` (lines 23-32) unchanged. + +New imports (replace lines 1-6): +```typescript +import { + EmbedBuilder, + AttachmentBuilder, + ContainerBuilder, + SectionBuilder, + TextDisplayBuilder, + MediaGalleryBuilder, + MediaGalleryItemBuilder, + ThumbnailBuilder, + SeparatorBuilder, + SeparatorSpacingSize, + MessageFlags, +} from "discord.js"; +import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets"; +import { getRarityConfig, defaultName } from "@shared/lib/rarity"; +import { join } from "path"; +import { existsSync } from "fs"; +``` + +Note: `EmbedBuilder` is still needed because `getInventoryEmbed` uses it. Remove the `EffectType` import and the `ItemUsageData` type import since the new function doesn't use them. + +New function (replaces `getItemUseResultEmbed` and `defaultName`): + +```typescript +/** + * Creates a Components V2 message showing the result of opening a lootbox. + * Falls back to a simple embed for non-lootbox item usage. + */ +export function getLootboxResultMessage( + results: any[], + item?: { name: string; iconUrl: string | null; imageUrl: string | null; usageData: any } +) { + const files: AttachmentBuilder[] = []; + const otherMessages: string[] = []; + let lootResult: any = null; + + for (const res of results) { + if (typeof res === "object" && res.type === "LOOTBOX_RESULT") { + lootResult = res; + } else { + otherMessages.push(typeof res === "string" ? `โ€ข ${res}` : `โ€ข ${JSON.stringify(res)}`); + } + } + + // If no loot result, fall back to a simple embed (non-lootbox item usage) + if (!lootResult) { + const embed = new EmbedBuilder() + .setTitle(item ? `โœ… Used ${item.name}` : "โœ… Item Used!") + .setDescription(otherMessages.join("\n") || "Effect applied.") + .setColor(0x2ecc71) + .setTimestamp(); + return { embeds: [embed], files, components: undefined, flags: undefined }; + } + + // Determine rarity key for theming + let rarityKey = "C"; + if (lootResult.rewardType === "ITEM" && lootResult.item) { + rarityKey = lootResult.item.rarity || "C"; + } else if (lootResult.rewardType === "CURRENCY") { + rarityKey = "CURRENCY"; + } else if (lootResult.rewardType === "XP") { + rarityKey = "XP"; + } else { + rarityKey = "NOTHING"; + } + + const config = getRarityConfig(rarityKey); + const container = new ContainerBuilder().setAccentColor(config.color); + + // Header: lootbox name + if (item?.name) { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`-# Opened: ${item.name}`) + ); + } + + // Build title and description based on reward type + let title = ""; + let description = ""; + + if (lootResult.rewardType === "ITEM" && lootResult.item) { + const i = lootResult.item; + const amountStr = lootResult.amount > 1 ? ` ร—${lootResult.amount}` : ""; + title = `${config.emoji} ${config.label} โ€” ${i.name}${amountStr}`; + description = i.description || ""; + if (description) description += "\n"; + description += `\n**${config.label}** ยท ร—${lootResult.amount || 1} added to inventory`; + } else if (lootResult.rewardType === "CURRENCY") { + title = `${config.emoji} You found ${lootResult.amount.toLocaleString()} AU!`; + description = "Coins have been added to your balance."; + } else if (lootResult.rewardType === "XP") { + title = `${config.emoji} You gained ${lootResult.amount.toLocaleString()} XP!`; + description = "Experience has been added to your profile."; + } else { + title = `${config.emoji} Empty...`; + description = lootResult.message || "You found nothing inside."; + } + + // Main section with optional thumbnail + const section = new SectionBuilder().addTextDisplayComponents( + new TextDisplayBuilder().setContent(`# ${title}`), + new TextDisplayBuilder().setContent(description) + ); + + // Thumbnail from iconUrl (use reward item's icon for ITEM, lootbox icon otherwise) + let thumbnailUrl: string | null = null; + const iconSource = lootResult.rewardType === "ITEM" ? lootResult.item?.iconUrl : item?.iconUrl; + if (iconSource) { + if (isLocalAssetUrl(iconSource)) { + const iconPath = join(process.cwd(), "bot/assets/graphics", iconSource.replace(/^\/?assets\//, "")); + if (existsSync(iconPath)) { + const iconName = defaultName(iconSource); + files.push(new AttachmentBuilder(iconPath, { name: iconName })); + thumbnailUrl = `attachment://${iconName}`; + } + } else { + thumbnailUrl = resolveAssetUrl(iconSource); + } + } + + if (thumbnailUrl) { + section.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl)); + } + + container.addSectionComponents(section); + + // Media gallery for full item art (if imageUrl differs from iconUrl) + if (lootResult.rewardType === "ITEM" && lootResult.item) { + const imgSource = lootResult.item.imageUrl; + const iconSrc = lootResult.item.iconUrl; + if (imgSource && imgSource !== iconSrc) { + let displayImageUrl: string | null = null; + if (isLocalAssetUrl(imgSource)) { + const imagePath = join(process.cwd(), "bot/assets/graphics", imgSource.replace(/^\/?assets\//, "")); + if (existsSync(imagePath)) { + const imageName = defaultName(imgSource); + if (!files.find(f => f.name === imageName)) { + files.push(new AttachmentBuilder(imagePath, { name: imageName })); + } + displayImageUrl = `attachment://${imageName}`; + } + } else { + displayImageUrl = resolveAssetUrl(imgSource); + } + if (displayImageUrl) { + container.addMediaGalleryComponents( + new MediaGalleryBuilder().addItems( + new MediaGalleryItemBuilder().setURL(displayImageUrl) + ) + ); + } + } + } + + // Other effects (non-lootbox results like temp roles, XP boosts) + if (otherMessages.length > 0) { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`**Other Effects**\n${otherMessages.join("\n")}`) + ); + } + + return { + components: [container] as any, + files, + flags: MessageFlags.IsComponentsV2, + embeds: undefined, + }; +} +``` + +- [ ] **Step 3: Update `use.ts` to use the new function** + +In `bot/commands/inventory/use.ts`: + +Change the import (line 6): +```typescript +// OLD +import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; +// NEW +import { getLootboxResultMessage } from "@/modules/inventory/inventory.view"; +``` + +Change the reply (lines 60-62): +```typescript +// OLD +const { embed, files } = getItemUseResultEmbed(result.results, result.item); +await interaction.editReply({ embeds: [embed], files }); + +// NEW +const message = getLootboxResultMessage(result.results, result.item); +await interaction.editReply(message as any); +``` + +- [ ] **Step 4: Run the full test suite** + +Run: `bun test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add shared/modules/inventory/effect.handlers.ts bot/modules/inventory/inventory.view.ts bot/commands/inventory/use.ts +git commit -m "feat: rewrite lootbox pull results with Components V2 and separate icon/image URLs" +``` + +--- + +### Task 3: Rework shop loot table display + +**Files:** +- Modify: `bot/modules/economy/shop.view.ts` + +This task replaces the entire `getShopListingMessage` function body. The changes are: +1. Replace local `RarityColors`, `TitleMap`, and `defaultName` with shared imports +2. Split the loot table into a separate Container 2 with blurple accent +3. Group drops by rarity tier with aggregated percentages +4. Move purchase button conditionally (into loot table container for lootboxes, main container otherwise) + +- [ ] **Step 1: Update imports** + +At the top of `bot/modules/economy/shop.view.ts`: + +Remove the local `RarityColors` constant (lines 24-32) and `TitleMap` constant (lines 34-42). Remove the local `defaultName` function at the bottom (lines 206-208). + +Add import: +```typescript +import { getRarityConfig, defaultName } from "@shared/lib/rarity"; +``` + +Keep existing imports for Discord.js builders, `resolveAssetUrl`, `isLocalAssetUrl`, `LootType`, `EffectType`, and `LootTableItem`. + +- [ ] **Step 2: Rewrite the loot table section and purchase button placement** + +Replace the loot table block (lines 122-184) and purchase button block (lines 186-195) with the new two-container logic. The key change is: + +1. **Create `buyButton` before the conditional** (move lines 187-191 up, before line 122): +```typescript +const buyButton = new ButtonBuilder() + .setCustomId(`shop_buy_${item.id}`) + .setLabel(`Purchase for ${item.price} ๐Ÿช™`) + .setStyle(ButtonStyle.Success) + .setEmoji("๐Ÿ›’"); +``` + +2. **Replace lines 122-195** (old loot table + old unconditional button) with: +```typescript +if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) { + const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX); + const pool = lootboxEffect.pool as LootTableItem[]; + const totalWeight = pool.reduce((sum: number, i: LootTableItem) => sum + i.weight, 0); + + const lootContainer = new ContainerBuilder().setAccentColor(0x5865F2); + lootContainer.addTextDisplayComponents( + new TextDisplayBuilder().setContent("## ๐ŸŽ Loot Table") + ); + + // Group drops by rarity tier + const tiers: Record = {}; + + for (const drop of pool) { + const chance = (drop.weight / totalWeight) * 100; + let line = ""; + let rarity = "C"; + + switch (drop.type as any) { + case LootType.CURRENCY: { + const amt = (drop.minAmount != null && drop.maxAmount != null) + ? `${drop.minAmount} โ€“ ${drop.maxAmount}` + : (Array.isArray(drop.amount) ? `${drop.amount[0]} โ€“ ${drop.amount[1]}` : `${drop.amount || 0}`); + line = `${amt} ๐Ÿช™`; + rarity = "CURRENCY"; + break; + } + case LootType.XP: { + const amt = (drop.minAmount != null && drop.maxAmount != null) + ? `${drop.minAmount} โ€“ ${drop.maxAmount}` + : (Array.isArray(drop.amount) ? `${drop.amount[0]} โ€“ ${drop.amount[1]}` : `${drop.amount || 0}`); + line = `${amt} XP`; + rarity = "XP"; + break; + } + case LootType.ITEM: { + const referencedItems = context?.referencedItems; + if (drop.itemId && referencedItems?.has(drop.itemId)) { + const i = referencedItems.get(drop.itemId)!; + line = `${i.name} ร—${drop.amount || 1}`; + rarity = i.rarity; + } else { + line = `Unknown Item`; + rarity = "C"; + } + break; + } + case LootType.NOTHING: { + line = "Nothing"; + rarity = "NOTHING"; + break; + } + } + + if (line) { + if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 }; + tiers[rarity].items.push(line); + tiers[rarity].totalChance += chance; + } + } + + const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"]; + let isFirst = true; + for (const rarity of order) { + const tier = tiers[rarity]; + if (!tier || tier.items.length === 0) continue; + + if (!isFirst) { + lootContainer.addSeparatorComponents( + new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small) + ); + } + isFirst = false; + + const config = getRarityConfig(rarity); + const chanceStr = tier.totalChance.toFixed(1); + lootContainer.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `${config.emoji} **${config.label}** โ€” ${chanceStr}%` + ), + new TextDisplayBuilder().setContent(tier.items.join(", ")) + ); + } + + // Purchase button inside loot table container + lootContainer.addActionRowComponents( + new ActionRowBuilder().addComponents(buyButton) + ); + + containers.push(lootContainer); +} else { + // Non-lootbox items: purchase button stays in main container + mainContainer.addActionRowComponents( + new ActionRowBuilder().addComponents(buyButton) + ); +} +``` + +3. **Update the main container accent color** (line 92): replace `RarityColors[item.rarity || "C"]` with `getRarityConfig(item.rarity || "C").color`. + +- [ ] **Step 3: Run the full test suite** + +Run: `bun test` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add bot/modules/economy/shop.view.ts +git commit -m "feat: rework shop loot table into two-container Components V2 layout" +``` + +--- + +### Task 4: Manual verification checklist + +- [ ] **Step 1: Start the bot** + +Run: `bun --watch bot/index.ts` + +- [ ] **Step 2: Test pull results in Discord** + +Test each reward type by using a lootbox item: +- ITEM reward (verify accent color matches rarity, thumbnail shows, title format correct) +- CURRENCY reward (verify green accent, amount displayed) +- XP reward (verify aqua accent, amount displayed) +- NOTHING reward (verify gray accent, custom message shown) +- Item with both iconUrl and imageUrl (verify thumbnail + media gallery) +- Item without icon (no thumbnail, no crash) +- Lootbox with additional effects (verify "Other Effects" section appears) + +- [ ] **Step 3: Test shop listing in Discord** + +Use `/listing` command to post a lootbox item listing: +- Verify two containers appear (item info + loot table) +- Verify tiers are grouped with aggregated percentages +- Verify separators between tiers +- Verify purchase button is inside the loot table container +- Test with a non-lootbox item to verify purchase button stays in main container + +- [ ] **Step 4: Test edge cases** + +- Item without icon (no thumbnail, no crash) +- Item without image (no media gallery, no crash) +- Lootbox with only one tier +- Lootbox with all tiers populated