All checks were successful
Deploy to Production / test (push) Successful in 45s
The available quests view was exceeding Discord's 40-component container limit when many quests existed, causing an API error. Paginate both active and available quest views at 7 quests per page with prev/next navigation buttons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
253 lines
9.1 KiB
TypeScript
253 lines
9.1 KiB
TypeScript
import {
|
||
ActionRowBuilder,
|
||
ButtonBuilder,
|
||
ButtonStyle,
|
||
ContainerBuilder,
|
||
TextDisplayBuilder,
|
||
SeparatorBuilder,
|
||
SeparatorSpacingSize,
|
||
MessageFlags
|
||
} from "discord.js";
|
||
|
||
/**
|
||
* Quest entry with quest details and progress
|
||
*/
|
||
interface QuestEntry {
|
||
progress: number | null;
|
||
completedAt: Date | null;
|
||
quest: {
|
||
id: number;
|
||
name: string;
|
||
description: string | null;
|
||
triggerEvent: string;
|
||
requirements: any;
|
||
rewards: any;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Available quest interface
|
||
*/
|
||
interface AvailableQuest {
|
||
id: number;
|
||
name: string;
|
||
description: string | null;
|
||
rewards: any;
|
||
requirements: any;
|
||
}
|
||
|
||
// Color palette for containers
|
||
const COLORS = {
|
||
ACTIVE: 0x3498db, // Blue - in progress
|
||
AVAILABLE: 0x2ecc71, // Green - available
|
||
COMPLETED: 0xf1c40f // Gold - completed
|
||
};
|
||
|
||
// Max quests per page (2 header + 1 page indicator + 7×5 components = 38, Discord max is 40 per container)
|
||
const QUESTS_PER_PAGE = 7;
|
||
|
||
/**
|
||
* Formats quest rewards object into a human-readable string
|
||
*/
|
||
function formatQuestRewards(rewards: { xp?: number, balance?: number }): string {
|
||
const rewardStr: string[] = [];
|
||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||
return rewardStr.join(" • ") || "None";
|
||
}
|
||
|
||
/**
|
||
* Renders a simple progress bar
|
||
*/
|
||
function renderProgressBar(current: number, total: number, size: number = 10): string {
|
||
const percentage = Math.min(current / total, 1);
|
||
const progress = Math.round(size * percentage);
|
||
const empty = size - progress;
|
||
|
||
const progressText = "▰".repeat(progress);
|
||
const emptyText = "▱".repeat(empty);
|
||
|
||
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
|
||
}
|
||
|
||
/**
|
||
* Creates Components v2 containers for the quest list (active quests only)
|
||
*/
|
||
export function getQuestListComponents(userQuests: QuestEntry[], page: number = 0): ContainerBuilder[] {
|
||
// Filter to only show in-progress quests (not completed)
|
||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||
const totalPages = Math.max(1, Math.ceil(activeQuests.length / QUESTS_PER_PAGE));
|
||
const safePage = Math.min(page, totalPages - 1);
|
||
const pageQuests = activeQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
|
||
|
||
const container = new ContainerBuilder()
|
||
.setAccentColor(COLORS.ACTIVE)
|
||
.addTextDisplayComponents(
|
||
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||
new TextDisplayBuilder().setContent(
|
||
totalPages > 1
|
||
? `-# Your active quests — Page ${safePage + 1}/${totalPages}`
|
||
: "-# Your active quests"
|
||
)
|
||
);
|
||
|
||
if (activeQuests.length === 0) {
|
||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||
container.addTextDisplayComponents(
|
||
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
|
||
);
|
||
return [container];
|
||
}
|
||
|
||
pageQuests.forEach((entry) => {
|
||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||
|
||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||
const rewardsText = formatQuestRewards(rewards);
|
||
|
||
const requirements = entry.quest.requirements as { target?: number };
|
||
const target = requirements?.target || 1;
|
||
const progress = entry.progress || 0;
|
||
const progressBar = renderProgressBar(progress, target);
|
||
|
||
container.addTextDisplayComponents(
|
||
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
|
||
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
|
||
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
|
||
);
|
||
});
|
||
|
||
return [container];
|
||
}
|
||
|
||
/**
|
||
* Creates Components v2 containers for available quests with inline accept buttons
|
||
*/
|
||
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[], page: number = 0): ContainerBuilder[] {
|
||
const totalPages = Math.max(1, Math.ceil(availableQuests.length / QUESTS_PER_PAGE));
|
||
const safePage = Math.min(page, totalPages - 1);
|
||
const pageQuests = availableQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
|
||
|
||
const container = new ContainerBuilder()
|
||
.setAccentColor(COLORS.AVAILABLE)
|
||
.addTextDisplayComponents(
|
||
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||
new TextDisplayBuilder().setContent(
|
||
totalPages > 1
|
||
? `-# Quests you can accept — Page ${safePage + 1}/${totalPages}`
|
||
: "-# Quests you can accept"
|
||
)
|
||
);
|
||
|
||
if (availableQuests.length === 0) {
|
||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||
container.addTextDisplayComponents(
|
||
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
|
||
);
|
||
return [container];
|
||
}
|
||
|
||
pageQuests.forEach((quest) => {
|
||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||
|
||
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||
const rewardsText = formatQuestRewards(rewards);
|
||
|
||
const requirements = quest.requirements as { target?: number };
|
||
const target = requirements?.target || 1;
|
||
|
||
container.addTextDisplayComponents(
|
||
new TextDisplayBuilder().setContent(`**${quest.name}**`),
|
||
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
|
||
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
|
||
);
|
||
|
||
// Add accept button inline within the container
|
||
container.addActionRowComponents(
|
||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||
new ButtonBuilder()
|
||
.setCustomId(`quest_accept:${quest.id}`)
|
||
.setLabel("Accept Quest")
|
||
.setStyle(ButtonStyle.Success)
|
||
.setEmoji("✅")
|
||
)
|
||
);
|
||
});
|
||
|
||
return [container];
|
||
}
|
||
|
||
/**
|
||
* Returns action rows for navigation and pagination
|
||
*/
|
||
export function getQuestActionRows(viewType: 'active' | 'available', totalItems: number, page: number): ActionRowBuilder<ButtonBuilder>[] {
|
||
const totalPages = Math.max(1, Math.ceil(totalItems / QUESTS_PER_PAGE));
|
||
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||
|
||
// Pagination row (only if more than one page)
|
||
if (totalPages > 1) {
|
||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||
new ButtonBuilder()
|
||
.setCustomId("quest_page_prev")
|
||
.setLabel("◀ Prev")
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(page <= 0),
|
||
new ButtonBuilder()
|
||
.setCustomId("quest_page_next")
|
||
.setLabel("Next ▶")
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(page >= totalPages - 1)
|
||
));
|
||
}
|
||
|
||
// Tab navigation row
|
||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||
new ButtonBuilder()
|
||
.setCustomId("quest_view_active")
|
||
.setLabel("📜 Active")
|
||
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||
.setDisabled(viewType === 'active'),
|
||
new ButtonBuilder()
|
||
.setCustomId("quest_view_available")
|
||
.setLabel("🗺️ Available")
|
||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||
.setDisabled(viewType === 'available')
|
||
));
|
||
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* Creates Components v2 celebratory message for quest completion
|
||
*/
|
||
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
|
||
const rewardsText = formatQuestRewards({
|
||
xp: Number(rewards.xp),
|
||
balance: Number(rewards.balance)
|
||
});
|
||
|
||
const container = new ContainerBuilder()
|
||
.setAccentColor(COLORS.COMPLETED)
|
||
.addTextDisplayComponents(
|
||
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
|
||
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
|
||
)
|
||
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||
.addTextDisplayComponents(
|
||
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
|
||
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
|
||
);
|
||
|
||
return [container];
|
||
}
|
||
|
||
/**
|
||
* Gets MessageFlags and allowedMentions for Components v2 messages
|
||
*/
|
||
export function getComponentsV2MessageFlags() {
|
||
return {
|
||
flags: MessageFlags.IsComponentsV2,
|
||
allowedMentions: { parse: [] as const }
|
||
};
|
||
}
|