Implement leveling and economy features with associated models and commands

- Added new models for leveling and economy functionalities in the Prisma schema, including LevelingConfig, MemberLevel, LevelReward, EconomyConfig, MemberEconomy, ShopItem, and InventoryItem.
- Introduced commands for managing XP, economy transactions, and shop interactions, enhancing user engagement.
- Updated job handling to include reminders and poll closing functionalities.
- Enhanced localization support for new commands and features in both German and English.
- Improved the bot's job processing capabilities with a dedicated reminder worker for scheduled tasks.
This commit is contained in:
smueller
2026-07-22 12:49:17 +02:00
parent 2518119257
commit fa5910ec43
38 changed files with 6759 additions and 144 deletions

View File

@@ -274,6 +274,414 @@ export const commandLocales = {
'verify.panel.options.channel': {
de: 'Optionaler Kanal für das Panel',
en: 'Optional channel for the panel'
},
'leveling.rank.description': {
de: 'Rangkarte eines Mitglieds anzeigen',
en: 'Show a member rank card'
},
'leveling.rank.options.user': {
de: 'Mitglied (Standard: du)',
en: 'Member (defaults to you)'
},
'leveling.leaderboard.description': {
de: 'Top 10 Mitglieder nach XP',
en: 'Top 10 members by XP'
},
'leveling.xp.description': {
de: 'XP verwalten (Admin)',
en: 'Manage XP (admin)'
},
'leveling.xp.give.description': {
de: 'XP vergeben',
en: 'Give XP'
},
'leveling.xp.remove.description': {
de: 'XP entfernen',
en: 'Remove XP'
},
'leveling.xp.reset.description': {
de: 'XP zurücksetzen',
en: 'Reset XP'
},
'leveling.xp.options.user': {
de: 'Zielmitglied',
en: 'Target member'
},
'leveling.xp.options.amount': {
de: 'XP-Betrag',
en: 'XP amount'
},
'economy.balance.description': {
de: 'Kontostand anzeigen',
en: 'Show balance'
},
'economy.daily.description': {
de: 'Tägliche Belohnung abholen',
en: 'Claim daily reward'
},
'economy.weekly.description': {
de: 'Wöchentliche Belohnung abholen',
en: 'Claim weekly reward'
},
'economy.work.description': {
de: 'Arbeiten und Geld verdienen',
en: 'Work to earn money'
},
'economy.pay.description': {
de: 'Geld an einen Nutzer senden',
en: 'Send money to a user'
},
'economy.gamble.description': {
de: '50/50 um deinen Einsatz spielen',
en: '50/50 gamble your bet'
},
'economy.slots.description': {
de: 'Spielautomaten spielen',
en: 'Play the slot machine'
},
'economy.blackjack.description': {
de: 'Blackjack spielen',
en: 'Play blackjack'
},
'economy.coinflip.description': {
de: 'Kopf oder Zahl um Geld wetten',
en: 'Bet on heads or tails'
},
'economy.shop.description': {
de: 'Server-Shop',
en: 'Server shop'
},
'economy.shop.view.description': {
de: 'Shop-Artikel anzeigen',
en: 'View shop items'
},
'economy.shop.buy.description': {
de: 'Artikel kaufen',
en: 'Buy an item'
},
'economy.shop.options.item_id': {
de: 'Artikel-ID aus /shop view',
en: 'Item ID from /shop view'
},
'economy.inventory.description': {
de: 'Dein Inventar anzeigen',
en: 'Show your inventory'
},
'economy.eco.description': {
de: 'Economy-Verwaltung',
en: 'Economy management'
},
'economy.eco.leaderboard.description': {
de: 'Reichsten Nutzer anzeigen',
en: 'Show richest users'
},
'economy.eco.give.description': {
de: 'Geld vergeben (Admin)',
en: 'Give money (admin)'
},
'economy.eco.remove.description': {
de: 'Geld entfernen (Admin)',
en: 'Remove money (admin)'
},
'economy.eco.reset.description': {
de: 'Nutzer-Economy zurücksetzen (Admin)',
en: 'Reset user economy (admin)'
},
'economy.common.options.amount': {
de: 'Betrag',
en: 'Amount'
},
'economy.common.options.user': {
de: 'Nutzer',
en: 'User'
},
'economy.common.options.userOptional': {
de: 'Optionaler Nutzer (Standard: du)',
en: 'Optional user (defaults to you)'
},
'economy.common.options.target': {
de: 'Zielnutzer',
en: 'Target user'
},
'economy.coinflip.options.choice': {
de: 'Kopf oder Zahl',
en: 'Heads or tails'
},
'economy.choice.heads': {
de: 'Kopf',
en: 'Heads'
},
'economy.choice.tails': {
de: 'Zahl',
en: 'Tails'
},
'utility.userinfo.description': {
de: 'Nutzerinformationen anzeigen',
en: 'Show user information'
},
'utility.serverinfo.description': {
de: 'Serverinformationen anzeigen',
en: 'Show server information'
},
'utility.roleinfo.description': {
de: 'Rolleninformationen anzeigen',
en: 'Show role information'
},
'utility.roleinfo.options.role': {
de: 'Rolle',
en: 'Role'
},
'utility.channelinfo.description': {
de: 'Kanalinformationen anzeigen',
en: 'Show channel information'
},
'utility.avatar.description': {
de: 'Avatar anzeigen',
en: 'Show avatar'
},
'utility.banner.description': {
de: 'Banner anzeigen',
en: 'Show banner'
},
'utility.common.options.userOptional': {
de: 'Optionaler Nutzer (Standard: du)',
en: 'Optional user (defaults to you)'
},
'utility.common.options.channelOptional': {
de: 'Optionaler Kanal (Standard: aktueller Kanal)',
en: 'Optional channel (defaults to current)'
},
'utility.common.options.attachment': {
de: 'Bild-Anhang',
en: 'Image attachment'
},
'utility.common.options.url': {
de: 'Bild-URL',
en: 'Image URL'
},
'utility.common.options.name': {
de: 'Name',
en: 'Name'
},
'utility.poll.description': {
de: 'Umfragen erstellen',
en: 'Create polls'
},
'utility.poll.create.description': {
de: 'Neue Umfrage erstellen',
en: 'Create a new poll'
},
'utility.poll.options.question': {
de: 'Frage',
en: 'Question'
},
'utility.poll.options.options': {
de: 'Optionen (kommagetrennt, 25)',
en: 'Options (comma-separated, 25)'
},
'utility.poll.options.multiselect': {
de: 'Mehrfachauswahl erlauben',
en: 'Allow multi-select'
},
'utility.poll.options.anonymous': {
de: 'Anonyme Abstimmung',
en: 'Anonymous voting'
},
'utility.poll.options.duration': {
de: 'Optionale Dauer (z. B. 1h)',
en: 'Optional duration (e.g. 1h)'
},
'utility.remindme.description': {
de: 'Erinnerung setzen',
en: 'Set a reminder'
},
'utility.remindme.options.when': {
de: 'Wann (z. B. 30m oder ISO-Datum)',
en: 'When (e.g. 30m or ISO datetime)'
},
'utility.remindme.options.content': {
de: 'Erinnerungstext',
en: 'Reminder content'
},
'utility.remindme.options.recurring_cron': {
de: 'Optional: Cron für wiederkehrende Erinnerung',
en: 'Optional cron for recurring reminder'
},
'utility.reminders.description': {
de: 'Erinnerungen verwalten',
en: 'Manage reminders'
},
'utility.reminders.list.description': {
de: 'Erinnerungen auflisten',
en: 'List reminders'
},
'utility.reminders.delete.description': {
de: 'Erinnerung löschen',
en: 'Delete a reminder'
},
'utility.reminders.options.id': {
de: 'Erinnerungs-ID (Präfix reicht)',
en: 'Reminder ID (prefix is enough)'
},
'utility.afk.description': {
de: 'AFK-Status setzen',
en: 'Set AFK status'
},
'utility.afk.set.description': {
de: 'Als AFK markieren',
en: 'Mark as AFK'
},
'utility.afk.options.reason': {
de: 'Optionaler AFK-Grund',
en: 'Optional AFK reason'
},
'utility.emoji.description': {
de: 'Emojis verwalten',
en: 'Manage emojis'
},
'utility.emoji.add.description': {
de: 'Emoji hinzufügen',
en: 'Add emoji'
},
'utility.emoji.remove.description': {
de: 'Emoji entfernen',
en: 'Remove emoji'
},
'utility.emoji.steal.description': {
de: 'Emoji aus Nachricht übernehmen',
en: 'Steal emoji from message'
},
'utility.emoji.options.emoji': {
de: 'Emoji',
en: 'Emoji'
},
'utility.emoji.options.message_id': {
de: 'Nachrichten-ID',
en: 'Message ID'
},
'utility.emoji.options.url': {
de: 'Emoji-URL',
en: 'Emoji URL'
},
'utility.sticker.description': {
de: 'Sticker verwalten',
en: 'Manage stickers'
},
'utility.sticker.add.description': {
de: 'Sticker hinzufügen',
en: 'Add sticker'
},
'utility.sticker.options.url': {
de: 'Sticker-URL',
en: 'Sticker URL'
},
'utility.timestamp.description': {
de: 'Discord-Timestamp-Formate generieren',
en: 'Generate Discord timestamp formats'
},
'utility.timestamp.options.datetime': {
de: 'Datum/Uhrzeit (ISO oder relative Dauer)',
en: 'Datetime (ISO or relative duration)'
},
'utility.snipe.description': {
de: 'Zuletzt gelöschte Nachricht anzeigen',
en: 'Show last deleted message'
},
'utility.editsnipe.description': {
de: 'Zuletzt bearbeitete Nachricht anzeigen',
en: 'Show last edited message'
},
'utility.embed.description': {
de: 'Embeds erstellen',
en: 'Create embeds'
},
'utility.embed.builder.description': {
de: 'Embed per Modal bauen und senden',
en: 'Build and send an embed via modal'
},
'fun.8ball.description': {
de: 'Magische 8-Ball-Antwort auf deine Frage',
en: 'Magic 8-ball answer to your question'
},
'fun.8ball.options.question': {
de: 'Deine Frage',
en: 'Your question'
},
'fun.dice.description': {
de: 'Würfel werfen',
en: 'Roll dice'
},
'fun.dice.options.sides': {
de: 'Anzahl Seiten (Standard: 6)',
en: 'Number of sides (default: 6)'
},
'fun.dice.options.count': {
de: 'Anzahl Würfe (Standard: 1, max. 10)',
en: 'Number of rolls (default: 1, max 10)'
},
'fun.flip.description': {
de: 'Kostenloser Münzwurf (Kopf oder Zahl)',
en: 'Free coin flip (heads or tails)'
},
'fun.rps.description': {
de: 'Schere, Stein, Papier gegen den Bot',
en: 'Rock, paper, scissors against the bot'
},
'fun.rps.options.choice': {
de: 'Deine Wahl',
en: 'Your choice'
},
'fun.rps.choice.rock': {
de: 'Stein',
en: 'Rock'
},
'fun.rps.choice.paper': {
de: 'Papier',
en: 'Paper'
},
'fun.rps.choice.scissors': {
de: 'Schere',
en: 'Scissors'
},
'fun.choose.description': {
de: 'Zufällige Auswahl aus Optionen',
en: 'Random choice from options'
},
'fun.choose.options.options': {
de: 'Optionen (kommagetrennt)',
en: 'Options (comma-separated)'
},
'fun.trivia.description': {
de: 'Trivia-Frage mit Multiple Choice',
en: 'Trivia question with multiple choice'
},
'fun.tictactoe.description': {
de: 'Tic-Tac-Toe gegen einen anderen Nutzer',
en: 'Play Tic-Tac-Toe against another user'
},
'fun.connect4.description': {
de: 'Connect 4 gegen einen anderen Nutzer',
en: 'Play Connect 4 against another user'
},
'fun.hangman.description': {
de: 'Hangman mit Buchstaben-Buttons',
en: 'Hangman with letter buttons'
},
'fun.meme.description': {
de: 'Zufälliges Meme laden',
en: 'Fetch a random meme'
},
'fun.cat.description': {
de: 'Zufälliges Katzenbild laden',
en: 'Fetch a random cat image'
},
'fun.dog.description': {
de: 'Zufälliges Hundebild laden',
en: 'Fetch a random dog image'
},
'fun.common.options.opponent': {
de: 'Gegner',
en: 'Opponent'
}
} as const;

