const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); const { randomUUID } = require("node:crypto"); const { getCollections, getCookieBalance, transferCookies, temporaryEphemeralReply, getUnixTime } = require("./cookieEconomy"); const GAME_COLLECTION = "items_games"; const BLACKJACK_BETS = [ 10, 50, 100, 500, 1000 ]; /* * ============================================================ * SHOW BLACKJACK MENU * ============================================================ */ async function showBlackjack( interaction ) { const ownerId = getBlackjackSessionOwner( interaction ); if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your Crumb Saucer session." } ); } const balance = await getCookieBalance( interaction.user.id ); const active = await getActiveBlackjack( interaction.user.id, interaction.guildId ); if (active) { return interaction.update({ embeds: [ buildGameEmbed( active ) ], components: buildGameButtons( active ) }); } const embed = new EmbedBuilder() .setTitle( "🃏 Crumb Saucer Blackjack" ) .setDescription( [ "Beat the dealer without going over **21**.", "", "**Dealer stands on 17.**", "**Blackjack pays 3:2.**", "**Push returns your wager.**", "", "Choose your wager below." ].join("\n") ) .addFields({ name: "Your Vault", value: `**${balance.toLocaleString()} 🍪**` }) .setFooter({ text: `crumbSaucerBlackjack:${interaction.user.id}` }); return interaction.update({ embeds: [ embed ], components: buildBetButtons() }); } /* * ============================================================ * START GAME * ============================================================ */ async function startBlackjack( interaction, bet ) { if ( !BLACKJACK_BETS.includes( bet ) ) { return temporaryEphemeralReply( interaction, { content: "That wager isn't available." } ); } const owner = getBlackjackMenuOwner( interaction ); if ( owner !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your Blackjack table." } ); } const { db } = getCollections(); const games = db.collection( GAME_COLLECTION ); const existing = await getActiveBlackjack( interaction.user.id, interaction.guildId ); if (existing) { return temporaryEphemeralReply( interaction, { content: "You already have an active Blackjack hand." } ); } const balance = await getCookieBalance( interaction.user.id ); if ( balance < bet ) { return temporaryEphemeralReply( interaction, { content: `You need **${bet.toLocaleString()} Cookies** for that wager.` } ); } /* * The house must be able to cover a natural Blackjack. * * Total Blackjack payout is 2.5x stake after * the player's stake has already moved to the house. */ const houseId = interaction.client.user.id; const houseBalance = await getCookieBalance( houseId ); if ( houseBalance < Math.ceil( bet * 1.5 ) ) { return temporaryEphemeralReply( interaction, { content: "The Crumb Saucer vault can't currently cover that wager." } ); } try { await transferCookies( interaction.user.id, houseId, bet ); } catch (error) { if ( error.message === "INSUFFICIENT_COOKIES" ) { return temporaryEphemeralReply( interaction, { content: "Your Cookie balance changed before the hand could begin." } ); } throw error; } const deck = createDeck(); shuffleDeck( deck ); const playerCards = [ deck.pop(), deck.pop() ]; const dealerCards = [ deck.pop(), deck.pop() ]; const game = { eventType: "Blackjack", gameId: randomUUID(), userId: interaction.user.id, guildId: interaction.guildId, channelId: interaction.channelId, state: "Playing", bet, originalBet: bet, doubled: false, deck, playerCards, dealerCards, createdAt: getUnixTime() }; await games.insertOne( game ); /* * Natural Blackjack checks. */ const playerBlackjack = isBlackjack( playerCards ); const dealerBlackjack = isBlackjack( dealerCards ); if ( playerBlackjack || dealerBlackjack ) { return resolveInitialBlackjack( interaction, game, playerBlackjack, dealerBlackjack ); } return interaction.update({ embeds: [ buildGameEmbed( game ) ], components: buildGameButtons( game ) }); } /* * ============================================================ * HIT * ============================================================ */ async function hitBlackjack( interaction ) { const game = await getGameFromInteraction( interaction ); if (!game) { return temporaryEphemeralReply( interaction, { content: "This Blackjack hand no longer exists." } ); } if ( game.userId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your Blackjack hand." } ); } if ( game.state !== "Playing" ) { return temporaryEphemeralReply( interaction, { content: "This hand is already finished." } ); } const deck = [...game.deck]; const playerCards = [...game.playerCards]; playerCards.push( deck.pop() ); const score = getHandValue( playerCards ); const { db } = getCollections(); const games = db.collection( GAME_COLLECTION ); await games.updateOne( { gameId: game.gameId, state: "Playing" }, { $set: { deck, playerCards } } ); if ( score > 21 ) { return finishBlackjack( interaction, { ...game, deck, playerCards }, "Bust" ); } if ( score === 21 ) { return standBlackjack( interaction, { ...game, deck, playerCards } ); } const updated = { ...game, deck, playerCards }; return interaction.update({ embeds: [ buildGameEmbed( updated ) ], components: buildGameButtons( updated ) }); } /* * ============================================================ * STAND * ============================================================ */ async function standBlackjack( interaction, suppliedGame = null ) { let game = suppliedGame; if (!game) { game = await getGameFromInteraction( interaction ); } if (!game) { return temporaryEphemeralReply( interaction, { content: "This Blackjack hand no longer exists." } ); } if ( game.userId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your Blackjack hand." } ); } const deck = [...game.deck]; const dealerCards = [...game.dealerCards]; 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 finishBlackjack( interaction, updated, result ); } /* * ============================================================ * DOUBLE * ============================================================ */ async function doubleBlackjack( interaction ) { const game = await getGameFromInteraction( interaction ); if (!game) { return temporaryEphemeralReply( interaction, { content: "This Blackjack hand no longer exists." } ); } if ( game.userId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your Blackjack hand." } ); } /* * Double is only allowed on the initial 2-card hand. */ if ( game.playerCards.length !== 2 ) { return temporaryEphemeralReply( interaction, { content: "You can only Double on your initial two cards." } ); } const balance = await getCookieBalance( interaction.user.id ); if ( balance < game.originalBet ) { return temporaryEphemeralReply( interaction, { content: `You need another **${game.originalBet.toLocaleString()} Cookies** to Double.` } ); } try { await transferCookies( interaction.user.id, interaction.client.user.id, game.originalBet ); } catch (error) { if ( error.message === "INSUFFICIENT_COOKIES" ) { return temporaryEphemeralReply( interaction, { content: "Your balance changed before you could Double." } ); } throw error; } const deck = [...game.deck]; const playerCards = [...game.playerCards]; playerCards.push( deck.pop() ); const updated = { ...game, deck, playerCards, bet: game.bet + game.originalBet, doubled: true }; const { db } = getCollections(); await db .collection( GAME_COLLECTION ) .updateOne( { gameId: game.gameId, state: "Playing" }, { $set: { deck, playerCards, bet: updated.bet, doubled: true } } ); if ( getHandValue( playerCards ) > 21 ) { return finishBlackjack( interaction, updated, "Bust" ); } /* * Double always automatically stands. */ return standBlackjack( interaction, updated ); } /* * ============================================================ * NATURAL BLACKJACK * ============================================================ */ async function resolveInitialBlackjack( interaction, game, playerBlackjack, dealerBlackjack ) { if ( playerBlackjack && dealerBlackjack ) { return finishBlackjack( interaction, game, "Push" ); } if (playerBlackjack) { return finishBlackjack( interaction, game, "Blackjack" ); } return finishBlackjack( interaction, game, "DealerBlackjack" ); } /* * ============================================================ * FINISH GAME * ============================================================ */ async function finishBlackjack( interaction, game, result ) { const houseId = interaction.client.user.id; let payout = 0; switch (result) { /* * Regular win returns stake + matching winnings. */ case "Win": case "DealerBust": payout = game.bet * 2; break; /* * Push simply returns the player's stake. */ case "Push": payout = game.bet; break; /* * Natural Blackjack: * * original stake + 3:2 winnings * * = 2.5x total returned */ case "Blackjack": payout = Math.floor( game.bet * 2.5 ); break; /* * Loss / Bust / Dealer Blackjack: * house keeps the stake. */ default: payout = 0; } if ( payout > 0 ) { const houseBalance = await getCookieBalance( houseId ); if ( houseBalance < payout ) { console.error( `[Blackjack] House cannot cover payout ${payout} for ${game.userId}` ); return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( "🃏 Blackjack Error" ) .setDescription( "The Crumb Saucer vault couldn't cover the payout. Please contact an administrator." ) ], components: buildReturnButtons() }); } await transferCookies( houseId, game.userId, payout ); } const { db } = getCollections(); await db .collection( GAME_COLLECTION ) .updateOne( { gameId: game.gameId, state: "Playing" }, { $set: { state: "Finished", result, payout, finishedAt: getUnixTime(), dealerCards: game.dealerCards, playerCards: game.playerCards } } ); const playerBalance = await getCookieBalance( game.userId ); const profit = payout - game.bet; const embed = new EmbedBuilder() .setTitle( getResultTitle( result ) ) .setDescription( [ "**Dealer**", formatCards( game.dealerCards ), `Score: **${getHandValue(game.dealerCards)}**`, "", "**You**", formatCards( game.playerCards ), `Score: **${getHandValue(game.playerCards)}**` ].join("\n") ) .addFields( { name: "Bet", value: `${game.bet.toLocaleString()} 🍪`, inline: true }, { name: "Payout", value: `${payout.toLocaleString()} 🍪`, inline: true }, { name: "Profit", value: `${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🍪`, inline: true }, { name: "Your Vault", value: `${playerBalance.toLocaleString()} 🍪` } ) .setFooter({ text: `crumbSaucerBlackjack:${game.userId}` }); return interaction.update({ embeds: [ embed ], components: buildReturnButtons() }); } /* * ============================================================ * GAME EMBED * ============================================================ */ function buildGameEmbed( game ) { const playerScore = getHandValue( game.playerCards ); const dealerVisible = game.dealerCards[0]; return new EmbedBuilder() .setTitle( "🃏 Crumb Saucer Blackjack" ) .setDescription( [ "**Dealer**", `${formatCard(dealerVisible)} 🂠`, "", "**You**", formatCards( game.playerCards ), `Score: **${playerScore}**` ].join("\n") ) .addFields( { name: "Bet", value: `**${game.bet.toLocaleString()} 🍪**`, inline: true }, { name: "Status", value: "**Your turn**", inline: true } ) .setFooter({ text: `crumbBlackjack:${game.gameId}:${game.userId}` }); } /* * ============================================================ * BUTTONS * ============================================================ */ function buildBetButtons() { return [ new ActionRowBuilder() .addComponents( ...BLACKJACK_BETS.map( bet => new ButtonBuilder() .setCustomId( `crumbBlackjackBet${bet}` ) .setLabel( bet.toLocaleString() ) .setEmoji( "🍪" ) .setStyle( bet >= 500 ? ButtonStyle.Primary : ButtonStyle.Secondary ) ) ), new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbSaucerBack" ) .setLabel( "Back to Crumb Saucer" ) .setEmoji( "◀️" ) .setStyle( ButtonStyle.Secondary ) ) ]; } function buildGameButtons( game ) { const canDouble = game.playerCards.length === 2 && !game.doubled; return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbBlackjackHit" ) .setLabel( "Hit" ) .setEmoji( "➕" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "crumbBlackjackStand" ) .setLabel( "Stand" ) .setEmoji( "✋" ) .setStyle( ButtonStyle.Success ), new ButtonBuilder() .setCustomId( "crumbBlackjackDouble" ) .setLabel( "Double" ) .setEmoji( "💰" ) .setDisabled( !canDouble ) .setStyle( ButtonStyle.Danger ) ) ]; } function buildReturnButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbSaucerBlackjack" ) .setLabel( "Play Again" ) .setEmoji( "🃏" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "crumbSaucerBack" ) .setLabel( "Crumb Saucer" ) .setEmoji( "◀️" ) .setStyle( ButtonStyle.Secondary ) ) ]; } /* * ============================================================ * CARDS * ============================================================ */ 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; } 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] ]; } } function getHandValue( cards ) { let total = 0; let aces = 0; for ( const card of cards ) { switch ( card.rank ) { case "A": total += 11; aces++; break; case "K": case "Q": case "J": total += 10; break; default: total += Number( card.rank ); } } while ( total > 21 && aces > 0 ) { total -= 10; aces--; } return total; } function isBlackjack( cards ) { return ( cards.length === 2 && getHandValue( cards ) === 21 ); } function formatCard( card ) { return ( `**${card.rank}${card.suit}**` ); } function formatCards( cards ) { return cards .map( formatCard ) .join( " " ); } /* * ============================================================ * LOOKUPS * ============================================================ */ async function getActiveBlackjack( userId, guildId ) { const { db } = getCollections(); return db .collection( GAME_COLLECTION ) .findOne({ eventType: "Blackjack", userId, guildId, state: "Playing" }); } async function getGameFromInteraction( interaction ) { const footer = interaction.message .embeds[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; if ( parts[0] !== "crumbBlackjack" ) { return null; } const gameId = parts[1]; const { db } = getCollections(); return db .collection( GAME_COLLECTION ) .findOne({ eventType: "Blackjack", gameId }); } /* * ============================================================ * MENU OWNERS * ============================================================ */ function getBlackjackSessionOwner( interaction ) { const footer = interaction.message .embeds[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; /* * Main Crumb Saucer * * crumbSaucer: */ if ( parts[0] === "crumbSaucer" ) { return parts[1]; } /* * Blackjack wager / result menu * * crumbSaucerBlackjack: */ if ( parts[0] === "crumbSaucerBlackjack" ) { return parts[1]; } /* * Active Blackjack hand * * crumbBlackjack:: */ if ( parts[0] === "crumbBlackjack" ) { return parts[2]; } return null; } function getBlackjackMenuOwner( interaction ) { const footer = interaction.message .embeds[0] ?.footer ?.text; const [ type, userId ] = footer?.split(":") ?? []; if ( type !== "crumbSaucerBlackjack" ) { return null; } return userId; } /* * ============================================================ * RESULT TITLES * ============================================================ */ function getResultTitle( result ) { switch ( result ) { case "Blackjack": return "🃏 BLACKJACK!"; case "Win": return "🎉 You Win!"; case "DealerBust": return "💥 Dealer Bust!"; case "Push": return "🤝 Push"; case "Bust": return "💥 Bust!"; case "DealerBlackjack": return "🃏 Dealer Blackjack"; default: return "🃏 Dealer Wins"; } } module.exports = { showBlackjack, startBlackjack, hitBlackjack, standBlackjack, doubleBlackjack };