const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); const { getCookieBalance, transferCookies, temporaryEphemeralReply, randomItem } = require("./cookieEconomy"); /* * ============================================================ * SLOT CONFIG * ============================================================ */ const SLOT_BETS = [ 10, 50, 100, 500, 1000 ]; const SLOT_SYMBOLS = [ "🍒", "🍋", "🍪", "⭐", "💎" ]; /* * Weighted outcomes. */ const SLOT_OUTCOMES = [ { weight: 300, type: "loss", multiplier: 0 }, { weight: 390, type: "pair", multiplier: 0.5 }, { weight: 150, type: "triple", symbol: "🍋", multiplier: 1.5 }, { weight: 80, type: "triple", symbol: "🍒", multiplier: 2 }, { weight: 45, type: "triple", symbol: "🍪", multiplier: 3 }, { weight: 25, type: "triple", symbol: "⭐", multiplier: 5 }, { weight: 10, type: "triple", symbol: "💎", multiplier: 10 } ]; /* * ============================================================ * MAIN MENU * ============================================================ */ async function showCrumbSaucer( interaction ) { /* * If this is being called from a button, * verify that the player owns the current * Crumb Saucer / game session. * * This prevents another user from pressing * "Crumb Saucer" / "Back" on somebody else's * game message and taking over their session. */ if ( interaction.isButton() ) { const ownerId = getCrumbSaucerSessionOwner( 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 embed = buildMainMenuEmbed( interaction.user, balance ); const components = buildMainMenuButtons(); /* * Button: * update existing menu/game message. */ if ( interaction.isButton() ) { return interaction.update({ embeds: [ embed ], components }); } /* * Slash command: * create a new Crumb Saucer. */ return interaction.reply({ embeds: [ embed ], components }); } /* * ============================================================ * SLOT MENU * ============================================================ */ async function showSlots( interaction ) { const ownerId = getSlotsSessionOwner( 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 embed = new EmbedBuilder() .setTitle( "🎰 Crumb Saucer Slots" ) .setDescription( [ "Welcome to the Cookie reels.", "", "Choose your wager below.", "", "🍋 **Triple Lemon** — x1.5", "🍒 **Triple Cherry** — x2", "🍪 **Triple Cookie** — x3", "⭐ **Triple Star** — x5", "💎 **Jackpot** — x10" ].join("\n") ) .addFields({ name: "Your Vault", value: `**${balance.toLocaleString()} 🍪**` }) .setFooter({ text: `crumbSaucerSlots:${interaction.user.id}` }); return interaction.update({ embeds: [ embed ], components: buildSlotBetButtons() }); } /* * ============================================================ * PLAY SLOTS * ============================================================ */ async function playSlots( interaction, bet ) { const ownerId = getSlotsSessionOwner( interaction ); if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "These aren't your slots. Open your own Crumb Saucer session." } ); } if ( !SLOT_BETS.includes( bet ) ) { return temporaryEphemeralReply( interaction, { content: "That wager isn't available." } ); } const playerId = interaction.user.id; const vaultId = interaction.client.user.id; const [ playerBalance, vaultBalance ] = await Promise.all([ getCookieBalance( playerId ), getCookieBalance( vaultId ) ]); if ( playerBalance < bet ) { return temporaryEphemeralReply( interaction, { content: `You need **${bet.toLocaleString()} Cookies** for that wager.` } ); } /* * Worst-case payout is x10. * * Once the house receives the player's bet, * it needs another 9x available to cover * the complete x10 payout. */ if ( vaultBalance < bet * 9 ) { return temporaryEphemeralReply( interaction, { content: "The Crumb Saucer vault doesn't currently have enough Cookies to cover that wager." } ); } /* * Take wager first. */ try { await transferCookies( playerId, vaultId, bet ); } catch (error) { if ( error.message === "INSUFFICIENT_COOKIES" ) { return temporaryEphemeralReply( interaction, { content: "Your Cookie balance changed before the spin could begin." } ); } throw error; } /* * Lock the buttons while spinning. */ await interaction.update({ embeds: [ buildSpinningEmbed( bet, generateRandomGrid(), "🎰 Spinning..." ) ], components: [] }); await sleep( 700 ); await interaction.editReply({ embeds: [ buildSpinningEmbed( bet, generateRandomGrid(), "🎰 The reels are moving..." ) ], components: [] }); await sleep( 700 ); await interaction.editReply({ embeds: [ buildSpinningEmbed( bet, generateRandomGrid(), "🎰 Almost..." ) ], components: [] }); await sleep( 700 ); /* * Actual outcome is determined only now. */ const outcome = rollSlotOutcome(); const grid = generateOutcomeGrid( outcome ); const payout = Math.floor( bet * outcome.multiplier ); /* * House pays winnings. */ if ( payout > 0 ) { try { await transferCookies( vaultId, playerId, payout ); } catch (error) { console.error( "CRITICAL: Crumb Saucer payout failed:", error ); return interaction.editReply({ embeds: [ new EmbedBuilder() .setTitle( "🎰 Crumb Saucer Error" ) .setDescription( "The reels stopped, but the payout system had a problem. Please contact an administrator." ) .setFooter({ text: `crumbSaucerSlotsResult:${playerId}:${bet}` }) ], components: buildResultButtons() }); } } const [ playerAfter, vaultAfter ] = await Promise.all([ getCookieBalance( playerId ), getCookieBalance( vaultId ) ]); const profit = payout - bet; const resultEmbed = new EmbedBuilder() .setTitle( "🎰 Crumb Saucer Slots" ) .setDescription( [ buildSlotDisplay( grid ), "", `### ${getSlotResultName(outcome)}` ].join("\n") ) .addFields( { name: "Bet", value: `${bet.toLocaleString()} 🍪`, inline: true }, { name: "Multiplier", value: `x${outcome.multiplier}`, inline: true }, { name: "Payout", value: `${payout.toLocaleString()} 🍪`, inline: true }, { name: "Profit", value: `${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🍪`, inline: true }, { name: "Your Vault", value: `${playerAfter.toLocaleString()} 🍪`, inline: true }, { name: "House Vault", value: `${vaultAfter.toLocaleString()} 🍪`, inline: true } ) /* * Store owner + previous wager. * * Run It Back can now replay the exact * same wager without opening the bet menu. */ .setFooter({ text: `crumbSaucerSlotsResult:${playerId}:${bet}` }); return interaction.editReply({ embeds: [ resultEmbed ], components: buildResultButtons() }); } /* * ============================================================ * RUN IT BACK * ============================================================ */ async function runSlotsBack( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; if ( parts[0] !== "crumbSaucerSlotsResult" ) { return temporaryEphemeralReply( interaction, { content: "This Slots result can no longer be replayed." } ); } const ownerId = parts[1]; const bet = Number( parts[2] ); if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "These aren't your slots." } ); } if ( !SLOT_BETS.includes( bet ) ) { return temporaryEphemeralReply( interaction, { content: "The previous wager is no longer available." } ); } return playSlots( interaction, bet ); } /* * ============================================================ * MAIN MENU EMBED * ============================================================ */ function buildMainMenuEmbed( user, balance ) { return new EmbedBuilder() .setTitle( "🎰 The Crumb Saucer" ) .setDescription( [ `Welcome, ${user}!`, "", "Where Cookies are won, lost,", "and occasionally confiscated by ShaiBot.", "", "### 🎰 Slots", "Spin the reels and chase the Cookie jackpot.", "", "### 🏦 Vault Heist", "Attempt to breach ShaiBot's Cookie Vault.", "", "### 🃏 Blackjack", "Beat the dealer without going over 21.", "", "### 🎡 Roulette", "Bet on Red, Black, Odd, Even, or Zero.", "", "*More attractions coming soon... make a lala offering or buy me a white monster*" ].join("\n") ) .addFields({ name: "Your Vault", value: `**${balance.toLocaleString()} 🍪**` }) .setFooter({ text: `crumbSaucer:${user.id}` }); } /* * ============================================================ * MAIN MENU BUTTONS * ============================================================ */ function buildMainMenuButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbSaucerSlots" ) .setLabel( "Slots" ) .setEmoji( "🎰" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "crumbSaucerBlackjack" ) .setLabel( "Blackjack" ) .setEmoji( "🃏" ) .setStyle( ButtonStyle.Success ), new ButtonBuilder() .setCustomId( "crumbSaucerRoulette" ) .setLabel( "Roulette" ) .setEmoji( "🎡" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "crumbSaucerHeist" ) .setLabel( "Vault Heist" ) .setEmoji( "🏦" ) .setStyle( ButtonStyle.Danger ) ) ]; } /* * ============================================================ * SLOT BET BUTTONS * ============================================================ */ function buildSlotBetButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbSaucerSlots10" ) .setLabel( "10" ) .setEmoji( "🍪" ) .setStyle( ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( "crumbSaucerSlots50" ) .setLabel( "50" ) .setEmoji( "🍪" ) .setStyle( ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( "crumbSaucerSlots100" ) .setLabel( "100" ) .setEmoji( "🍪" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "crumbSaucerSlots500" ) .setLabel( "500" ) .setEmoji( "🍪" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "crumbSaucerSlots1000" ) .setLabel( "1,000" ) .setEmoji( "🍪" ) .setStyle( ButtonStyle.Danger ) ), new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbSaucerBack" ) .setLabel( "Back to Crumb Saucer" ) .setEmoji( "◀️" ) .setStyle( ButtonStyle.Secondary ) ) ]; } /* * ============================================================ * SLOT RESULT BUTTONS * ============================================================ */ function buildResultButtons() { return [ new ActionRowBuilder() .addComponents( /* * Exact same wager immediately. */ new ButtonBuilder() .setCustomId( "crumbSaucerSlotsRunItBack" ) .setLabel( "Run It Back" ) .setEmoji( "🔁" ) .setStyle( ButtonStyle.Success ), /* * Return to wager selection. */ new ButtonBuilder() .setCustomId( "crumbSaucerSlots" ) .setLabel( "Change Bet" ) .setEmoji( "🎰" ) .setStyle( ButtonStyle.Primary ), /* * Main Saucer. */ new ButtonBuilder() .setCustomId( "crumbSaucerBack" ) .setLabel( "Crumb Saucer" ) .setEmoji( "◀️" ) .setStyle( ButtonStyle.Secondary ) ) ]; } /* * ============================================================ * SLOT LOGIC * ============================================================ */ function rollSlotOutcome() { const totalWeight = SLOT_OUTCOMES.reduce( ( total, outcome ) => total + outcome.weight, 0 ); let roll = Math.random() * totalWeight; for ( const outcome of SLOT_OUTCOMES ) { roll -= outcome.weight; if ( roll <= 0 ) { return outcome; } } return SLOT_OUTCOMES[0]; } function generateRandomGrid() { return Array.from( { length: 3 }, () => Array.from( { length: 3 }, () => randomItem( SLOT_SYMBOLS ) ) ); } function generateOutcomeGrid( outcome ) { const grid = generateRandomGrid(); if ( outcome.type === "triple" ) { grid[1] = [ outcome.symbol, outcome.symbol, outcome.symbol ]; return grid; } if ( outcome.type === "pair" ) { const symbol = randomItem( SLOT_SYMBOLS ); let other; do { other = randomItem( SLOT_SYMBOLS ); } while ( other === symbol ); const oddPosition = Math.floor( Math.random() * 3 ); grid[1] = [ symbol, symbol, symbol ]; grid[1][ oddPosition ] = other; return grid; } let line; do { line = [ randomItem( SLOT_SYMBOLS ), randomItem( SLOT_SYMBOLS ), randomItem( SLOT_SYMBOLS ) ]; } while ( new Set( line ).size < 3 ); grid[1] = line; return grid; } function buildSlotDisplay( grid ) { return [ "╔═════════╗", `║ ${grid[0][0]} │ ${grid[0][1]} │ ${grid[0][2]} ║`, `║ ${grid[1][0]} │ ${grid[1][1]} │ ${grid[1][2]} ║`, `║ ${grid[2][0]} │ ${grid[2][1]} │ ${grid[2][2]} ║`, "╚═════════╝" ].join("\n"); } function buildSpinningEmbed( bet, grid, status ) { return new EmbedBuilder() .setTitle( "🎰 Crumb Saucer Slots" ) .setDescription( [ buildSlotDisplay( grid ), "", `**${status}**` ].join("\n") ) .addFields({ name: "Bet", value: `**${bet.toLocaleString()} 🍪**` }); } function getSlotResultName( outcome ) { if ( outcome.type === "loss" ) { return "No luck this time."; } if ( outcome.type === "pair" ) { return "Pair!"; } switch ( outcome.symbol ) { case "🍋": return "🍋 Triple Lemon!"; case "🍒": return "🍒 Triple Cherry!"; case "🍪": return "🍪 Triple Cookie!"; case "⭐": return "⭐ Triple Star!"; case "💎": return "💎 JACKPOT! 💎"; default: return "Winner!"; } } /* * ============================================================ * SESSION OWNER RESOLUTION * ============================================================ * * This intentionally understands every Crumb Saucer * screen that can navigate back to the main menu. * * That prevents another player from pressing the * Back / Crumb Saucer button on somebody else's * public interaction. */ function getCrumbSaucerSessionOwner( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; switch ( parts[0] ) { /* * Main menu */ case "crumbSaucer": /* * Slots wager menu */ case "crumbSaucerSlots": /* * Slots completed result */ case "crumbSaucerSlotsResult": /* * Blackjack wager/result menu */ case "crumbSaucerBlackjack": case "crumbSaucerBlackjackResult": /* * Roulette */ case "crumbRouletteMenu": case "crumbRouletteBet": case "crumbRouletteResult": /* * Heist */ case "crumbSaucerHeist": case "cookieHeist": return parts[1]; /* * Active Blackjack footer: * * crumbBlackjack:: */ case "crumbBlackjack": return parts[2]; default: return null; } } /* * ============================================================ * SLOT OWNER RESOLUTION * ============================================================ */ function getSlotsSessionOwner( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; /* * Main Crumb Saucer. */ if ( parts[0] === "crumbSaucer" ) { return parts[1]; } /* * Slots wager menu. */ if ( parts[0] === "crumbSaucerSlots" ) { return parts[1]; } /* * Completed Slots result. */ if ( parts[0] === "crumbSaucerSlotsResult" ) { return parts[1]; } return null; } /* * ============================================================ * SLEEP * ============================================================ */ function sleep( ms ) { return new Promise( resolve => setTimeout( resolve, ms ) ); } /* * ============================================================ * EXPORTS * ============================================================ */ module.exports = { showCrumbSaucer, showSlots, playSlots, runSlotsBack };