Some checks failed
Deploy to Production / test (push) Failing after 29s
- Replace in-memory auth sessions with signed cookies and signed OAuth state - Add auth route coverage and update panel/web server wiring - Switch test script to per-file Bun processes and clean up type checks
217 lines
8.1 KiB
TypeScript
217 lines
8.1 KiB
TypeScript
import {
|
||
ActionRowBuilder,
|
||
ButtonBuilder,
|
||
ButtonStyle,
|
||
AttachmentBuilder,
|
||
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";
|
||
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
||
import { SHOP_CUSTOM_IDS } from "./economy.types";
|
||
|
||
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<number, { name: string; rarity: string }> }
|
||
) {
|
||
const files: AttachmentBuilder[] = [];
|
||
let thumbnailUrl = resolveAssetUrl(item.iconUrl);
|
||
let displayImageUrl = resolveAssetUrl(item.imageUrl);
|
||
|
||
// Handle local icon
|
||
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
|
||
const iconPath = join(process.cwd(), "bot/assets/graphics", stripQuery(item.iconUrl).replace(/^\/?assets\//, ""));
|
||
if (existsSync(iconPath)) {
|
||
const iconName = defaultName(item.iconUrl);
|
||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||
thumbnailUrl = `attachment://${iconName}`;
|
||
}
|
||
}
|
||
|
||
// Handle local image
|
||
if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) {
|
||
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
|
||
displayImageUrl = thumbnailUrl;
|
||
} else {
|
||
const imagePath = join(process.cwd(), "bot/assets/graphics", stripQuery(item.imageUrl).replace(/^\/?assets\//, ""));
|
||
if (existsSync(imagePath)) {
|
||
const imageName = defaultName(item.imageUrl);
|
||
if (!files.find(f => f.name === imageName)) {
|
||
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||
}
|
||
displayImageUrl = `attachment://${imageName}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
const containers: ContainerBuilder[] = [];
|
||
|
||
// 1. Main Container
|
||
const mainContainer = new ContainerBuilder()
|
||
.setAccentColor(getRarityConfig(item.rarity || "C").color);
|
||
|
||
// 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))
|
||
)
|
||
);
|
||
}
|
||
|
||
// Create buy button (used in either main or loot container)
|
||
const buyButton = new ButtonBuilder()
|
||
.setCustomId(SHOP_CUSTOM_IDS.BUY(item.id))
|
||
.setLabel(`Purchase for ${item.price} 🪙`)
|
||
.setStyle(ButtonStyle.Success)
|
||
.setEmoji("🛒");
|
||
|
||
// 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);
|
||
|
||
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 };
|
||
const tier = tiers[rarity]!;
|
||
tier.items.push(line);
|
||
tier.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,
|
||
files,
|
||
flags: MessageFlags.IsComponentsV2
|
||
};
|
||
}
|