// utils/nutBlackjack.js const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); const { randomUUID } = require("node:crypto"); const { getCollections, getNutBalance, transferNuts, temporaryEphemeralReply, getUnixTime } = require("./nutEconomy"); /* * ============================================================ * CONFIG * ============================================================ */ const SESSION_COLLECTION = "sessions_blackjack"; const BLACKJACK_BETS = [ 10, 50, 100, 500, 1000 ]; /* * ============================================================ * SHOW BLACKJACK MENU * ============================================================ */ async function showNutBlackjack( interaction ) { const ownerId = getBlackjackSessionOwner( interaction ); if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "Das ist nicht deine Nut-Hub-Session." } ); } /* * Resume active hand if one exists. */ const active = await getActiveBlackjack( interaction.user.id ); if (active) { return interaction.update({ embeds: [ buildGameEmbed( active ) ], components: buildGameButtons( active ) }); } const balance = await getNutBalance( interaction.user.id ); const embed = new EmbedBuilder() .setTitle( "🃏 Nut Blackjack" ) .setDescription( [ "Schlag den Dealer ohne über **21** zu kommen.", "", "**Dealer stands on 17.**", "**Blackjack pays 3:2.**", "**Push returns your wager.**", "", "Choose your wager." ].join("\n") ) .addFields({ name: "Deine Nüsse", value: `**${balance.toLocaleString()} 🥜**` }) .setFooter({ text: `nutBlackjackMenu:${interaction.user.id}` }); return interaction.update({ embeds: [ embed ], components: buildBetButtons() }); } /* * ============================================================ * START BLACKJACK * ============================================================ */ async function startNutBlackjack( interaction, bet ) { if ( !BLACKJACK_BETS.includes( bet ) ) { return temporaryEphemeralReply( interaction, { content: "Dieser Blackjack-Einsatz ist nicht verfügbar." } ); } const ownerId = getBlackjackSessionOwner( interaction ); if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "Das ist nicht dein Blackjack-Tisch." } ); } /* * Prevent multiple active hands. */ const existing = await getActiveBlackjack( interaction.user.id ); if (existing) { return temporaryEphemeralReply( interaction, { content: "Du hast bereits eine aktive Blackjack-Hand." } ); } const playerId = interaction.user.id; const houseId = interaction.client.user.id; const [ playerBalance, houseBalance ] = await Promise.all([ getNutBalance( playerId ), getNutBalance( houseId ) ]); if ( playerBalance < bet ) { return temporaryEphemeralReply( interaction, { content: `Du brauchst **${bet.toLocaleString()} Nüsse**.` } ); } /* * Natural Blackjack can require 2.5x total * returned to the player. * * Since the player's wager moves into the house * before resolving, the house needs enough liquidity * to cover the extra 1.5x. */ if ( houseBalance < Math.ceil( bet * 1.5 ) ) { return temporaryEphemeralReply( interaction, { content: "Arthonor's Nut Stash kann diesen Einsatz aktuell nicht absichern." } ); } try { await transferNuts( playerId, houseId, bet ); } catch (error) { if ( error.message === "INSUFFICIENT_NUTS" ) { return temporaryEphemeralReply( interaction, { content: "Dein Nut-Bestand hat sich verändert." } ); } throw error; } /* * Fresh real 52-card deck. */ const deck = createDeck(); shuffleDeck( deck ); const playerCards = [ deck.pop(), deck.pop() ]; const dealerCards = [ deck.pop(), deck.pop() ]; const game = { sessionId: randomUUID(), userId: playerId, state: "Playing", bet, originalBet: bet, doubled: false, deck, playerCards, dealerCards, createdAt: getUnixTime() }; const { db } = getCollections(); await db .collection( SESSION_COLLECTION ) .insertOne( game ); /* * Natural Blackjack check. */ const playerBlackjack = isBlackjack( playerCards ); const dealerBlackjack = isBlackjack( dealerCards ); if ( playerBlackjack || dealerBlackjack ) { if ( playerBlackjack && dealerBlackjack ) { return finishNutBlackjack( interaction, game, "Push" ); } if (playerBlackjack) { return finishNutBlackjack( interaction, game, "Blackjack" ); } return finishNutBlackjack( interaction, game, "DealerBlackjack" ); } return interaction.update({ embeds: [ buildGameEmbed( game ) ], components: buildGameButtons( game ) }); } /* * ============================================================ * HIT * ============================================================ */ async function hitNutBlackjack( interaction ) { const game = await getBlackjackFromInteraction( interaction ); if ( !game || game.state !== "Playing" ) { return temporaryEphemeralReply( interaction, { content: "Diese Blackjack-Hand existiert nicht mehr." } ); } if ( game.userId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "Das ist nicht deine Blackjack-Hand." } ); } const deck = [ ...game.deck ]; const playerCards = [ ...game.playerCards ]; playerCards.push( deck.pop() ); const updated = { ...game, deck, playerCards }; await updateBlackjackSession( game.sessionId, { deck, playerCards } ); const score = getHandValue( playerCards ); if ( score > 21 ) { return finishNutBlackjack( interaction, updated, "Bust" ); } /* * Auto-stand on 21. */ if ( score === 21 ) { return standNutBlackjack( interaction, updated ); } return interaction.update({ embeds: [ buildGameEmbed( updated ) ], components: buildGameButtons( updated ) }); } /* * ============================================================ * STAND * ============================================================ */ async function standNutBlackjack( interaction, suppliedGame = null ) { const game = suppliedGame ?? await getBlackjackFromInteraction( interaction ); if (!game) { return temporaryEphemeralReply( interaction, { content: "Diese Blackjack-Hand existiert nicht mehr." } ); } if ( game.userId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "Das ist nicht deine Blackjack-Hand." } ); } const deck = [ ...game.deck ]; const dealerCards = [ ...game.dealerCards ]; /* * Dealer stands on all 17s. */ while ( getHandValue( dealerCards ) < 17 ) { dealerCards.push( deck.pop() ); } const updated = { ...game, deck, dealerCards }; const playerScore = getHandValue( updated.playerCards ); const dealerScore = getHandValue( dealerCards ); let result; if ( dealerScore > 21 ) { result = "DealerBust"; } else if ( playerScore > dealerScore ) { result = "Win"; } else if ( playerScore < dealerScore ) { result = "Loss"; } else { result = "Push"; } return finishNutBlackjack( interaction, updated, result ); } /* * ============================================================ * DOUBLE * ============================================================ */ async function doubleNutBlackjack( interaction ) { const game = await getBlackjackFromInteraction( interaction ); if ( !game || game.state !== "Playing" ) { return temporaryEphemeralReply( interaction, { content: "Diese Blackjack-Hand existiert nicht mehr." } ); } if ( game.userId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "Das ist nicht deine Blackjack-Hand." } ); } /* * Double is only possible on the original hand. */ if ( game.playerCards.length !== 2 || game.doubled ) { return temporaryEphemeralReply( interaction, { content: "Double ist nur mit den ersten zwei Karten möglich." } ); } const playerBalance = await getNutBalance( interaction.user.id ); if ( playerBalance < game.originalBet ) { return temporaryEphemeralReply( interaction, { content: `Du brauchst weitere **${game.originalBet.toLocaleString()} Nüsse** für Double.` } ); } try { await transferNuts( interaction.user.id, interaction.client.user.id, game.originalBet ); } catch (error) { if ( error.message === "INSUFFICIENT_NUTS" ) { return temporaryEphemeralReply( interaction, { content: "Du hast nicht mehr genug Nüsse für Double." } ); } throw error; } const deck = [ ...game.deck ]; const playerCards = [ ...game.playerCards ]; /* * Double receives exactly one additional card. */ playerCards.push( deck.pop() ); const updated = { ...game, deck, playerCards, bet: game.bet + game.originalBet, doubled: true }; await updateBlackjackSession( game.sessionId, { deck, playerCards, bet: updated.bet, doubled: true } ); if ( getHandValue( playerCards ) > 21 ) { return finishNutBlackjack( interaction, updated, "Bust" ); } /* * Double automatically stands. */ return standNutBlackjack( interaction, updated ); } /* * ============================================================ * FINISH BLACKJACK * ============================================================ */ async function finishNutBlackjack( interaction, game, result ) { let payout = 0; switch (result) { /* * Normal win: * * return original wager + winnings. */ case "Win": case "DealerBust": payout = game.bet * 2; break; /* * Push: * * wager returned. */ case "Push": payout = game.bet; break; /* * Natural Blackjack: * * wager + 3:2 winnings * = 2.5x returned. */ case "Blackjack": payout = Math.floor( game.bet * 2.5 ); break; /* * Bust / Loss / Dealer Blackjack: * house keeps wager. */ default: payout = 0; } if ( payout > 0 ) { try { await transferNuts( interaction.client.user.id, game.userId, payout ); } catch (error) { console.error( "[Nut Blackjack] Payout failed:", error ); const errorEmbed = new EmbedBuilder() .setTitle( "🃏 Blackjack Error" ) .setDescription( [ "The hand resolved, but Arthonor's Nut Stash could not cover the payout.", "", "Please contact an administrator." ].join("\n") ) .setFooter({ text: `nutBlackjackMenu:${game.userId}` }); return interaction.update({ embeds: [ errorEmbed ], components: buildResultButtons() }); } } /* * sessions_* represents currently active state. * * Finished hand = remove session. */ const { db } = getCollections(); await db .collection( SESSION_COLLECTION ) .deleteOne({ sessionId: game.sessionId }); const balance = await getNutBalance( game.userId ); const profit = payout - game.bet; const embed = new EmbedBuilder() .setTitle( getResultTitle( result ) ) .setDescription( [ "**Dealer**", formatCards( game.dealerCards ), `Score: **${getHandValue(game.dealerCards)}**`, "", "**Du**", formatCards( game.playerCards ), `Score: **${getHandValue(game.playerCards)}**` ].join("\n") ) .addFields( { name: "Einsatz", value: `${game.bet.toLocaleString()} 🥜`, inline: true }, { name: "Payout", value: `${payout.toLocaleString()} 🥜`, inline: true }, { name: "Profit", value: `${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🥜`, inline: true }, { name: "Deine Nüsse", value: `${balance.toLocaleString()} 🥜` } ) .setFooter({ text: `nutBlackjackMenu:${game.userId}` }); return interaction.update({ embeds: [ embed ], components: buildResultButtons() }); } /* * ============================================================ * ACTIVE GAME EMBED * ============================================================ */ function buildGameEmbed( game ) { const playerScore = getHandValue( game.playerCards ); return new EmbedBuilder() .setTitle( "🃏 Nut Blackjack" ) .setDescription( [ "**Dealer**", `${formatCard(game.dealerCards[0])} 🂠`, "", "**Du**", formatCards( game.playerCards ), `Score: **${playerScore}**` ].join("\n") ) .addFields( { name: "Einsatz", value: `${game.bet.toLocaleString()} 🥜`, inline: true }, { name: "Status", value: "**Dein Zug**", inline: true } ) .setFooter({ text: `nutBlackjack:${game.sessionId}:${game.userId}` }); } /* * ============================================================ * BET BUTTONS * ============================================================ */ function buildBetButtons() { return [ new ActionRowBuilder() .addComponents( ...BLACKJACK_BETS.map( bet => new ButtonBuilder() .setCustomId( `nutBlackjackBet${bet}` ) .setLabel( bet.toLocaleString() ) .setEmoji( "🥜" ) .setStyle( bet >= 500 ? ButtonStyle.Primary : ButtonStyle.Secondary ) ) ), new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "nutHubBack" ) .setLabel( "Nut Hub" ) .setEmoji( "◀️" ) .setStyle( ButtonStyle.Secondary ) ) ]; } /* * ============================================================ * GAME BUTTONS * ============================================================ */ function buildGameButtons( game ) { const canDouble = game.playerCards.length === 2 && !game.doubled; return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "nutBlackjackHit" ) .setLabel( "Hit" ) .setEmoji( "➕" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "nutBlackjackStand" ) .setLabel( "Stand" ) .setEmoji( "✋" ) .setStyle( ButtonStyle.Success ), new ButtonBuilder() .setCustomId( "nutBlackjackDouble" ) .setLabel( "Double" ) .setEmoji( "💰" ) .setDisabled( !canDouble ) .setStyle( ButtonStyle.Danger ) ) ]; } /* * ============================================================ * RESULT BUTTONS * ============================================================ */ function buildResultButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "nutHubBlackjack" ) .setLabel( "Play Again" ) .setEmoji( "🃏" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "nutHubBack" ) .setLabel( "Nut Hub" ) .setEmoji( "◀️" ) .setStyle( ButtonStyle.Secondary ) ) ]; } /* * ============================================================ * DECK * ============================================================ */ function createDeck() { const suits = [ "♠️", "♥️", "♦️", "♣️" ]; const ranks = [ "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K" ]; const deck = []; for ( const suit of suits ) { for ( const rank of ranks ) { deck.push({ rank, suit }); } } return deck; } /* * Fisher-Yates shuffle. */ function shuffleDeck( deck ) { for ( let i = deck.length - 1; i > 0; i-- ) { const j = Math.floor( Math.random() * ( i + 1 ) ); [ deck[i], deck[j] ] = [ deck[j], deck[i] ]; } } /* * ============================================================ * HAND VALUE * ============================================================ */ function getHandValue( cards ) { let total = 0; let aces = 0; for ( const card of cards ) { if ( card.rank === "A" ) { total += 11; aces++; } else if ( [ "J", "Q", "K" ].includes( card.rank ) ) { total += 10; } else { total += Number( card.rank ); } } /* * Convert Aces from 11 → 1 as required. */ while ( total > 21 && aces > 0 ) { total -= 10; aces--; } return total; } function isBlackjack( cards ) { return ( cards.length === 2 && getHandValue( cards ) === 21 ); } /* * ============================================================ * CARD DISPLAY * ============================================================ */ function formatCard( card ) { return `**${card.rank}${card.suit}**`; } function formatCards( cards ) { return cards .map( formatCard ) .join( " " ); } /* * ============================================================ * ACTIVE SESSION * ============================================================ */ async function getActiveBlackjack( userId ) { const { db } = getCollections(); return db .collection( SESSION_COLLECTION ) .findOne({ userId, state: "Playing" }); } /* * ============================================================ * GET SESSION FROM MESSAGE * ============================================================ */ async function getBlackjackFromInteraction( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; if ( parts[0] !== "nutBlackjack" ) { return null; } const sessionId = parts[1]; const { db } = getCollections(); return db .collection( SESSION_COLLECTION ) .findOne({ sessionId, state: "Playing" }); } /* * ============================================================ * UPDATE SESSION * ============================================================ */ async function updateBlackjackSession( sessionId, fields ) { const { db } = getCollections(); return db .collection( SESSION_COLLECTION ) .updateOne( { sessionId, state: "Playing" }, { $set: fields } ); } /* * ============================================================ * SESSION OWNERSHIP * ============================================================ */ function getBlackjackSessionOwner( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; /* * Nut Hub: * * nutHub: */ if ( parts[0] === "nutHub" ) { return parts[1]; } /* * Blackjack menu/result: * * nutBlackjackMenu: */ if ( parts[0] === "nutBlackjackMenu" ) { return parts[1]; } /* * Active Blackjack: * * nutBlackjack:: */ if ( parts[0] === "nutBlackjack" ) { return parts[2]; } return null; } /* * ============================================================ * RESULT TITLES * ============================================================ */ function getResultTitle( result ) { switch ( result ) { case "Blackjack": return "🃏 BLACKJACK!"; case "Win": return "🎉 Du gewinnst!"; case "DealerBust": return "💥 Dealer Bust!"; case "Push": return "🤝 Push"; case "Bust": return "💥 Bust!"; case "DealerBlackjack": return "🃏 Dealer Blackjack"; default: return "🃏 Dealer gewinnt"; } } /* * ============================================================ * EXPORTS * ============================================================ */ module.exports = { showNutBlackjack, startNutBlackjack, hitNutBlackjack, standNutBlackjack, doubleNutBlackjack };