From e56e133a6925460268222e774825f98cf187e28d Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 28 Mar 2026 14:20:53 +0100 Subject: [PATCH] fix: add pagination to quest list to stay within Discord component limits 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) --- bot/commands/quest/quests.ts | 31 +++++++++++----- bot/modules/quest/quest.view.ts | 64 +++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/bot/commands/quest/quests.ts b/bot/commands/quest/quests.ts index a3f053a..f5e0b0a 100644 --- a/bot/commands/quest/quests.ts +++ b/bot/commands/quest/quests.ts @@ -16,16 +16,24 @@ export const quests = createCommand({ const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const userId = interaction.user.id; + let currentView: 'active' | 'available' = 'active'; + let currentPage = 0; + + const updateView = async (viewType: 'active' | 'available', page: number = 0) => { + currentView = viewType; + currentPage = page; - const updateView = async (viewType: 'active' | 'available') => { const userQuests = await questService.getUserQuests(userId); const availableQuests = await questService.getAvailableQuests(userId); - const containers = viewType === 'active' - ? getQuestListComponents(userQuests) - : getAvailableQuestsComponents(availableQuests); + const activeQuests = userQuests.filter(entry => entry.completedAt === null); + const totalItems = viewType === 'active' ? activeQuests.length : availableQuests.length; - const actionRows = getQuestActionRows(viewType); + const containers = viewType === 'active' + ? getQuestListComponents(userQuests, page) + : getAvailableQuestsComponents(availableQuests, page); + + const actionRows = getQuestActionRows(viewType, totalItems, page); await interaction.editReply({ content: null, @@ -50,10 +58,16 @@ export const quests = createCommand({ try { if (i.customId === "quest_view_active") { await i.deferUpdate(); - await updateView('active'); + await updateView('active', 0); } else if (i.customId === "quest_view_available") { await i.deferUpdate(); - await updateView('available'); + await updateView('available', 0); + } else if (i.customId === "quest_page_prev") { + await i.deferUpdate(); + await updateView(currentView, Math.max(0, currentPage - 1)); + } else if (i.customId === "quest_page_next") { + await i.deferUpdate(); + await updateView(currentView, currentPage + 1); } else if (i.customId.startsWith("quest_accept:")) { const questIdStr = i.customId.split(":")[1]; if (!questIdStr) return; @@ -65,7 +79,8 @@ export const quests = createCommand({ flags: MessageFlags.Ephemeral }); - await updateView('active'); + // Stay on current view/page but refresh (accepted quest disappears from available) + await updateView(currentView, currentPage); } } catch (error) { console.error("Quest interaction error:", error); diff --git a/bot/modules/quest/quest.view.ts b/bot/modules/quest/quest.view.ts index 2e18283..003cbb8 100644 --- a/bot/modules/quest/quest.view.ts +++ b/bot/modules/quest/quest.view.ts @@ -43,6 +43,9 @@ const COLORS = { 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 */ @@ -70,15 +73,22 @@ function renderProgressBar(current: number, total: number, size: number = 10): s /** * Creates Components v2 containers for the quest list (active quests only) */ -export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] { +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("-# Your active quests") + new TextDisplayBuilder().setContent( + totalPages > 1 + ? `-# Your active quests β€” Page ${safePage + 1}/${totalPages}` + : "-# Your active quests" + ) ); if (activeQuests.length === 0) { @@ -89,7 +99,7 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild return [container]; } - activeQuests.forEach((entry) => { + pageQuests.forEach((entry) => { container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); const rewards = entry.quest.rewards as { xp?: number, balance?: number }; @@ -113,12 +123,20 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild /** * Creates Components v2 containers for available quests with inline accept buttons */ -export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] { +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("-# Quests you can accept") + new TextDisplayBuilder().setContent( + totalPages > 1 + ? `-# Quests you can accept β€” Page ${safePage + 1}/${totalPages}` + : "-# Quests you can accept" + ) ); if (availableQuests.length === 0) { @@ -129,10 +147,7 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): return [container]; } - // Limit to 10 quests (5 action rows max with 2 added for navigation) - const questsToShow = availableQuests.slice(0, 10); - - questsToShow.forEach((quest) => { + pageQuests.forEach((quest) => { container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); const rewards = quest.rewards as { xp?: number, balance?: number }; @@ -163,11 +178,30 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): } /** - * Returns action rows for navigation only + * Returns action rows for navigation and pagination */ -export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder[] { - // Navigation row - const navRow = new ActionRowBuilder().addComponents( +export function getQuestActionRows(viewType: 'active' | 'available', totalItems: number, page: number): ActionRowBuilder[] { + const totalPages = Math.max(1, Math.ceil(totalItems / QUESTS_PER_PAGE)); + const rows: ActionRowBuilder[] = []; + + // Pagination row (only if more than one page) + if (totalPages > 1) { + rows.push(new ActionRowBuilder().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().addComponents( new ButtonBuilder() .setCustomId("quest_view_active") .setLabel("πŸ“œ Active") @@ -178,9 +212,9 @@ export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowB .setLabel("πŸ—ΊοΈ Available") .setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary) .setDisabled(viewType === 'available') - ); + )); - return [navRow]; + return rows; } /**