- 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.
421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
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 };
|
||
}
|