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

549 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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**
```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<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)
);
}
```
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