Files
d4rkbot/src/bot/levels.js
T
D4rkst3randClaude Fable 5 2d4afad3cb
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Level-Embeds aufgewertet, Server-Footer korrigiert
Server-Monitor: Der Footer behauptete fest "alle 2 min", obwohl das Intervall
seit den einstellbaren Werten aus monitor_interval kommt. Wer im Panel etwas
anderes setzt, bekam eine falsche Angabe. Liest jetzt den echten Wert.

Level-Aufstieg: War als einziges im Community-Bereich reiner Text, waehrend
Geburtstage, Verlosungen und Bewerbungen laengst Embeds sind. Jetzt ueber
brandEmbed mit Avatar, Gesamt-XP und Rest bis zum naechsten Level. Der Text
bleibt die Vorlage aus dem Panel — nur der Rahmen ist neu.

Dazu zeigt das Embed die Belohnungs-Rolle: entweder die gerade vergebene oder
die naechste, auf die man hinspielt. grantRewards() gibt dafuer zurueck, was
wirklich dazukam.

Ist die Vorlage im Panel leer, wirft setDescription('') — der Aufstieg waere
still ausgefallen. Faellt jetzt auf eine schlichte Zeile zurueck.

/rank: Avatar als Thumbnail, Podest-Zeichen fuer die ersten drei, Rest-XP und
naechste Belohnung als eigene Felder. Das Feld erscheint nur, wenn es
Belohnungs-Rollen gibt — sonst stuende dort ein leeres Feld.

Nebenbei zwei veraltete ephemeral: true auf flags: MessageFlags.Ephemeral
umgestellt, wie es der Rest der Befehle schon macht.

Geprueft: alle vier Embeds wirklich gebaut und ausgegeben, discord.js
validiert dabei. Das Intervall im Footer zog den auf 7 gestellten Wert.
Die negative XP-Differenz im ersten Testlauf war ein inkonsistenter
Testdatensatz — mit 4200 XP ist man Level 9, nicht 7; ueber 0..60000 XP
durchgespielt wird die Differenz nie negativ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 05:21:56 +02:00

124 lines
5.2 KiB
JavaScript

// Level-System (Carl-/MEE6-Stil): XP pro Nachricht mit Cooldown, Level-Up-Announce,
// Rollen-Belohnungen; Bestenliste öffentlich auf der Webseite (/level).
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) */
export function xpForLevel(level) {
let total = 0;
for (let n = 0; n < level; n++) total += 5 * n * n + 50 * n + 100;
return total;
}
export function levelFromXp(xp) {
let level = 0;
while (xp >= xpForLevel(level + 1)) level++;
return level;
}
/**
* XP für eine Nachricht gutschreiben (Cooldown-gesteuert).
* @returns {{ xp: number, level: number, leveledUp: boolean } | null} null = Cooldown aktiv
*/
export function awardXp(userId, username, now = Date.now()) {
const row = getLevelRow(userId) ?? { user_id: userId, username, xp: 0, level: 0, last_xp_ms: 0 };
if (now - row.last_xp_ms < tuningMs.seconds('xp_cooldown')) return null;
const { min, max } = xpRange();
const xp = row.xp + min + Math.floor(Math.random() * (max - min + 1));
const level = levelFromXp(xp);
const leveledUp = level > row.level;
setLevelRow({ user_id: userId, username, xp, level, last_xp_ms: now });
return { xp, level, leveledUp };
}
/**
* Beim Level-Up fällige Belohnungs-Rollen (alle Schwellen ≤ Level) vergeben.
* @returns {Promise<string[]>} 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)) {
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) {
client.on(Events.MessageCreate, async (message) => {
try {
if (!levelsEnabled()) return;
if (!message.guildId || message.author?.bot || message.webhookId) return;
const result = awardXp(
message.author.id,
message.member?.displayName ?? message.author.username
);
if (!result?.leveledUp) return;
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
// 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) {
console.error('[level] XP-Verarbeitung fehlgeschlagen:', error);
}
});
}