From 5e8683a19f18044671c2c81b9be015ed9ffc54ae Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sun, 8 Feb 2026 16:07:13 +0100 Subject: [PATCH] feat: Implement structured lootbox results with image support and display referenced items in shop listings. --- .gitignore | 3 +- bot/commands/admin/listing.ts | 40 +++- bot/commands/inventory/use.ts | 4 +- bot/modules/economy/shop.view.ts | 177 ++++++++++++++++-- bot/modules/inventory/effects/handlers.ts | 33 +++- bot/modules/inventory/effects/types.ts | 2 +- bot/modules/inventory/inventory.view.ts | 122 ++++++++++-- shared/modules/inventory/inventory.service.ts | 2 +- web/src/components/loot-table-builder.tsx | 40 ++-- 9 files changed, 359 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index 0ac8ac8..97c7c14 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json src/db/data src/db/log scratchpad/ -tickets/ \ No newline at end of file +tickets/ +bot/assets/graphics/items diff --git a/bot/commands/admin/listing.ts b/bot/commands/admin/listing.ts index 7468e18..dda9567 100644 --- a/bot/commands/admin/listing.ts +++ b/bot/commands/admin/listing.ts @@ -1,20 +1,18 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, type BaseGuildTextChannel, PermissionFlagsBits, MessageFlags } from "discord.js"; import { inventoryService } from "@shared/modules/inventory/inventory.service"; -import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds"; +import { createErrorEmbed } from "@lib/embeds"; import { UserError } from "@shared/lib/errors"; import { items } from "@db/schema"; -import { ilike, isNotNull, and } from "drizzle-orm"; +import { ilike, isNotNull, and, inArray } from "drizzle-orm"; import { DrizzleClient } from "@shared/db/DrizzleClient"; import { getShopListingMessage } from "@/modules/economy/shop.view"; +import { EffectType, LootType } from "@shared/lib/constants"; export const listing = createCommand({ data: new SlashCommandBuilder() @@ -54,14 +52,42 @@ export const listing = createCommand({ return; } + // Prepare context for lootboxes + const context: { referencedItems: Map } = { referencedItems: new Map() }; + + const usageData = item.usageData as any; + const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX); + + if (lootboxEffect && lootboxEffect.pool) { + const itemIds = lootboxEffect.pool + .filter((drop: any) => drop.type === LootType.ITEM && drop.itemId) + .map((drop: any) => drop.itemId); + + if (itemIds.length > 0) { + // Remove duplicates + const uniqueIds = [...new Set(itemIds)] as number[]; + + const referencedItems = await DrizzleClient.select({ + id: items.id, + name: items.name, + rarity: items.rarity + }).from(items).where(inArray(items.id, uniqueIds)); + + for (const ref of referencedItems) { + context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' }); + } + } + } + const listingMessage = getShopListingMessage({ ...item, + rarity: item.rarity || undefined, formattedPrice: `${item.price} 🪙`, price: item.price - }); + }, context); try { - await targetChannel.send(listingMessage); + await targetChannel.send(listingMessage as any); await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` }); } catch (error: any) { if (error instanceof UserError) { diff --git a/bot/commands/inventory/use.ts b/bot/commands/inventory/use.ts index d425395..61dbac5 100644 --- a/bot/commands/inventory/use.ts +++ b/bot/commands/inventory/use.ts @@ -55,9 +55,9 @@ export const use = createCommand({ } } - const embed = getItemUseResultEmbed(result.results, result.item); + const { embed, files } = getItemUseResultEmbed(result.results, result.item); - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed], files }); } catch (error: any) { if (error instanceof UserError) { diff --git a/bot/modules/economy/shop.view.ts b/bot/modules/economy/shop.view.ts index f5e74af..dd5d4f6 100644 --- a/bot/modules/economy/shop.view.ts +++ b/bot/modules/economy/shop.view.ts @@ -1,10 +1,60 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder } from "discord.js"; -import { createBaseEmbed } from "@/lib/embeds"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + AttachmentBuilder, + Colors, + ContainerBuilder, + SectionBuilder, + TextDisplayBuilder, + MediaGalleryBuilder, + MediaGalleryItemBuilder, + ThumbnailBuilder, + SeparatorBuilder, + SeparatorSpacingSize, + MessageFlags +} from "discord.js"; import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets"; import { join } from "path"; import { existsSync } from "fs"; +import { LootType, EffectType } from "@shared/lib/constants"; +import type { LootTableItem } from "@shared/lib/types"; -export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) { +// Rarity Color Map +const RarityColors: Record = { + "C": Colors.LightGrey, + "R": Colors.Blue, + "SR": Colors.Purple, + "SSR": Colors.Gold, + "CURRENCY": Colors.Green, + "XP": Colors.Aqua, + "NOTHING": Colors.DarkButNotBlack +}; + +const TitleMap: Record = { + "C": "📦 Common Items", + "R": "📦 Rare Items", + "SR": "✨ Super Rare Items", + "SSR": "🌟 SSR Items", + "CURRENCY": "💰 Currency", + "XP": "🔮 Experience", + "NOTHING": "💨 Empty" +}; + +export function getShopListingMessage( + item: { + id: number; + name: string; + description: string | null; + formattedPrice: string; + iconUrl: string | null; + imageUrl: string | null; + price: number | bigint; + usageData?: any; + rarity?: string; + }, + context?: { referencedItems: Map } +) { const files: AttachmentBuilder[] = []; let thumbnailUrl = resolveAssetUrl(item.iconUrl); let displayImageUrl = resolveAssetUrl(item.imageUrl); @@ -19,16 +69,14 @@ export function getShopListingMessage(item: { id: number; name: string; descript } } - // Handle local image (avoid duplicate attachments if same as icon) + // Handle local image if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) { - // If image is same as icon, just use the same attachment reference if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) { displayImageUrl = thumbnailUrl; } else { const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, "")); if (existsSync(imagePath)) { const imageName = defaultName(item.imageUrl); - // Check if we already attached this file (by name) if (!files.find(f => f.name === imageName)) { files.push(new AttachmentBuilder(imagePath, { name: imageName })); } @@ -37,21 +85,122 @@ export function getShopListingMessage(item: { id: number; name: string; descript } } - const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green") - .addFields({ name: "Price", value: item.formattedPrice, inline: true }) - .setThumbnail(thumbnailUrl) - .setImage(displayImageUrl) - .setFooter({ text: "Click the button below to purchase instantly." }); + const containers: ContainerBuilder[] = []; + // 1. Main Container + const mainContainer = new ContainerBuilder() + .setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green); + + // Header Section + const infoSection = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`# ${item.name}`), + new TextDisplayBuilder().setContent(item.description || "_No description available._"), + new TextDisplayBuilder().setContent(`### 🏷️ Price: ${item.formattedPrice}`) + ); + + // Set Thumbnail Accessory if we have an icon + if (thumbnailUrl) { + infoSection.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl)); + } + + mainContainer.addSectionComponents(infoSection); + + // Media Gallery for additional images (if multiple) + const mediaSources: string[] = []; + if (thumbnailUrl) mediaSources.push(thumbnailUrl); + if (displayImageUrl && displayImageUrl !== thumbnailUrl) mediaSources.push(displayImageUrl); + + if (mediaSources.length > 1) { + mainContainer.addMediaGalleryComponents( + new MediaGalleryBuilder().addItems( + ...mediaSources.map(src => new MediaGalleryItemBuilder().setURL(src)) + ) + ); + } + + // 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 = {}; + 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() .setCustomId(`shop_buy_${item.id}`) - .setLabel(`Buy for ${item.price} 🪙`) + .setLabel(`Purchase for ${item.price} 🪙`) .setStyle(ButtonStyle.Success) .setEmoji("🛒"); - const row = new ActionRowBuilder().addComponents(buyButton); + mainContainer.addActionRowComponents( + new ActionRowBuilder().addComponents(buyButton) + ); - return { embeds: [embed], components: [row], files }; + containers.push(mainContainer); + + return { + components: containers as any, + files, + flags: MessageFlags.IsComponentsV2 + }; } function defaultName(path: string): string { diff --git a/bot/modules/inventory/effects/handlers.ts b/bot/modules/inventory/effects/handlers.ts index b06f5b6..ba4aece 100644 --- a/bot/modules/inventory/effects/handlers.ts +++ b/bot/modules/inventory/effects/handlers.ts @@ -86,7 +86,11 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => { // Process Winner if (winner.type === LootType.NOTHING) { - return winner.message || "You found nothing inside."; + return { + type: 'LOOTBOX_RESULT', + rewardType: 'NOTHING', + message: winner.message || "You found nothing inside." + }; } if (winner.type === LootType.CURRENCY) { @@ -96,7 +100,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => { } if (amount > 0) { await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn); - return winner.message || `You found ${amount} 🪙!`; + return { + type: 'LOOTBOX_RESULT', + rewardType: 'CURRENCY', + amount: amount, + message: winner.message || `You found ${amount} 🪙!` + }; } } @@ -107,7 +116,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => { } if (amount > 0) { await levelingService.addXp(userId, BigInt(amount), txFn); - return winner.message || `You gained ${amount} XP!`; + return { + type: 'LOOTBOX_RESULT', + rewardType: 'XP', + amount: amount, + message: winner.message || `You gained ${amount} XP!` + }; } } @@ -123,7 +137,18 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => { where: (items: any, { eq }: any) => eq(items.id, winner.itemId!) }); if (item) { - return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`; + return { + type: 'LOOTBOX_RESULT', + rewardType: 'ITEM', + amount: Number(quantity), + item: { + name: item.name, + rarity: item.rarity, + description: item.description, + image: item.imageUrl || item.iconUrl + }, + message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!` + }; } } catch (e) { console.error("Failed to fetch item name for lootbox message", e); diff --git a/bot/modules/inventory/effects/types.ts b/bot/modules/inventory/effects/types.ts index 98b6c60..046cde5 100644 --- a/bot/modules/inventory/effects/types.ts +++ b/bot/modules/inventory/effects/types.ts @@ -1,4 +1,4 @@ import type { Transaction } from "@shared/lib/types"; -export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise; +export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise; diff --git a/bot/modules/inventory/inventory.view.ts b/bot/modules/inventory/inventory.view.ts index 4a1adff..bdb9f24 100644 --- a/bot/modules/inventory/inventory.view.ts +++ b/bot/modules/inventory/inventory.view.ts @@ -1,7 +1,9 @@ -import { EmbedBuilder } from "discord.js"; +import { EmbedBuilder, AttachmentBuilder } from "discord.js"; import type { ItemUsageData } from "@shared/lib/types"; import { EffectType } from "@shared/lib/constants"; -import { resolveAssetUrl } from "@shared/lib/assets"; +import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets"; +import { join } from "path"; +import { existsSync } from "fs"; /** * Inventory entry with item details @@ -32,25 +34,107 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em /** * Creates an embed showing the results of using an item */ -export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder { - const description = results.map(r => `• ${r}`).join("\n"); +export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } { + const embed = new EmbedBuilder(); + const files: AttachmentBuilder[] = []; + const otherMessages: string[] = []; + let lootResult: any = null; - // Check if it was a lootbox - const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX); - - const embed = new EmbedBuilder() - .setDescription(description) - .setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise - - if (isLootbox && item) { - embed.setTitle(`🎁 ${item.name} Opened!`); - const resolvedIconUrl = resolveAssetUrl(item.iconUrl); - if (resolvedIconUrl) { - embed.setThumbnail(resolvedIconUrl); + 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)}`); } - } else { - embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!"); } - return embed; + // 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 = { + '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 + } + + } else { + // Standard item usage + embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!"); + embed.setDescription(otherMessages.join("\n") || "Effect applied."); + + 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 })); + } + embed.setThumbnail(`attachment://${iconName}`); + } + } else { + const resolvedIconUrl = resolveAssetUrl(item.iconUrl); + if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl); + } + } + } + + if (otherMessages.length > 0 && lootResult) { + embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") }); + } + + return { embed, files }; +} + +function defaultName(path: string): string { + return path.split("/").pop() || "image.png"; } diff --git a/shared/modules/inventory/inventory.service.ts b/shared/modules/inventory/inventory.service.ts index 167b1e7..ba3d437 100644 --- a/shared/modules/inventory/inventory.service.ts +++ b/shared/modules/inventory/inventory.service.ts @@ -168,7 +168,7 @@ export const inventoryService = { throw new UserError("This item cannot be used."); } - const results: string[] = []; + const results: any[] = []; // 2. Apply Effects const { effectHandlers } = await import("@/modules/inventory/effects/registry"); diff --git a/web/src/components/loot-table-builder.tsx b/web/src/components/loot-table-builder.tsx index 7d74c0a..689db0c 100644 --- a/web/src/components/loot-table-builder.tsx +++ b/web/src/components/loot-table-builder.tsx @@ -20,13 +20,15 @@ import { Plus, Trash2, Package, Coins, Sparkles, GripVertical, Percent } from "l import { cn } from "@/lib/utils"; // Loot drop types -type LootType = "ITEM" | "BALANCE" | "XP"; +type LootType = "ITEM" | "CURRENCY" | "XP"; interface LootDrop { type: LootType; itemId?: number; itemName?: string; - amount?: number | [number, number]; // Single value or [min, max] + amount?: number; + minAmount?: number; + maxAmount?: number; weight: number; } @@ -37,7 +39,7 @@ interface LootTableBuilderProps { const LOOT_TYPES = [ { value: "ITEM" as LootType, label: "Item", icon: Package, color: "text-purple-400" }, - { value: "BALANCE" as LootType, label: "Balance", icon: Coins, color: "text-amber-400" }, + { value: "CURRENCY" as LootType, label: "Currency", icon: Coins, color: "text-amber-400" }, { value: "XP" as LootType, label: "XP", icon: Sparkles, color: "text-blue-400" }, ]; @@ -45,10 +47,10 @@ const getDefaultDrop = (type: LootType): LootDrop => { switch (type) { case "ITEM": return { type: "ITEM", itemId: 0, itemName: "", weight: 10 }; - case "BALANCE": - return { type: "BALANCE", amount: [100, 500], weight: 30 }; + case "CURRENCY": + return { type: "CURRENCY", minAmount: 100, maxAmount: 500, weight: 30 }; case "XP": - return { type: "XP", amount: [50, 200], weight: 20 }; + return { type: "XP", minAmount: 50, maxAmount: 200, weight: 20 }; } }; @@ -57,7 +59,7 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) { const totalWeight = pool.reduce((sum, drop) => sum + drop.weight, 0); const addDrop = useCallback(() => { - onChange([...pool, getDefaultDrop("BALANCE")]); + onChange([...pool, getDefaultDrop("CURRENCY")]); }, [pool, onChange]); const removeDrop = useCallback((index: number) => { @@ -105,7 +107,7 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) { const colors: Record = { ITEM: "bg-purple-500", - BALANCE: "bg-amber-500", + CURRENCY: "bg-amber-500", XP: "bg-blue-500", }; @@ -206,17 +208,21 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) { )} - {(drop.type === "BALANCE" || drop.type === "XP") && ( + {(drop.type === "CURRENCY" || drop.type === "XP") && ( <>
{ const min = parseInt(e.target.value) || 0; - const max = Array.isArray(drop.amount) ? drop.amount[1] : min; - updateDrop(index, { amount: [min, Math.max(min, max)] }); + const currentMax = drop.maxAmount ?? drop.amount ?? min; + updateDrop(index, { + minAmount: min, + maxAmount: Math.max(min, currentMax), + amount: undefined // Clear amount + }); }} className="bg-background/50 h-8 text-sm" /> @@ -225,11 +231,15 @@ export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) { { const max = parseInt(e.target.value) || 0; - const min = Array.isArray(drop.amount) ? drop.amount[0] : max; - updateDrop(index, { amount: [Math.min(min, max), max] }); + const currentMin = drop.minAmount ?? drop.amount ?? max; + updateDrop(index, { + minAmount: Math.min(currentMin, max), + maxAmount: max, + amount: undefined // Clear amount + }); }} className="bg-background/50 h-8 text-sm" />