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>
124 lines
5.2 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
}
|