diff --git a/src/bot/commands/rank.js b/src/bot/commands/rank.js index aebdea6..3029f39 100644 --- a/src/bot/commands/rank.js +++ b/src/bot/commands/rank.js @@ -1,8 +1,12 @@ // /rank — eigenes Level, XP-Fortschritt und Platzierung anzeigen -import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { getLevelRow, rankOf } from '../../db.js'; import { xpForLevel } from '../levels.js'; -import { levelsEnabled, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js'; +import { brandEmbed } from '../../embeds.js'; +import { levelsEnabled, publicUrl, levelRewards } from '../../runtime-settings.js'; + +// Platz 1–3 bekommen ein Zeichen, danach schlicht die Nummer +const PODEST = ['🥇', '🥈', '🥉']; export const data = new SlashCommandBuilder() .setName('rank') @@ -11,13 +15,19 @@ export const data = new SlashCommandBuilder() export async function execute(interaction) { if (!levelsEnabled()) { - await interaction.reply({ content: '❌ Das Level-System ist nicht aktiviert.', ephemeral: true }); + await interaction.reply({ + content: '❌ Das Level-System ist nicht aktiviert.', + flags: MessageFlags.Ephemeral, + }); return; } const user = interaction.options.getUser('member') ?? interaction.user; const row = getLevelRow(user.id); if (!row) { - await interaction.reply({ content: `**${user.username}** hat noch keine XP — einfach mitschreiben! ✍️`, ephemeral: true }); + await interaction.reply({ + content: `**${user.username}** hat noch keine XP — einfach mitschreiben! ✍️`, + flags: MessageFlags.Ephemeral, + }); return; } @@ -29,15 +39,30 @@ export async function execute(interaction) { const barLen = 14; const bar = '█'.repeat(Math.round((pct / 100) * barLen)).padEnd(barLen, '·'); - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setColor(brandColor()) - .setAuthor({ name: user.username, iconURL: user.displayAvatarURL({ size: 64 }) }) - .setTitle(`Level ${row.level} · Platz #${rankOf(user.id)}`) - .setDescription(`\`${bar}\` ${pct}%\n**${progress} / ${needed} XP** bis Level ${row.level + 1} · gesamt ${row.xp} XP`) - .setURL(`${publicUrl()}/level`) - .setFooter({ text: brandFooter('LEVEL') }), - ], - }); + const platz = rankOf(user.id); + const zahl = (n) => n.toLocaleString('de-DE'); + const naechste = levelRewards().find((r) => r.level > row.level) ?? null; + + const embed = brandEmbed(interaction.client, 'LEVEL') + .setAuthor({ name: user.username, iconURL: user.displayAvatarURL({ size: 64 }) }) + .setTitle(`${PODEST[platz - 1] ?? ''} Level ${row.level} · Platz #${platz}`.trim()) + .setURL(`${publicUrl()}/level`) + .setThumbnail(user.displayAvatarURL({ size: 128 })) + .setDescription(`\`${bar}\` **${pct}%**`) + .addFields( + { name: `Bis Level ${row.level + 1}`, value: `${zahl(needed - progress)} XP`, inline: true }, + { name: 'Gesamt', value: `${zahl(row.xp)} XP`, inline: true }, + ); + + // Nur zeigen, was es auch gibt — ohne Belohnungs-Rollen bliebe hier ein + // leeres Feld stehen und das Embed sähe kaputt aus. + if (naechste) { + embed.addFields({ + name: 'Nächste Belohnung', + value: `<@&${naechste.roleId}> ab Level ${naechste.level}`, + inline: true, + }); + } + + await interaction.reply({ embeds: [embed], allowedMentions: { parse: [] } }); } diff --git a/src/bot/levels.js b/src/bot/levels.js index f767e37..ec8005c 100644 --- a/src/bot/levels.js +++ b/src/bot/levels.js @@ -4,6 +4,7 @@ import { Events } from 'discord.js'; import { getLevelRow, setLevelRow } from '../db.js'; import { levelsEnabled, levelsAnnounce, levelRewards } from '../runtime-settings.js'; import { renderTemplate } from '../templates.js'; +import { brandEmbed } from '../embeds.js'; import { tuningMs, xpRange } from '../tuning.js'; /** Kumulierte XP-Schwelle, ab der ein Level erreicht ist (MEE6-Formel pro Stufe) */ @@ -35,16 +36,28 @@ export function awardXp(userId, username, now = Date.now()) { return { xp, level, leveledUp }; } -/** Beim Level-Up fällige Belohnungs-Rollen (alle Schwellen ≤ Level) vergeben */ +/** + * Beim Level-Up fällige Belohnungs-Rollen (alle Schwellen ≤ Level) vergeben. + * @returns {Promise} Rollen-IDs, die neu dazukamen — fürs Embed + */ async function grantRewards(member, level) { + const neu = []; for (const reward of levelRewards()) { if (reward.level > level) break; if (!member.roles.cache.has(reward.roleId)) { - await member.roles.add(reward.roleId).catch((e) => - console.error(`[level] Belohnungs-Rolle ${reward.roleId} fehlgeschlagen: ${e.message}`) - ); + const ok = await member.roles.add(reward.roleId).then(() => true).catch((e) => { + console.error(`[level] Belohnungs-Rolle ${reward.roleId} fehlgeschlagen: ${e.message}`); + return false; + }); + if (ok) neu.push(reward.roleId); } } + return neu; +} + +/** Nächste Belohnungs-Stufe oberhalb des aktuellen Levels (oder null) */ +function naechsteBelohnung(level) { + return levelRewards().find((r) => r.level > level) ?? null; } export function registerLevels(client) { @@ -59,19 +72,48 @@ export function registerLevels(client) { ); if (!result?.leveledUp) return; - if (message.member) await grantRewards(message.member, result.level); + const belohnt = message.member ? await grantRewards(message.member, result.level) : []; if (levelsAnnounce()) { const name = message.member?.displayName ?? message.author.username; + const bisNaechstes = xpForLevel(result.level + 1) - result.xp; + const naechste = naechsteBelohnung(result.level); + + // Der Text bleibt die Vorlage aus dem Panel — nur der Rahmen ist + // jetzt ein Embed, damit es zu Geburtstagen und Verlosungen passt. + // Leere Vorlage: discord.js wirft bei setDescription('') und der + // Aufstieg fiele still aus. Lieber eine schlichte Zeile. + const text = renderTemplate('level.up', { + user: name, + mention: `<@${message.author.id}>`, + level: result.level, + xp: result.xp, + }).trim() || `**${name}** ist jetzt **Level ${result.level}**!`; + + const embed = brandEmbed(client, 'LEVEL') + .setDescription(text) + .setThumbnail(message.author.displayAvatarURL({ size: 128 })) + .addFields( + { name: 'Gesamt', value: `${result.xp.toLocaleString('de-DE')} XP`, inline: true }, + { name: `Bis Level ${result.level + 1}`, value: `${bisNaechstes.toLocaleString('de-DE')} XP`, inline: true }, + ); + if (belohnt.length > 0) { + embed.addFields({ + name: belohnt.length > 1 ? 'Neue Rollen' : 'Neue Rolle', + value: belohnt.map((id) => `<@&${id}>`).join(' '), + inline: true, + }); + } else if (naechste) { + embed.addFields({ + name: 'Nächste Belohnung', + value: `<@&${naechste.roleId}> ab Level ${naechste.level}`, + inline: true, + }); + } + await message.channel - .send({ - content: renderTemplate('level.up', { - user: name, - mention: `<@${message.author.id}>`, - level: result.level, - xp: result.xp, - }), - allowedMentions: { parse: [] }, - }) + // Rollen-Erwähnungen im Embed pingen ohnehin nicht — der + // Schalter hier hält auch den Nachrichtentext still. + .send({ embeds: [embed], allowedMentions: { parse: [] } }) .catch(() => {}); } } catch (error) { diff --git a/src/bot/server-monitor.js b/src/bot/server-monitor.js index d0c7a7b..f2b8e1d 100644 --- a/src/bot/server-monitor.js +++ b/src/bot/server-monitor.js @@ -84,7 +84,12 @@ export function buildServerEmbed(r) { .setColor(r.online ? GREEN : RED) .setTitle(`${icon} ${r.name}`) .addFields({ name: 'Status', value: r.online ? '🟢 **Online**' : '🔴 **Offline**', inline: true }) - .setFooter({ text: `${brandFooter('STATUS')}${r.ping != null ? ` • ${r.ping}ms` : ''} • alle 2 min` }) + // Intervall aus den Werten lesen, nicht festschreiben — sonst behauptet + // das Embed weiter „alle 2 min", nachdem im Panel etwas anderes steht. + .setFooter({ + text: `${brandFooter('STATUS')}${r.ping != null ? ` • ${r.ping}ms` : ''}` + + ` • alle ${tuning('monitor_interval')} min`, + }) .setTimestamp(); if (r.image_url) embed.setThumbnail(r.image_url);