9 Commits

Author SHA1 Message Date
syntaxbullet
9e6bb8b148 fix: add non-null assertion for default rarity config fallback
All checks were successful
Deploy to Production / test (push) Successful in 35s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:15:53 +01:00
syntaxbullet
305a0b0553 fix: collapse double find() and add <0.1% guard for tiny drop rates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:01:05 +01:00
syntaxbullet
023ff9fb1b feat: rework shop loot table into two-container Components V2 layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 21:58:28 +01:00
syntaxbullet
56353a7756 fix: fix double newline in item description and add TODO comment on type cast
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:56:37 +01:00
syntaxbullet
86142cba6c feat: rewrite lootbox pull results with Components V2 and separate icon/image URLs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 21:51:28 +01:00
syntaxbullet
0517cd638c fix: add JSDoc header and null input test for rarity config 2026-03-18 21:49:10 +01:00
syntaxbullet
b8303a7e28 feat: add shared rarity config and helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 21:46:21 +01:00
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
syntaxbullet
8b9ab2cd29 docs: add lootbox UX overhaul design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:37:10 +01:00
9 changed files with 970 additions and 186 deletions

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ bot/assets/graphics/items
tickets/
.citrine.local
.worktrees/
.superpowers/

View File

@@ -3,7 +3,7 @@ import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
import { getGuildConfig } from "@shared/lib/config";
@@ -57,9 +57,8 @@ export const use = createCommand({
}
}
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed], files });
const message = getLootboxResultMessage(result.results, result.item);
await interaction.editReply(message as any);
}
);
},

View File

@@ -3,7 +3,6 @@ import {
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
Colors,
ContainerBuilder,
SectionBuilder,
TextDisplayBuilder,
@@ -19,27 +18,7 @@ import { join } from "path";
import { existsSync } from "fs";
import { LootType, EffectType } from "@shared/lib/constants";
import type { LootTableItem } from "@shared/lib/types";
// Rarity Color Map
const RarityColors: Record<string, number> = {
"C": Colors.LightGrey,
"R": Colors.Blue,
"SR": Colors.Purple,
"SSR": Colors.Gold,
"CURRENCY": Colors.Green,
"XP": Colors.Aqua,
"NOTHING": Colors.DarkButNotBlack
};
const TitleMap: Record<string, string> = {
"C": "📦 Common Items",
"R": "📦 Rare Items",
"SR": "✨ Super Rare Items",
"SSR": "🌟 SSR Items",
"CURRENCY": "💰 Currency",
"XP": "🔮 Experience",
"NOTHING": "💨 Empty"
};
import { getRarityConfig, defaultName } from "@shared/lib/rarity";
export function getShopListingMessage(
item: {
@@ -89,7 +68,7 @@ export function getShopListingMessage(
// 1. Main Container
const mainContainer = new ContainerBuilder()
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
.setAccentColor(getRarityConfig(item.rarity || "C").color);
// Header Section
const infoSection = new SectionBuilder()
@@ -119,82 +98,113 @@ export function getShopListingMessage(
);
}
// 2. Loot Table (if applicable)
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, i) => sum + i.weight, 0);
mainContainer.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
mainContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent("## 🎁 Potential Rewards"));
const groups: Record<string, string[]> = {};
for (const drop of pool) {
const chance = ((drop.weight / totalWeight) * 100).toFixed(1);
let line = "";
let rarity = "C";
switch (drop.type as any) {
case LootType.CURRENCY:
const currAmount = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} - ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
line = `**${currAmount} 🪙** (${chance}%)`;
rarity = "CURRENCY";
break;
case LootType.XP:
const xpAmount = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} - ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
line = `**${xpAmount} XP** (${chance}%)`;
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}** x${drop.amount || 1} (${chance}%)`;
rarity = i.rarity;
} else {
line = `**Unknown Item** (${chance}%)`;
rarity = "C";
}
break;
case LootType.NOTHING:
line = `**Nothing** (${chance}%)`;
rarity = "NOTHING";
break;
}
if (line) {
if (!groups[rarity]) groups[rarity] = [];
groups[rarity]!.push(line);
}
}
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
for (const rarity of order) {
if (groups[rarity] && groups[rarity]!.length > 0) {
mainContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`### ${TitleMap[rarity] || rarity}`),
new TextDisplayBuilder().setContent(groups[rarity]!.join("\n"))
);
}
}
}
// Purchase Row
// Create buy button (used in either main or loot container)
const buyButton = new ButtonBuilder()
.setCustomId(`shop_buy_${item.id}`)
.setLabel(`Purchase for ${item.price} 🪙`)
.setStyle(ButtonStyle.Success)
.setEmoji("🛒");
mainContainer.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
);
// 2. Loot Table (if applicable) — separate Container with blurple accent
const lootboxEffect = item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
if (lootboxEffect) {
const pool = lootboxEffect.pool as LootTableItem[];
const totalWeight = pool.reduce((sum: number, i: LootTableItem) => sum + i.weight, 0);
containers.push(mainContainer);
const lootContainer = new ContainerBuilder().setAccentColor(0x5865F2);
lootContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 🎁 Loot Table")
);
// Group drops by rarity tier with aggregated percentages
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 < 0.1 ? "<0.1" : 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(mainContainer);
containers.push(lootContainer);
} else {
// Non-lootbox items: purchase button stays in main container
mainContainer.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
);
containers.push(mainContainer);
}
return {
components: containers as any,
@@ -202,7 +212,3 @@ export function getShopListingMessage(
flags: MessageFlags.IsComponentsV2
};
}
function defaultName(path: string): string {
return path.split("/").pop() || "image.png";
}