View File

@@ -4,6 +4,7 @@ export * from './audit.js';
export * from './command-locales.js';
export * from './discord-localization.js';
export * from './phase2.js';
export * from './phase3.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;
@@ -92,7 +93,152 @@ const de: Dictionary = {
'verification.error.generic': 'Verifizierung fehlgeschlagen.',
'verification.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.',
'verification.captcha.invalid': 'Captcha ungültig oder abgelaufen.',
'verification.captcha.title': 'Nexumi Verifizierung'
'verification.captcha.title': 'Nexumi Verifizierung',
'leveling.disabled': 'Leveling ist auf diesem Server deaktiviert.',
'leveling.rank.title': 'Rangkarte',
'leveling.leaderboard.title': 'Leaderboard',
'leveling.leaderboard.empty': 'Noch keine XP-Daten vorhanden.',
'leveling.leaderboard.entry': '{rank}. {user} — Level {level} ({xp} XP)',
'leveling.leaderboard.footer': 'Top {count} Mitglieder',
'leveling.levelUp.default': 'Glückwunsch {user}, du hast Level {level} erreicht!',
'leveling.xp.give.success': '{amount} XP an {user} vergeben. Neues Level: {level}.',
'leveling.xp.remove.success': '{amount} XP von {user} entfernt. Neues Level: {level}.',
'leveling.xp.reset.success': 'XP von {user} zurückgesetzt.',
'leveling.xp.invalidAmount': 'Der Betrag muss größer als 0 sein.',
'leveling.user.notFound': 'Nutzer nicht auf diesem Server gefunden.',
'economy.error.disabled': 'Das Economy-Modul ist auf diesem Server deaktiviert.',
'economy.error.insufficient': 'Du hast nicht genug Geld.',
'economy.error.cooldown': 'Cooldown aktiv. Versuche es in {seconds} Sekunden erneut.',
'economy.error.invalid_amount': 'Ungültiger Betrag.',
'economy.error.self_transfer': 'Du kannst dir nicht selbst Geld senden.',
'economy.error.not_found': 'Eintrag nicht gefunden.',
'economy.error.out_of_stock': 'Dieser Artikel ist ausverkauft.',
'economy.error.role_assign_failed': 'Kauf erfolgreich, aber die Rolle konnte nicht vergeben werden.',
'economy.error.game_in_progress': 'Du hast bereits ein laufendes Blackjack-Spiel.',
'economy.balance.result': 'Kontostand von {user}: {amount}',
'economy.daily.success': 'Tägliche Belohnung: {amount}. Neuer Kontostand: {balance}',
'economy.weekly.success': 'Wöchentliche Belohnung: {amount}. Neuer Kontostand: {balance}',
'economy.work.success': 'Du hast {amount} verdient. Neuer Kontostand: {balance}',
'economy.pay.success': 'Du hast {amount} an {user} gesendet. Dein Kontostand: {balance}',
'economy.gamble.win': 'Gewonnen! +{amount}. Kontostand: {balance}',
'economy.gamble.lose': 'Verloren! -{amount}. Kontostand: {balance}',
'economy.slots.win': '{reels}\nGewinn: {payout}. Kontostand: {balance}',
'economy.slots.lose': '{reels}\nKein Gewinn. Kontostand: {balance}',
'economy.coinflip.win': '{result} du hattest {choice} gewählt. Gewonnen! +{amount}. Kontostand: {balance}',
'economy.coinflip.lose': '{result} du hattest {choice} gewählt. Verloren! -{amount}. Kontostand: {balance}',
'economy.choice.heads': 'Kopf',
'economy.choice.tails': 'Zahl',
'economy.shop.empty': 'Der Shop ist leer.',
'economy.shop.noDescription': 'Keine Beschreibung',
'economy.shop.itemLine': '**{name}** (`{id}`) {price} | Bestand: {stock}\n{description}',
'economy.shop.purchased': 'Du hast **{name}** gekauft. Kontostand: {balance}',
'economy.inventory.empty': 'Dein Inventar ist leer.',
'economy.inventory.itemLine': '- {name} × {quantity}',
'economy.leaderboard.empty': 'Noch keine Einträge auf der Bestenliste.',
'economy.leaderboard.line': '{rank}. {user} {amount}',
'economy.admin.give': '{amount} an {user} vergeben. Neuer Kontostand: {balance}',
'economy.admin.remove': '{amount} von {user} entfernt. Neuer Kontostand: {balance}',
'economy.admin.reset': 'Economy-Daten von {user} zurückgesetzt.',
'economy.blackjack.title': 'Blackjack',
'economy.blackjack.hand': 'Einsatz: {bet}\n\n**Du:** {playerHand} ({playerTotal})\n**Dealer:** {dealerHand} ({dealerTotal})',
'economy.blackjack.button.hit': 'Hit',
'economy.blackjack.button.stand': 'Stand',
'economy.blackjack.outcome.win': 'Du gewinnst {bet}!',
'economy.blackjack.outcome.lose': 'Du verlierst deinen Einsatz.',
'economy.blackjack.outcome.push': 'Unentschieden Einsatz zurück.',
'economy.blackjack.outcome.blackjack': 'Blackjack! Du gewinnst {bet}!',
'economy.blackjack.expired': 'Dieses Blackjack-Spiel ist abgelaufen oder existiert nicht mehr.',
'economy.blackjack.notOwner': 'Nur der Spieler kann diese Buttons nutzen.',
'utility.error.invalid_when': 'Ungültiger Zeitpunkt. Nutze z. B. 30m oder ein ISO-Datum in der Zukunft.',
'utility.error.invalid_datetime': 'Ungültiges Datum/Uhrzeit.',
'utility.error.invalid_poll_options': 'Es werden 25 Optionen benötigt (kommagetrennt).',
'utility.error.channel_not_found': 'Kanal nicht gefunden.',
'utility.error.role_not_found': 'Rolle nicht gefunden.',
'utility.error.poll_not_found': 'Umfrage nicht gefunden.',
'utility.error.reminder_channel_unavailable': 'Erinnerungskanal nicht erreichbar.',
'utility.poll.created': 'Umfrage erstellt.',
'utility.poll.closed': 'Diese Umfrage ist geschlossen.',
'utility.poll.notFound': 'Umfrage nicht gefunden.',
'utility.poll.invalidOption': 'Ungültige Option.',
'utility.poll.alreadyVoted': 'Du hast bereits abgestimmt.',
'utility.poll.voted': 'Stimme für **{option}** gezählt.',
'utility.poll.votes': 'Stimmen',
'utility.poll.endsAt': 'Endet {time}',
'utility.poll.multiSelect': 'Mehrfachauswahl',
'utility.poll.multiSelectHint': 'Du kannst mehrere Optionen wählen.',
'utility.remindme.created': 'Erinnerung erstellt (ID: `{id}`).',
'utility.reminders.none': 'Keine Erinnerungen.',
'utility.reminders.line': '`{id}` — {time}: {content}',
'utility.reminders.deleted': 'Erinnerung gelöscht.',
'utility.reminders.notFound': 'Erinnerung nicht gefunden.',
'utility.afk.set': 'AFK gesetzt. Grund: {reason}',
'utility.afk.welcomeBack': 'Willkommen zurück! Du warst AFK: {reason}',
'utility.afk.mention': '{user} ist AFK: {reason}',
'utility.avatar.title': 'Avatar von {user}',
'utility.banner.title': 'Banner von {user}',
'utility.banner.none': 'Kein Banner gesetzt.',
'utility.emoji.noSource': 'Anhang oder URL erforderlich.',
'utility.emoji.added': 'Emoji {emoji} hinzugefügt.',
'utility.emoji.removed': 'Emoji entfernt.',
'utility.emoji.notFound': 'Emoji nicht gefunden.',
'utility.emoji.messageNotFound': 'Nachricht nicht gefunden.',
'utility.emoji.noEmojiInMessage': 'Kein Custom-Emoji in der Nachricht.',
'utility.emoji.stolen': 'Emoji {emoji} gestohlen.',
'utility.sticker.noSource': 'Anhang oder URL erforderlich.',
'utility.sticker.added': 'Sticker **{name}** hinzugefügt.',
'utility.embed.sent': 'Embed gesendet.',
'utility.embed.invalidColor': 'Ungültige Farbe. Nutze z. B. 6366F1.',
'utility.snipe.none': 'Nichts zum Anzeigen.',
'utility.snipe.privacy':
'Datenschutz: Snipe speichert gelöschte/bearbeitete Nachrichten kurzzeitig (1 h) in Redis.',
'utility.snipe.deletedAt': 'Gelöschte Nachricht von {user}',
'utility.snipe.editedAt': 'Bearbeitete Nachricht von {user}',
'utility.translate.noContent': 'Kein übersetzbare Textinhalt.',
'utility.translate.failed': 'Übersetzung fehlgeschlagen.',
'utility.translate.result': '**Übersetzung:** {text}',
'fun.8ball.result': '**Frage:** {question}\n**Antwort:** {answer}',
'fun.dice.result': 'Würfel ({sides} Seiten): {rolls}',
'fun.flip.result': 'Die Münze zeigt: **{result}**',
'fun.rps.win': 'Du wähltest **{player}**, ich **{bot}**. Du gewinnst!',
'fun.rps.lose': 'Du wähltest **{player}**, ich **{bot}**. Du verlierst!',
'fun.rps.draw': 'Beide wählten **{player}**. Unentschieden!',
'fun.rps.choice.rock': 'Stein',
'fun.rps.choice.paper': 'Papier',
'fun.rps.choice.scissors': 'Schere',
'fun.choose.result': 'Ich wähle: **{choice}**',
'fun.choose.tooFew': 'Gib mindestens zwei Optionen an (kommagetrennt).',
'fun.trivia.title': 'Trivia',
'fun.trivia.question': '**{question}**\n\n{options}',
'fun.trivia.footer': 'Nur {user} kann antworten.',
'fun.trivia.correct': '✅ Richtig!',
'fun.trivia.incorrect': '❌ Falsch!',
'fun.trivia.notOwner': 'Nur der Initiator kann diese Frage beantworten.',
'fun.tictactoe.title': 'Tic-Tac-Toe',
'fun.tictactoe.board': '❌: {x} | ⭕: {o}\n\n{board}',
'fun.tictactoe.challenge': '{challenger} fordert {opponent} zu Tic-Tac-Toe heraus!',
'fun.tictactoe.turn': 'Am Zug: {user}',
'fun.tictactoe.won': '{user} gewinnt!',
'fun.tictactoe.draw': 'Unentschieden!',
'fun.tictactoe.invalidMove': 'Ungültiger Zug.',
'fun.connect4.title': 'Connect 4',
'fun.connect4.board': '🔴: {red} | 🟡: {yellow}\n\n{board}',
'fun.connect4.challenge': '{challenger} fordert {opponent} zu Connect 4 heraus!',
'fun.connect4.turn': 'Am Zug: {user}',
'fun.connect4.won': '{user} gewinnt!',
'fun.connect4.draw': 'Unentschieden!',
'fun.connect4.invalidMove': 'Diese Spalte ist voll.',
'fun.hangman.title': 'Hangman',
'fun.hangman.progress': 'Wort: {word}\nFehler: {wrong}/{max}\nGeraten: {guessed}',
'fun.hangman.won': 'Gewonnen! Das Wort war **{word}**.',
'fun.hangman.lost': 'Verloren! Das Wort war **{word}**.',
'fun.common.notParticipant': 'Du bist nicht an diesem Spiel beteiligt.',
'fun.common.expired': 'Dieses Spiel ist abgelaufen oder existiert nicht mehr.',
'fun.common.selfChallenge': 'Du kannst dich nicht selbst herausfordern.',
'fun.common.botChallenge': 'Du kannst keinen Bot herausfordern.',
'fun.media.disabled': 'Medien-Commands sind auf diesem Server deaktiviert.',
'fun.media.fetchFailed': 'Bild konnte nicht geladen werden. Versuche es später erneut.',
'fun.cat.title': 'Random Cat',
'fun.dog.title': 'Random Dog'
};
const en: Dictionary = {
'generic.noPermission': 'You do not have permission for this action.',
@@ -148,7 +294,152 @@ const en: Dictionary = {
'verification.error.generic': 'Verification failed.',
'verification.captcha.success': 'Captcha passed. You have been verified.',
'verification.captcha.invalid': 'Captcha invalid or expired.',
'verification.captcha.title': 'Nexumi Verification'
'verification.captcha.title': 'Nexumi Verification',
'leveling.disabled': 'Leveling is disabled on this server.',
'leveling.rank.title': 'Rank card',
'leveling.leaderboard.title': 'Leaderboard',
'leveling.leaderboard.empty': 'No XP data yet.',
'leveling.leaderboard.entry': '{rank}. {user} — Level {level} ({xp} XP)',
'leveling.leaderboard.footer': 'Top {count} members',
'leveling.levelUp.default': 'Congratulations {user}, you reached level {level}!',
'leveling.xp.give.success': 'Gave {amount} XP to {user}. New level: {level}.',
'leveling.xp.remove.success': 'Removed {amount} XP from {user}. New level: {level}.',
'leveling.xp.reset.success': 'Reset XP for {user}.',
'leveling.xp.invalidAmount': 'Amount must be greater than 0.',
'leveling.user.notFound': 'User not found on this server.',
'economy.error.disabled': 'The economy module is disabled on this server.',
'economy.error.insufficient': 'You do not have enough money.',
'economy.error.cooldown': 'Cooldown active. Try again in {seconds} seconds.',
'economy.error.invalid_amount': 'Invalid amount.',
'economy.error.self_transfer': 'You cannot send money to yourself.',
'economy.error.not_found': 'Entry not found.',
'economy.error.out_of_stock': 'This item is out of stock.',
'economy.error.role_assign_failed': 'Purchase succeeded, but the role could not be assigned.',
'economy.error.game_in_progress': 'You already have an active blackjack game.',
'economy.balance.result': 'Balance of {user}: {amount}',
'economy.daily.success': 'Daily reward: {amount}. New balance: {balance}',
'economy.weekly.success': 'Weekly reward: {amount}. New balance: {balance}',
'economy.work.success': 'You earned {amount}. New balance: {balance}',
'economy.pay.success': 'You sent {amount} to {user}. Your balance: {balance}',
'economy.gamble.win': 'You won! +{amount}. Balance: {balance}',
'economy.gamble.lose': 'You lost! -{amount}. Balance: {balance}',
'economy.slots.win': '{reels}\nWinnings: {payout}. Balance: {balance}',
'economy.slots.lose': '{reels}\nNo win. Balance: {balance}',
'economy.coinflip.win': '{result} you picked {choice}. You won! +{amount}. Balance: {balance}',
'economy.coinflip.lose': '{result} you picked {choice}. You lost! -{amount}. Balance: {balance}',
'economy.choice.heads': 'Heads',
'economy.choice.tails': 'Tails',
'economy.shop.empty': 'The shop is empty.',
'economy.shop.noDescription': 'No description',
'economy.shop.itemLine': '**{name}** (`{id}`) {price} | Stock: {stock}\n{description}',
'economy.shop.purchased': 'You purchased **{name}**. Balance: {balance}',
'economy.inventory.empty': 'Your inventory is empty.',
'economy.inventory.itemLine': '- {name} × {quantity}',
'economy.leaderboard.empty': 'No leaderboard entries yet.',
'economy.leaderboard.line': '{rank}. {user} {amount}',
'economy.admin.give': 'Gave {amount} to {user}. New balance: {balance}',
'economy.admin.remove': 'Removed {amount} from {user}. New balance: {balance}',
'economy.admin.reset': 'Reset economy data for {user}.',
'economy.blackjack.title': 'Blackjack',
'economy.blackjack.hand': 'Bet: {bet}\n\n**You:** {playerHand} ({playerTotal})\n**Dealer:** {dealerHand} ({dealerTotal})',
'economy.blackjack.button.hit': 'Hit',
'economy.blackjack.button.stand': 'Stand',
'economy.blackjack.outcome.win': 'You win {bet}!',
'economy.blackjack.outcome.lose': 'You lose your bet.',
'economy.blackjack.outcome.push': 'Push bet returned.',
'economy.blackjack.outcome.blackjack': 'Blackjack! You win {bet}!',
'economy.blackjack.expired': 'This blackjack game expired or no longer exists.',
'economy.blackjack.notOwner': 'Only the player can use these buttons.',
'utility.error.invalid_when': 'Invalid time. Use e.g. 30m or a future ISO datetime.',
'utility.error.invalid_datetime': 'Invalid date/time.',
'utility.error.invalid_poll_options': 'Provide 25 comma-separated options.',
'utility.error.channel_not_found': 'Channel not found.',
'utility.error.role_not_found': 'Role not found.',
'utility.error.poll_not_found': 'Poll not found.',
'utility.error.reminder_channel_unavailable': 'Reminder channel unavailable.',
'utility.poll.created': 'Poll created.',
'utility.poll.closed': 'This poll is closed.',
'utility.poll.notFound': 'Poll not found.',
'utility.poll.invalidOption': 'Invalid option.',
'utility.poll.alreadyVoted': 'You already voted.',
'utility.poll.voted': 'Vote counted for **{option}**.',
'utility.poll.votes': 'votes',
'utility.poll.endsAt': 'Ends {time}',
'utility.poll.multiSelect': 'Multi-select',
'utility.poll.multiSelectHint': 'You may select multiple options.',
'utility.remindme.created': 'Reminder created (ID: `{id}`).',
'utility.reminders.none': 'No reminders.',
'utility.reminders.line': '`{id}` — {time}: {content}',
'utility.reminders.deleted': 'Reminder deleted.',
'utility.reminders.notFound': 'Reminder not found.',
'utility.afk.set': 'AFK set. Reason: {reason}',
'utility.afk.welcomeBack': 'Welcome back! You were AFK: {reason}',
'utility.afk.mention': '{user} is AFK: {reason}',
'utility.avatar.title': 'Avatar of {user}',
'utility.banner.title': 'Banner of {user}',
'utility.banner.none': 'No banner set.',
'utility.emoji.noSource': 'Attachment or URL required.',
'utility.emoji.added': 'Added emoji {emoji}.',
'utility.emoji.removed': 'Emoji removed.',
'utility.emoji.notFound': 'Emoji not found.',
'utility.emoji.messageNotFound': 'Message not found.',
'utility.emoji.noEmojiInMessage': 'No custom emoji in message.',
'utility.emoji.stolen': 'Stole emoji {emoji}.',
'utility.sticker.noSource': 'Attachment or URL required.',
'utility.sticker.added': 'Added sticker **{name}**.',
'utility.embed.sent': 'Embed sent.',
'utility.embed.invalidColor': 'Invalid color. Use e.g. 6366F1.',
'utility.snipe.none': 'Nothing to show.',
'utility.snipe.privacy':
'Privacy: Snipe stores deleted/edited messages briefly (1 h) in Redis.',
'utility.snipe.deletedAt': 'Deleted message by {user}',
'utility.snipe.editedAt': 'Edited message by {user}',
'utility.translate.noContent': 'No translatable text content.',
'utility.translate.failed': 'Translation failed.',
'utility.translate.result': '**Translation:** {text}',
'fun.8ball.result': '**Question:** {question}\n**Answer:** {answer}',
'fun.dice.result': 'Dice ({sides} sides): {rolls}',
'fun.flip.result': 'The coin shows: **{result}**',
'fun.rps.win': 'You picked **{player}**, I picked **{bot}**. You win!',
'fun.rps.lose': 'You picked **{player}**, I picked **{bot}**. You lose!',
'fun.rps.draw': 'Both picked **{player}**. Draw!',
'fun.rps.choice.rock': 'Rock',
'fun.rps.choice.paper': 'Paper',
'fun.rps.choice.scissors': 'Scissors',
'fun.choose.result': 'I choose: **{choice}**',
'fun.choose.tooFew': 'Provide at least two comma-separated options.',
'fun.trivia.title': 'Trivia',
'fun.trivia.question': '**{question}**\n\n{options}',
'fun.trivia.footer': 'Only {user} can answer.',
'fun.trivia.correct': '✅ Correct!',
'fun.trivia.incorrect': '❌ Wrong!',
'fun.trivia.notOwner': 'Only the initiator can answer this question.',
'fun.tictactoe.title': 'Tic-Tac-Toe',
'fun.tictactoe.board': '❌: {x} | ⭕: {o}\n\n{board}',
'fun.tictactoe.challenge': '{challenger} challenges {opponent} to Tic-Tac-Toe!',
'fun.tictactoe.turn': 'Turn: {user}',
'fun.tictactoe.won': '{user} wins!',
'fun.tictactoe.draw': 'Draw!',
'fun.tictactoe.invalidMove': 'Invalid move.',
'fun.connect4.title': 'Connect 4',
'fun.connect4.board': '🔴: {red} | 🟡: {yellow}\n\n{board}',
'fun.connect4.challenge': '{challenger} challenges {opponent} to Connect 4!',
'fun.connect4.turn': 'Turn: {user}',
'fun.connect4.won': '{user} wins!',
'fun.connect4.draw': 'Draw!',
'fun.connect4.invalidMove': 'This column is full.',
'fun.hangman.title': 'Hangman',
'fun.hangman.progress': 'Word: {word}\nWrong: {wrong}/{max}\nGuessed: {guessed}',
'fun.hangman.won': 'You won! The word was **{word}**.',
'fun.hangman.lost': 'You lost! The word was **{word}**.',
'fun.common.notParticipant': 'You are not a participant in this game.',
'fun.common.expired': 'This game expired or no longer exists.',
'fun.common.selfChallenge': 'You cannot challenge yourself.',
'fun.common.botChallenge': 'You cannot challenge a bot.',
'fun.media.disabled': 'Media commands are disabled on this server.',
'fun.media.fetchFailed': 'Could not load image. Try again later.',
'fun.cat.title': 'Random Cat',
'fun.dog.title': 'Random Dog'
};
const locales: Record<Locale, Dictionary> = { de, en };

View File

@@ -0,0 +1,112 @@
import { z } from 'zod';
/** XP required to go from level L to L+1. */
export function xpForNextLevel(level: number): number {
return 5 * level * level + 50 * level + 100;
}
/** Total XP needed to reach a given level from 0. */
export function totalXpForLevel(level: number): number {
let total = 0;
for (let i = 0; i < level; i += 1) {
total += xpForNextLevel(i);
}
return total;
}
/** Derive level from cumulative XP. */
export function levelFromXp(xp: number): number {
let level = 0;
let remaining = Math.max(0, xp);
while (remaining >= xpForNextLevel(level)) {
remaining -= xpForNextLevel(level);
level += 1;
if (level > 1000) {
break;
}
}
return level;
}
export function progressInLevel(xp: number): { level: number; intoLevel: number; needed: number } {
const level = levelFromXp(xp);
const base = totalXpForLevel(level);
const intoLevel = xp - base;
const needed = xpForNextLevel(level);
return { level, intoLevel, needed };
}
export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export const LevelUpModeSchema = z.enum(['CHANNEL', 'DM', 'OFF']);
export type LevelUpMode = z.infer<typeof LevelUpModeSchema>;
export const MultiplierMapSchema = z.record(z.string(), z.number().positive());
export type MultiplierMap = z.infer<typeof MultiplierMapSchema>;
export function parseMultiplierMap(raw: unknown): MultiplierMap {
const parsed = MultiplierMapSchema.safeParse(raw);
return parsed.success ? parsed.data : {};
}
export function formatCurrency(amount: number, symbol: string, name: string): string {
return `${symbol} ${amount.toLocaleString()} ${name}`;
}
export const EIGHT_BALL_ANSWERS = {
de: [
'Ja.',
'Nein.',
'Vielleicht.',
'Frag später nochmal.',
'Sieht gut aus.',
'Zweifelhaft.',
'Auf keinen Fall.',
'Absolut.'
],
en: [
'Yes.',
'No.',
'Maybe.',
'Ask again later.',
'Looks good.',
'Doubtful.',
'Absolutely not.',
'Absolutely.'
]
} as const;
export const TRIVIA_QUESTIONS = [
{
q: { de: 'Welche Farbe hat der Himmel an einem klaren Tag?', en: 'What color is the sky on a clear day?' },
options: { de: ['Blau', 'Grün', 'Rot', 'Gelb'], en: ['Blue', 'Green', 'Red', 'Yellow'] },
answer: 0
},
{
q: { de: 'Wie viele Kontinente gibt es?', en: 'How many continents are there?' },
options: { de: ['5', '6', '7', '8'], en: ['5', '6', '7', '8'] },
answer: 2
},
{
q: { de: 'Welches Tier ist ein Säugetier?', en: 'Which animal is a mammal?' },
options: { de: ['Hai', 'Delphin', 'Thunfisch', 'Lachs'], en: ['Shark', 'Dolphin', 'Tuna', 'Salmon'] },
answer: 1
},
{
q: { de: 'Was ist 2 + 2?', en: 'What is 2 + 2?' },
options: { de: ['3', '4', '5', '22'], en: ['3', '4', '5', '22'] },
answer: 1
},
{
q: { de: 'Hauptstadt von Frankreich?', en: 'Capital of France?' },
options: { de: ['Berlin', 'Madrid', 'Paris', 'Rom'], en: ['Berlin', 'Madrid', 'Paris', 'Rome'] },
answer: 2
}
] as const;
export const HANGMAN_WORDS = {
de: ['DISCORD', 'NEXUMI', 'SERVER', 'MODERATION', 'BOT', 'KANAL', 'ROLLE'],
en: ['DISCORD', 'NEXUMI', 'SERVER', 'MODERATION', 'BOT', 'CHANNEL', 'ROLE']
} as const;