Files
aurorabot/docs/superpowers/plans/2026-03-18-lootbox-ux-overhaul.md
syntaxbullet d259c0c6a6 docs: add lootbox UX overhaul implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:44:17 +01:00

20 KiB
Raw Blame History

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

// 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
// shared/lib/rarity.ts

export const RARITY_CONFIG: Record<string, { color: number; emoji: string; label: string }> = {
  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
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:

// 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):

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

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

// OLD
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
// NEW
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";

Change the reply (lines 60-62):

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

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):
const buyButton = new ButtonBuilder()
    .setCustomId(`shop_buy_${item.id}`)
    .setLabel(`Purchase for ${item.price} 🪙`)
    .setStyle(ButtonStyle.Success)
    .setEmoji("🛒");
  1. Replace lines 122-195 (old loot table + old unconditional button) with:
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<string, { items: string[]; totalChance: number }> = {};

    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<ButtonBuilder>().addComponents(buyButton)
    );

    containers.push(lootContainer);
} else {
    // Non-lootbox items: purchase button stays in main container
    mainContainer.addActionRowComponents(
        new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
    );
}
  1. 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
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