View File

@@ -1,7 +1,18 @@
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
import type { ItemUsageData } from "@shared/lib/types";
import { EffectType } from "@shared/lib/constants";
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";
@@ -32,109 +43,147 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
}
/**
* Creates an embed showing the results of using an item
* 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 getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
const embed = new EmbedBuilder();
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') {
if (typeof res === "object" && res.type === "LOOTBOX_RESULT") {
lootResult = res;
} else {
otherMessages.push(typeof res === 'string' ? `${res}` : `${JSON.stringify(res)}`);
otherMessages.push(typeof res === "string" ? `${res}` : `${JSON.stringify(res)}`);
}
}
// Default Configuration
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default
embed.setTimestamp();
if (lootResult) {
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`);
if (lootResult.rewardType === 'ITEM' && lootResult.item) {
const i = lootResult.item;
const amountStr = lootResult.amount > 1 ? `x${lootResult.amount}` : '';
// Rarity Colors
const rarityColors: Record<string, number> = {
'C': 0x95A5A6, // Gray
'R': 0x3498DB, // Blue
'SR': 0x9B59B6, // Purple
'SSR': 0xF1C40F // Gold
};
const rarityKey = i.rarity || 'C';
if (rarityKey in rarityColors) {
embed.setColor(rarityColors[rarityKey] ?? 0x95A5A6);
} else {
embed.setColor(0x95A5A6);
}
if (i.image) {
if (isLocalAssetUrl(i.image)) {
const imagePath = join(process.cwd(), "bot/assets/graphics", i.image.replace(/^\/?assets\//, ""));
if (existsSync(imagePath)) {
const imageName = defaultName(i.image);
if (!files.find(f => f.name === imageName)) {
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
}
embed.setImage(`attachment://${imageName}`);
}
} else {
const imgUrl = resolveAssetUrl(i.image);
if (imgUrl) embed.setImage(imgUrl);
}
}
embed.setDescription(`**You found ${i.name} ${amountStr}!**\n${i.description || '_'}`);
embed.addFields({ name: 'Rarity', value: rarityKey, inline: true });
} else if (lootResult.rewardType === 'CURRENCY') {
embed.setColor(0xF1C40F);
embed.setDescription(`**You found ${lootResult.amount.toLocaleString()} 🪙 AU!**`);
} else if (lootResult.rewardType === 'XP') {
embed.setColor(0x2ECC71); // Green
embed.setDescription(`**You gained ${lootResult.amount.toLocaleString()} XP!**`);
} else {
// Nothing or Message
embed.setDescription(lootResult.message);
embed.setColor(0x95A5A6); // Gray
}
// 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 {
// Standard item usage
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
rarityKey = "NOTHING";
}
if (isLootbox && item && item.iconUrl) {
if (isLocalAssetUrl(item.iconUrl)) {
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
if (existsSync(iconPath)) {
const iconName = defaultName(item.iconUrl);
if (!files.find(f => f.name === iconName)) {
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
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 || "";
description += (description ? "\n\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 }));
}
embed.setThumbnail(`attachment://${iconName}`);
displayImageUrl = `attachment://${imageName}`;
}
} else {
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
displayImageUrl = resolveAssetUrl(imgSource);
}
if (displayImageUrl) {
container.addMediaGalleryComponents(
new MediaGalleryBuilder().addItems(
new MediaGalleryItemBuilder().setURL(displayImageUrl)
)
);
}
}
}
if (otherMessages.length > 0 && lootResult) {
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
// 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 { embed, files };
}
function defaultName(path: string): string {
return path.split("/").pop() || "image.png";
return {
// TODO: remove cast once discord.js types include ContainerBuilder in MessageEditOptions
components: [container] as any,
files,
flags: MessageFlags.IsComponentsV2,
embeds: undefined,
};
}

View File

@@ -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<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

View File

@@ -0,0 +1,122 @@
# Lootbox UX Overhaul
**Date:** 2026-03-18
**Status:** Approved
## Problem
The current lootbox system has three UX issues:
1. **Pull results are visually flat** — a basic embed with plain text like "You found X!" with no visual differentiation between rarities.
2. **Shop loot table formatting is poor** — rewards are dumped as flat text lines grouped by rarity, with no visual hierarchy or scannability.
3. **No personality** — opening a lootbox feels like a database query response, not an event.
## Approach
**Full Components V2** — both pull results and shop loot tables use Discord's Components V2 system (containers, sections, media galleries, accent colors). No canvas image generation. Keeps the rendering approach consistent, simpler to build and maintain.
**Instant reveal** — no two-phase animations or button-driven reveals. The result appears immediately; excitement comes from visual quality and rarity theming.
**Loot table stays in shop only** — not shown in inventory or alongside pull results.
## Design: Pull Result
When a user opens a lootbox, the result is displayed as a Components V2 message (`flags: MessageFlags.IsComponentsV2`) with:
### Container
- **Accent color** driven by reward rarity:
- `C` (Common): `#95A5A6` (gray)
- `R` (Rare): `#3498DB` (blue)
- `SR` (Super Rare): `#9B59B6` (purple)
- `SSR`: `#F1C40F` (gold)
- `CURRENCY`: `#2ECC71` (green)
- `XP`: `#1ABC9C` (aqua)
- `NOTHING`: `#636363` (dark gray)
### Header
- Subtle context line: source lootbox name (e.g., "Opened: Astral Crate")
### Section (main content)
- **Title format (item rewards):** `🌟 SSR — Celestial Blade` (emoji + rarity + item name)
- **Title format (currency):** `💰 You found 1,250 AU!`
- **Title format (XP):** `🔮 You gained 500 XP!`
- **Title format (nothing):** `💨 Empty...`
- **Description:** Item description for items, contextual message for currency/XP/nothing. For NOTHING results, use the custom `lootResult.message` from the handler (falls back to "You found nothing inside.")
- **Rarity badge:** Shown as text below description for item rewards (e.g., "SSR" + "×1 added to inventory")
- **Thumbnail accessory:** Item icon (via `iconUrl`) when available
### Media Gallery
- If the item has an `imageUrl` different from `iconUrl`, display it in a media gallery below the section for full art showcase.
### Other Effects
- If the lootbox item has non-lootbox effects that also produce results (e.g., a lootbox that also grants XP or a temp role), display these as an additional text display below the main result: "**Other Effects**\n• Gained 100 XP\n• Temporary Role granted for 30m"
### Edge Cases
- **Unknown rarity:** If a reward item's rarity is not in `RARITY_CONFIG`, fall back to Common (`C`) styling.
- **Missing icon:** If no `iconUrl` is available, omit the thumbnail accessory entirely (section without accessory).
- **Missing image:** If no `imageUrl` is available (or same as `iconUrl`), omit the media gallery.
## Design: Shop Loot Table
When viewing a lootbox item in the shop, the listing uses two containers:
### Container 1: Item Info
- **Accent color:** Based on lootbox item's own rarity
- **Section:** Item name (heading), description, price
- **Thumbnail accessory:** Item icon
- **Media gallery:** Item image if different from icon
### Container 2: Loot Table + Purchase
- **Accent color:** Discord blurple (`#5865F2`)
- **Header:** `🎁 Loot Table`
- **Tiers listed in descending rarity order:** SSR → SR → R → C → Currency → XP → Nothing
- **Each tier shows:**
- Tier header: emoji + rarity label + aggregated chance percentage (sum of all items in that tier)
- Items listed inline, comma-separated (e.g., "Shadow Dagger ×1, Arcane Focus ×1")
- **Separators** between tiers for visual scannability
- **Tiers with no items are omitted**
- **Purchase button:** Action row inside this container with "🛒 Purchase for {price} 🪙" button (success style)
## Files to Modify
| File | Change |
|------|--------|
| `bot/modules/inventory/inventory.view.ts` | Replace `getItemUseResultEmbed()` with new Components V2 pull result builder |
| `bot/modules/economy/shop.view.ts` | Rework `getShopListingMessage()` loot table section into two-container layout |
| `bot/commands/inventory/use.ts` | Update to send Components V2 message with `flags: MessageFlags.IsComponentsV2` instead of embed |
| `shared/modules/inventory/effect.handlers.ts` | Modify `handleLootbox` ITEM result to return both `iconUrl` and `imageUrl` separately (currently collapses into single `image` field) |
## Shared Constants
The rarity color map and title/emoji map are currently duplicated between `shop.view.ts` and `inventory.view.ts`. Consolidate into a shared location (either a new `shared/lib/rarity.ts` or add to existing `shared/lib/constants.ts`).
Also consolidate the `defaultName` helper (duplicated in both view files) into a shared utility.
Rarity display config:
```typescript
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" },
};
```
## Out of Scope
- Loot table visibility in inventory or pull results
- Canvas-based image generation for pulls
- Two-phase or button-driven reveal mechanics
- Lootdrop system changes (channel activity drops are separate)
## Testing
- Existing lootbox tests should continue to pass (effect handler return shape changes are additive)
- Manual testing needed for visual output in Discord (Components V2 rendering)
- Verify all reward types render correctly: ITEM (all rarities), CURRENCY, XP, NOTHING
- Verify shop listing renders cleanly with various loot table sizes (1 tier, all tiers, many items per tier)
- Verify "other effects" display when lootbox item has multiple effect types
- Verify fallback behavior for items with unknown rarity, missing icons, missing images

36
shared/lib/rarity.test.ts Normal file
View File

@@ -0,0 +1,36 @@
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"]);
});
it("falls back to Common for null/undefined input", () => {
expect(getRarityConfig(null as any)).toEqual(RARITY_CONFIG["C"]);
expect(getRarityConfig(undefined as any)).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");
});
});

22
shared/lib/rarity.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* 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<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";
}

View File

@@ -146,7 +146,8 @@ export const handleLootbox: EffectHandler = async (userId, effect: Extract<Valid
name: item.name,
rarity: item.rarity,
description: item.description,
image: item.imageUrl || item.iconUrl
iconUrl: item.iconUrl,
imageUrl: item.imageUrl,
},
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
};