Files
Nexumi/apps/bot/src/modules/economy/games.ts
smueller fa5910ec43 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.
2026-07-22 12:49:17 +02:00

421 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}