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 { 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 { 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 { 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 { return new ActionRowBuilder().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[] }> { 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[]; 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 }; }