Compare commits
9 Commits
5d832c9601
...
9e6bb8b148
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6bb8b148 | ||
|
|
305a0b0553 | ||
|
|
023ff9fb1b | ||
|
|
56353a7756 | ||
|
|
86142cba6c | ||
|
|
0517cd638c | ||
|
|
b8303a7e28 | ||
|
|
d259c0c6a6 | ||
|
|
8b9ab2cd29 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -51,3 +51,4 @@ bot/assets/graphics/items
|
|||||||
tickets/
|
tickets/
|
||||||
.citrine.local
|
.citrine.local
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SlashCommandBuilder } from "discord.js";
|
|||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
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 { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
import { getGuildConfig } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
@@ -57,9 +57,8 @@ export const use = createCommand({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
const message = getLootboxResultMessage(result.results, result.item);
|
||||||
|
await interaction.editReply(message as any);
|
||||||
await interaction.editReply({ embeds: [embed], files });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
ButtonBuilder,
|
ButtonBuilder,
|
||||||
ButtonStyle,
|
ButtonStyle,
|
||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
Colors,
|
|
||||||
ContainerBuilder,
|
ContainerBuilder,
|
||||||
SectionBuilder,
|
SectionBuilder,
|
||||||
TextDisplayBuilder,
|
TextDisplayBuilder,
|
||||||
@@ -19,27 +18,7 @@ import { join } from "path";
|
|||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { LootType, EffectType } from "@shared/lib/constants";
|
import { LootType, EffectType } from "@shared/lib/constants";
|
||||||
import type { LootTableItem } from "@shared/lib/types";
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
|
import { getRarityConfig, defaultName } from "@shared/lib/rarity";
|
||||||
// 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"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getShopListingMessage(
|
export function getShopListingMessage(
|
||||||
item: {
|
item: {
|
||||||
@@ -89,7 +68,7 @@ export function getShopListingMessage(
|
|||||||
|
|
||||||
// 1. Main Container
|
// 1. Main Container
|
||||||
const mainContainer = new ContainerBuilder()
|
const mainContainer = new ContainerBuilder()
|
||||||
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
|
.setAccentColor(getRarityConfig(item.rarity || "C").color);
|
||||||
|
|
||||||
// Header Section
|
// Header Section
|
||||||
const infoSection = new SectionBuilder()
|
const infoSection = new SectionBuilder()
|
||||||
@@ -119,82 +98,113 @@ export function getShopListingMessage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Loot Table (if applicable)
|
// Create buy button (used in either main or loot container)
|
||||||
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
|
|
||||||
const buyButton = new ButtonBuilder()
|
const buyButton = new ButtonBuilder()
|
||||||
.setCustomId(`shop_buy_${item.id}`)
|
.setCustomId(`shop_buy_${item.id}`)
|
||||||
.setLabel(`Purchase for ${item.price} 🪙`)
|
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
.setEmoji("🛒");
|
.setEmoji("🛒");
|
||||||
|
|
||||||
mainContainer.addActionRowComponents(
|
// 2. Loot Table (if applicable) — separate Container with blurple accent
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
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 {
|
return {
|
||||||
components: containers as any,
|
components: containers as any,
|
||||||
@@ -202,7 +212,3 @@ export function getShopListingMessage(
|
|||||||
flags: MessageFlags.IsComponentsV2
|
flags: MessageFlags.IsComponentsV2
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultName(path: string): string {
|
|
||||||
return path.split("/").pop() || "image.png";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
|
import {
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
EmbedBuilder,
|
||||||
import { EffectType } from "@shared/lib/constants";
|
AttachmentBuilder,
|
||||||
|
ContainerBuilder,
|
||||||
|
SectionBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
MediaGalleryBuilder,
|
||||||
|
MediaGalleryItemBuilder,
|
||||||
|
ThumbnailBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
MessageFlags,
|
||||||
|
} from "discord.js";
|
||||||
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||||
|
import { getRarityConfig, defaultName } from "@shared/lib/rarity";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { existsSync } from "fs";
|
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[] } {
|
export function getLootboxResultMessage(
|
||||||
const embed = new EmbedBuilder();
|
results: any[],
|
||||||
|
item?: { name: string; iconUrl: string | null; imageUrl: string | null; usageData: any }
|
||||||
|
) {
|
||||||
const files: AttachmentBuilder[] = [];
|
const files: AttachmentBuilder[] = [];
|
||||||
const otherMessages: string[] = [];
|
const otherMessages: string[] = [];
|
||||||
let lootResult: any = null;
|
let lootResult: any = null;
|
||||||
|
|
||||||
for (const res of results) {
|
for (const res of results) {
|
||||||
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
|
if (typeof res === "object" && res.type === "LOOTBOX_RESULT") {
|
||||||
lootResult = res;
|
lootResult = res;
|
||||||
} else {
|
} else {
|
||||||
otherMessages.push(typeof res === 'string' ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
otherMessages.push(typeof res === "string" ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default Configuration
|
// If no loot result, fall back to a simple embed (non-lootbox item usage)
|
||||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
if (!lootResult) {
|
||||||
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default
|
const embed = new EmbedBuilder()
|
||||||
embed.setTimestamp();
|
.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!")
|
||||||
|
.setDescription(otherMessages.join("\n") || "Effect applied.")
|
||||||
if (lootResult) {
|
.setColor(0x2ecc71)
|
||||||
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`);
|
.setTimestamp();
|
||||||
|
return { embeds: [embed], files, components: undefined, flags: undefined };
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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 {
|
} else {
|
||||||
// Standard item usage
|
rarityKey = "NOTHING";
|
||||||
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
}
|
||||||
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
|
|
||||||
|
|
||||||
if (isLootbox && item && item.iconUrl) {
|
const config = getRarityConfig(rarityKey);
|
||||||
if (isLocalAssetUrl(item.iconUrl)) {
|
const container = new ContainerBuilder().setAccentColor(config.color);
|
||||||
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
|
||||||
if (existsSync(iconPath)) {
|
// Header: lootbox name
|
||||||
const iconName = defaultName(item.iconUrl);
|
if (item?.name) {
|
||||||
if (!files.find(f => f.name === iconName)) {
|
container.addTextDisplayComponents(
|
||||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
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 {
|
} else {
|
||||||
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
|
displayImageUrl = resolveAssetUrl(imgSource);
|
||||||
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
|
}
|
||||||
|
if (displayImageUrl) {
|
||||||
|
container.addMediaGalleryComponents(
|
||||||
|
new MediaGalleryBuilder().addItems(
|
||||||
|
new MediaGalleryItemBuilder().setURL(displayImageUrl)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otherMessages.length > 0 && lootResult) {
|
// Other effects (non-lootbox results like temp roles, XP boosts)
|
||||||
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
|
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 };
|
return {
|
||||||
}
|
// TODO: remove cast once discord.js types include ContainerBuilder in MessageEditOptions
|
||||||
|
components: [container] as any,
|
||||||
function defaultName(path: string): string {
|
files,
|
||||||
return path.split("/").pop() || "image.png";
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
embeds: undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
548
docs/superpowers/plans/2026-03-18-lootbox-ux-overhaul.md
Normal file
548
docs/superpowers/plans/2026-03-18-lootbox-ux-overhaul.md
Normal 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
|
||||||
122
docs/superpowers/specs/2026-03-18-lootbox-ux-overhaul-design.md
Normal file
122
docs/superpowers/specs/2026-03-18-lootbox-ux-overhaul-design.md
Normal 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
36
shared/lib/rarity.test.ts
Normal 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
22
shared/lib/rarity.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -146,7 +146,8 @@ export const handleLootbox: EffectHandler = async (userId, effect: Extract<Valid
|
|||||||
name: item.name,
|
name: item.name,
|
||||||
rarity: item.rarity,
|
rarity: item.rarity,
|
||||||
description: item.description,
|
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}**!`
|
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user