Files
aurorabot/bot/modules/quest/quest.view.ts
syntaxbullet 3c256ba0b2 refactor: centralize custom interaction IDs into constants
Replace all hardcoded custom ID strings with module-level constants.
Each module now has *_CUSTOM_IDS in its types file, using functions
for dynamic IDs and PREFIX for startsWith matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:35 +02:00

257 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
TextDisplayBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
MessageFlags
} from "discord.js";
import { QUEST_CUSTOM_IDS } from "./quest.types";
/**
* 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. Discord counts all nested components toward a 40 total limit:
// Fixed: 1 container + 2 header + 1 nav row + 2 nav buttons + 1 pagination row + 2 pagination buttons = 9
// Per quest (available): 1 separator + 3 text + 1 action row + 1 button = 6
// Budget: 9 + 6×5 = 39 <= 40
const QUESTS_PER_PAGE = 5;
/**
* 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_CUSTOM_IDS.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_CUSTOM_IDS.PAGE_PREV)
.setLabel("◀ Prev")
.setStyle(ButtonStyle.Secondary)
.setDisabled(page <= 0),
new ButtonBuilder()
.setCustomId(QUEST_CUSTOM_IDS.PAGE_NEXT)
.setLabel("Next ▶")
.setStyle(ButtonStyle.Secondary)
.setDisabled(page >= totalPages - 1)
));
}
// Tab navigation row
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(QUEST_CUSTOM_IDS.VIEW_ACTIVE)
.setLabel("📜 Active")
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'active'),
new ButtonBuilder()
.setCustomId(QUEST_CUSTOM_IDS.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 }
};
}