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

@@ -0,0 +1,420 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type APIEmbed } from 'discord.js';
import { formatCurrency, randomInt } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import type { Locale } from '@nexumi/shared';
import { tf } from '@nexumi/shared';
import {
addBalance,
assertEconomyEnabled,
deductBalance,
EconomyError,
getEconomyConfig
} from './service.js';
const BLACKJACK_TTL_SECONDS = 300;
const BLACKJACK_KEY_PREFIX = 'eco:bj:';
export const BLACKJACK_BUTTON_PREFIX = 'eco:bj:';
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] as const;
const SUITS = ['H', 'D', 'C', 'S'] as const;
type Card = `${(typeof RANKS)[number]}${(typeof SUITS)[number]}`;
export type BlackjackState = {
userId: string;
guildId: string;
bet: number;
deck: Card[];
playerHand: Card[];
dealerHand: Card[];
finished: boolean;
};
const SLOT_SYMBOLS = ['🍒', '🍋', '🔔', '⭐', '💎', '7⃣'] as const;
function buildDeck(): Card[] {
const deck: Card[] = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
deck.push(`${rank}${suit}`);
}
}
for (let i = deck.length - 1; i > 0; i -= 1) {
const j = randomInt(0, i);
[deck[i], deck[j]] = [deck[j]!, deck[i]!];
}
return deck;
}
function cardValue(card: Card): number {
const rank = card.slice(0, card.length - 1);
if (rank === 'A') {
return 11;
}
if (rank === 'K' || rank === 'Q' || rank === 'J') {
return 10;
}
return Number(rank);
}
export function handValue(hand: Card[]): number {
let total = hand.reduce((sum, card) => sum + cardValue(card), 0);
let aces = hand.filter((card) => card.startsWith('A')).length;
while (total > 21 && aces > 0) {
total -= 10;
aces -= 1;
}
return total;
}
function isBlackjack(hand: Card[]): boolean {
return hand.length === 2 && handValue(hand) === 21;
}
function formatHand(hand: Card[], hideFirst = false): string {
if (hideFirst && hand.length > 0) {
return `🂠 ${hand.slice(1).join(' ')}`;
}
return hand.join(' ');
}
function blackjackKey(guildId: string, userId: string): string {
return `${BLACKJACK_KEY_PREFIX}${guildId}:${userId}`;
}
async function saveBlackjackState(context: BotContext, state: BlackjackState): Promise<void> {
await context.redis.set(
blackjackKey(state.guildId, state.userId),
JSON.stringify(state),
'EX',
BLACKJACK_TTL_SECONDS
);
}
async function loadBlackjackState(
context: BotContext,
guildId: string,
userId: string
): Promise<BlackjackState | null> {
const raw = await context.redis.get(blackjackKey(guildId, userId));
if (!raw) {
return null;
}
return JSON.parse(raw) as BlackjackState;
}
async function deleteBlackjackState(context: BotContext, guildId: string, userId: string): Promise<void> {
await context.redis.del(blackjackKey(guildId, userId));
}
function drawCard(state: BlackjackState): Card {
const card = state.deck.pop();
if (!card) {
throw new Error('Blackjack deck empty');
}
return card;
}
export function buildBlackjackButtons(locale: Locale, guildId: string, userId: string): ActionRowBuilder<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`${BLACKJACK_BUTTON_PREFIX}hit:${guildId}:${userId}`)
.setLabel(tf(locale, 'economy.blackjack.button.hit', {}))
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId(`${BLACKJACK_BUTTON_PREFIX}stand:${guildId}:${userId}`)
.setLabel(tf(locale, 'economy.blackjack.button.stand', {}))
.setStyle(ButtonStyle.Secondary)
);
}
export function buildBlackjackEmbed(
locale: Locale,
state: BlackjackState,
config: { currencySymbol: string; currencyName: string },
revealDealer: boolean,
outcome?: 'win' | 'lose' | 'push' | 'blackjack'
): APIEmbed {
const playerTotal = handValue(state.playerHand);
const dealerTotal = revealDealer ? handValue(state.dealerHand) : handValue(state.dealerHand.slice(1));
let description = tf(locale, 'economy.blackjack.hand', {
playerHand: formatHand(state.playerHand),
playerTotal: String(playerTotal),
dealerHand: formatHand(state.dealerHand, !revealDealer),
dealerTotal: revealDealer ? String(dealerTotal) : '?',
bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName)
});
if (outcome) {
const outcomeKey = `economy.blackjack.outcome.${outcome}` as const;
description += `\n\n${tf(locale, outcomeKey, {
bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName)
})}`;
}
return {
title: tf(locale, 'economy.blackjack.title', {}),
description,
color: outcome === 'win' || outcome === 'blackjack' ? 0x22c55e : outcome === 'push' ? 0xeab308 : 0x6366f1
};
}
async function finishBlackjack(
context: BotContext,
state: BlackjackState
): Promise<{ state: BlackjackState; outcome: 'win' | 'lose' | 'push' | 'blackjack'; payout: number }> {
const playerTotal = handValue(state.playerHand);
const dealerTotal = handValue(state.dealerHand);
let outcome: 'win' | 'lose' | 'push' | 'blackjack';
let payout = 0;
if (playerTotal > 21) {
outcome = 'lose';
} else if (isBlackjack(state.playerHand) && !isBlackjack(state.dealerHand)) {
outcome = 'blackjack';
payout = Math.floor(state.bet * 2.5);
} else if (dealerTotal > 21 || playerTotal > dealerTotal) {
outcome = 'win';
payout = state.bet * 2;
} else if (playerTotal === dealerTotal) {
outcome = 'push';
payout = state.bet;
} else {
outcome = 'lose';
}
if (payout > 0) {
await addBalance(context.prisma, state.guildId, state.userId, payout);
}
state.finished = true;
await deleteBlackjackState(context, state.guildId, state.userId);
return { state, outcome, payout };
}
export async function startBlackjack(
context: BotContext,
guildId: string,
userId: string,
bet: number,
locale: Locale
): Promise<{ embed: APIEmbed; components: ActionRowBuilder<ButtonBuilder>[] }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(bet) || bet <= 0) {
throw new EconomyError('invalid_amount');
}
const existing = await loadBlackjackState(context, guildId, userId);
if (existing && !existing.finished) {
throw new EconomyError('game_in_progress');
}
await deductBalance(context.prisma, guildId, userId, bet);
const state: BlackjackState = {
userId,
guildId,
bet,
deck: buildDeck(),
playerHand: [],
dealerHand: [],
finished: false
};
state.playerHand.push(drawCard(state), drawCard(state));
state.dealerHand.push(drawCard(state), drawCard(state));
const config = await getEconomyConfig(context.prisma, guildId);
if (isBlackjack(state.playerHand) || isBlackjack(state.dealerHand)) {
const { state: finished, outcome } = await finishBlackjack(context, state);
return {
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
components: []
};
}
await saveBlackjackState(context, state);
return {
embed: buildBlackjackEmbed(locale, state, config, false),
components: [buildBlackjackButtons(locale, guildId, userId)]
};
}
export async function hitBlackjack(
context: BotContext,
guildId: string,
userId: string,
locale: Locale
): Promise<{ embed: APIEmbed; components: ActionRowBuilder<ButtonBuilder>[]; finished: boolean }> {
const state = await loadBlackjackState(context, guildId, userId);
if (!state || state.finished) {
throw new EconomyError('not_found');
}
if (state.userId !== userId) {
throw new EconomyError('not_found');
}
state.playerHand.push(drawCard(state));
const config = await getEconomyConfig(context.prisma, guildId);
if (handValue(state.playerHand) > 21) {
const { state: finished, outcome } = await finishBlackjack(context, state);
return {
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
components: [],
finished: true
};
}
await saveBlackjackState(context, state);
return {
embed: buildBlackjackEmbed(locale, state, config, false),
components: [buildBlackjackButtons(locale, guildId, userId)],
finished: false
};
}
export async function standBlackjack(
context: BotContext,
guildId: string,
userId: string,
locale: Locale
): Promise<{ embed: APIEmbed; finished: true }> {
const state = await loadBlackjackState(context, guildId, userId);
if (!state || state.finished) {
throw new EconomyError('not_found');
}
if (state.userId !== userId) {
throw new EconomyError('not_found');
}
while (handValue(state.dealerHand) < 17) {
state.dealerHand.push(drawCard(state));
}
const config = await getEconomyConfig(context.prisma, guildId);
const { state: finished, outcome } = await finishBlackjack(context, state);
return {
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
finished: true
};
}
export async function playGamble(
context: BotContext,
guildId: string,
userId: string,
amount: number
): Promise<{ won: boolean; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
await deductBalance(context.prisma, guildId, userId, amount);
const won = Math.random() < 0.5;
if (won) {
const updated = await addBalance(context.prisma, guildId, userId, amount * 2);
return { won: true, balance: updated.balance };
}
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
where: { guildId_userId: { guildId, userId } }
});
return { won: false, balance: member.balance };
}
export async function playSlots(
context: BotContext,
guildId: string,
userId: string,
amount: number
): Promise<{ reels: string[]; multiplier: number; payout: number; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
await deductBalance(context.prisma, guildId, userId, amount);
const reels = [
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!,
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!,
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!
];
let multiplier = 0;
if (reels[0] === reels[1] && reels[1] === reels[2]) {
multiplier = reels[0] === '7⃣' ? 10 : reels[0] === '💎' ? 5 : 3;
} else if (reels[0] === reels[1] || reels[1] === reels[2] || reels[0] === reels[2]) {
multiplier = 1.5;
}
const payout = Math.floor(amount * multiplier);
let balance: number;
if (payout > 0) {
const updated = await addBalance(context.prisma, guildId, userId, payout);
balance = updated.balance;
} else {
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
where: { guildId_userId: { guildId, userId } }
});
balance = member.balance;
}
return { reels, multiplier, payout, balance };
}
export type CoinflipChoice = 'heads' | 'tails';
export async function playCoinflipBet(
context: BotContext,
guildId: string,
userId: string,
amount: number,
choice: CoinflipChoice
): Promise<{ result: CoinflipChoice; won: boolean; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
await deductBalance(context.prisma, guildId, userId, amount);
const result: CoinflipChoice = Math.random() < 0.5 ? 'heads' : 'tails';
const won = result === choice;
if (won) {
const updated = await addBalance(context.prisma, guildId, userId, amount * 2);
return { result, won: true, balance: updated.balance };
}
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
where: { guildId_userId: { guildId, userId } }
});
return { result, won: false, balance: member.balance };
}
export function parseBlackjackButtonCustomId(customId: string): {
action: 'hit' | 'stand';
guildId: string;
userId: string;
} | null {
if (!customId.startsWith(BLACKJACK_BUTTON_PREFIX)) {
return null;
}
const parts = customId.slice(BLACKJACK_BUTTON_PREFIX.length).split(':');
if (parts.length !== 3) {
return null;
}
const [action, guildId, userId] = parts;
if (action !== 'hit' && action !== 'stand') {
return null;
}
if (!guildId || !userId) {
return null;
}
return { action, guildId, userId };
}