const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); const { randomUUID } = require("node:crypto"); const { getCollections, getCookieBalance, transferCookies, temporaryEphemeralReply, getUnixTime } = require("./cookieEconomy"); /* * ============================================================ * CONFIG * ============================================================ */ 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." } ); } /* * Resume an unfinished game. */ const active = await getActiveBlackjack( interaction.user.id, interaction.guildId ); if (active) { return interaction.update({ embeds: [ buildGameEmbed( active ) ], components: buildGameButtons( active ) }); } const balance = await getCookieBalance( interaction.user.id ); 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 refunds 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, allowCustomBet = false ) { /* * Normal wager buttons must use one of our * predefined values. * * All In and Run It Back are allowed to use * custom wager amounts. */ if ( !allowCustomBet && !BLACKJACK_BETS.includes( bet ) ) { return temporaryEphemeralReply( interaction, { content: "That wager isn't available." } ); } /* * Every wager, predefined or custom, must * still be a positive safe integer. */ if ( !Number.isSafeInteger( bet ) || bet <= 0 ) { return temporaryEphemeralReply( interaction, { content: "That wager isn't valid." } ); } const ownerId = getBlackjackSessionOwner( interaction ); if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your Blackjack table." } ); } const { db } = getCollections(); const games = db.collection( GAME_COLLECTION ); /* * Prevent multiple active hands. */ const existing = await getActiveBlackjack( interaction.user.id, interaction.guildId ); if (existing) { return temporaryEphemeralReply( interaction, { content: "You already have an active Blackjack hand." } ); } const playerId = interaction.user.id; const houseId = interaction.client.user.id; const [ playerBalance, houseBalance ] = await Promise.all([ getCookieBalance( playerId ), getCookieBalance( houseId ) ]); if ( playerBalance < bet ) { return temporaryEphemeralReply( interaction, { content: `You need **${bet.toLocaleString()} Cookies** for that wager.` } ); } /* * Natural Blackjack returns: * * wager + 3:2 winnings * * = 2.5x total payout. * * The wager will move into the house before * resolution, so the house currently needs * enough to cover the additional 1.5x. */ if ( houseBalance < Math.ceil( bet * 1.5 ) ) { return temporaryEphemeralReply( interaction, { content: "The Crumb Saucer vault can't currently cover that wager." } ); } /* * Take wager. */ try { await transferCookies( playerId, houseId, bet ); } catch (error) { if ( error.message === "INSUFFICIENT_COOKIES" ) { return temporaryEphemeralReply( interaction, { content: "Your Cookie balance changed before the hand could begin." } ); } throw error; } /* * ======================================================== * REAL 52-CARD DECK * ======================================================== */ const deck = createDeck(); shuffleDeck( deck ); const playerCards = [ deck.pop(), deck.pop() ]; const dealerCards = [ deck.pop(), deck.pop() ]; const game = { eventType: "Blackjack", gameId: randomUUID(), userId: playerId, guildId: interaction.guildId, channelId: interaction.channelId, state: "Playing", bet, originalBet: bet, doubled: false, deck, playerCards, dealerCards, createdAt: getUnixTime() }; await games.insertOne( game ); /* * ======================================================== * NATURAL BLACKJACK CHECK * ======================================================== */ const playerBlackjack = isBlackjack( playerCards ); const dealerBlackjack = isBlackjack( dealerCards ); if ( playerBlackjack || dealerBlackjack ) { /* * Both natural Blackjack = Push. */ if ( playerBlackjack && dealerBlackjack ) { return finishBlackjack( interaction, game, "Push" ); } if (playerBlackjack) { return finishBlackjack( interaction, game, "Blackjack" ); } return finishBlackjack( interaction, game, "DealerBlackjack" ); } return interaction.update({ embeds: [ buildGameEmbed( game ) ], components: buildGameButtons( game ) }); } /* * ============================================================ * ALL IN * ============================================================ */ async function startBlackjackAllIn( interaction ) { const ownerId = getBlackjackSessionOwner( interaction ); if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your Blackjack table." } ); } /* * Resolve balance at click time. */ const balance = await getCookieBalance( interaction.user.id ); if ( balance <= 0 ) { return temporaryEphemeralReply( interaction, { content: "You don't have any Cookies to go All In with." } ); } /* * true allows the balance to be used even though * it likely isn't one of BLACKJACK_BETS. */ return startBlackjack( interaction, balance, true ); } /* * ============================================================ * RUN IT BACK * ============================================================ * * Reuses only the previous BET. * * A completely new shuffled deck and hand are created. */ async function runBlackjackBack( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; /* * Expected: * * crumbBlackjackResult:: */ if ( parts[0] !== "crumbBlackjackResult" ) { return temporaryEphemeralReply( interaction, { content: "This Blackjack result can no longer be replayed." } ); } const ownerId = parts[1]; const bet = Number( parts[2] ); if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your Blackjack session." } ); } if ( !Number.isSafeInteger( bet ) || bet <= 0 ) { return temporaryEphemeralReply( interaction, { content: "The previous wager is no longer valid." } ); } /* * Reuse the exact previous wager. * * true is required because a previous All In bet * may not be part of BLACKJACK_BETS. */ return startBlackjack( interaction, bet, true ); } /* * ============================================================ * 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 } } ); const updated = { ...game, deck, playerCards }; /* * Bust. */ if ( score > 21 ) { return finishBlackjack( interaction, updated, "Bust" ); } /* * Automatically stand on exactly 21. */ if ( score === 21 ) { return standBlackjack( interaction, updated ); } 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 ]; /* * Dealer stands on 17. */ 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"; } /* * Same value = Push. */ 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 only on initial two cards. */ if ( game.playerCards.length !== 2 || game.doubled ) { return temporaryEphemeralReply( interaction, { content: "You can only Double on your initial two cards." } ); } const playerBalance = await getCookieBalance( interaction.user.id ); if ( playerBalance < 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 ]; /* * Double receives exactly one additional card. */ 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 automatically stands. */ return standBlackjack( interaction, updated ); } /* * ============================================================ * FINISH GAME * ============================================================ */ async function finishBlackjack( interaction, game, result ) { const houseId = interaction.client.user.id; let payout = 0; switch (result) { /* * Normal win. */ case "Win": case "DealerBust": payout = game.bet * 2; break; /* * Push: * * Return exactly the wager. */ case "Push": payout = game.bet; break; /* * Natural Blackjack: * * stake + 3:2 winnings. */ case "Blackjack": payout = Math.floor( game.bet * 2.5 ); break; /* * Bust / Loss / Dealer Blackjack. */ default: payout = 0; } /* * Pay player. */ 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." ) .setFooter({ text: `crumbBlackjackResult:${game.userId}:${game.bet}` }) ], components: buildResultButtons() }); } await transferCookies( houseId, game.userId, payout ); } /* * Mark game finished. */ 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 description = [ "**Dealer**", formatCards( game.dealerCards ), `Score: **${getHandValue(game.dealerCards)}**`, "", "**You**", formatCards( game.playerCards ), `Score: **${getHandValue(game.playerCards)}**` ]; if ( result === "Push" ) { description.push( "", "🤝 **Push — your wager has been refunded.**" ); } const embed = new EmbedBuilder() .setTitle( getResultTitle( result ) ) .setDescription( description.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()} 🍪` } ) /* * Owner + previous exact wager. */ .setFooter({ text: `crumbBlackjackResult:${game.userId}:${game.bet}` }); return interaction.update({ embeds: [ embed ], components: buildResultButtons() }); } /* * ============================================================ * 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}` }); } /* * ============================================================ * BET 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( "crumbBlackjackAllIn" ) .setLabel( "All In" ) .setEmoji( "🔥" ) .setStyle( ButtonStyle.Danger ), new ButtonBuilder() .setCustomId( "crumbSaucerBack" ) .setLabel( "Back to Crumb Saucer" ) .setEmoji( "◀️" ) .setStyle( ButtonStyle.Secondary ) ) ]; } /* * ============================================================ * ACTIVE GAME BUTTONS * ============================================================ */ 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 ) ) ]; } /* * ============================================================ * RESULT BUTTONS * ============================================================ */ function buildResultButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbBlackjackRunItBack" ) .setLabel( "Run It Back" ) .setEmoji( "🔁" ) .setStyle( ButtonStyle.Success ), new ButtonBuilder() .setCustomId( "crumbSaucerBlackjack" ) .setLabel( "Change Bet" ) .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( " " ); } /* * ============================================================ * DATABASE 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, state: "Playing" }); } /* * ============================================================ * SESSION OWNER * ============================================================ */ function getBlackjackSessionOwner( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; if ( parts[0] === "crumbSaucer" ) { return parts[1]; } if ( parts[0] === "crumbSaucerBlackjack" ) { return parts[1]; } /* * Active hand: * * crumbBlackjack:: */ if ( parts[0] === "crumbBlackjack" ) { return parts[2]; } /* * Finished result: * * crumbBlackjackResult:: */ if ( parts[0] === "crumbBlackjackResult" ) { return parts[1]; } return null; } /* * ============================================================ * 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 — Wager Refunded"; case "Bust": return "💥 Bust!"; case "DealerBlackjack": return "🃏 Dealer Blackjack"; default: return "🃏 Dealer Wins"; } } /* * ============================================================ * EXPORTS * ============================================================ */ module.exports = { showBlackjack, startBlackjack, startBlackjackAllIn, hitBlackjack, standBlackjack, doubleBlackjack, runBlackjackBack };