549 lines
20 KiB
Markdown
549 lines
20 KiB
Markdown
# 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
|