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 ) { const balance = await getCookieBalance( interaction.user.id ); const embed = buildMainMenuEmbed( interaction.user, balance ); const components = buildMainMenuButtons(); /* * Slash command: * reply normally. * * Button: * update existing menu message. */ if ( interaction.isButton() ) { return interaction.update({ embeds: [ embed ], components }); } return interaction.reply({ embeds: [ embed ], components }); } /* * ============================================================ * SLOT MENU * ============================================================ */ async function showSlots( interaction ) { const ownerId = getMenuOwner( interaction ); if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your Crumb Saucer session. Open your own with `/crumbsaucer`." } ); } 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** — ×1.5", "🍒 **Triple Cherry** — ×2", "🍪 **Triple Cookie** — ×3", "⭐ **Triple Star** — ×5", "💎 **Jackpot** — ×10" ].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 = getSlotsOwner( 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: [] }); /* * A few slow edits give a slot-machine feel * without hammering the Discord API. */ 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. * * The original stake was already transferred * to the house, so x2 means a total payout of 2x. */ 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." ) ], 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: `×${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 } ) .setFooter({ text: `crumbSaucerSlots:${interaction.user.id}` }); return interaction.editReply({ embeds: [ resultEmbed ], components: buildResultButtons() }); } /* * ============================================================ * 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.", "", "*More attractions coming soon...*" ].join("\n") ) .addFields({ name: "Your Vault", value: `**${balance.toLocaleString()} 🍪**` }) .setFooter({ text: `crumbSaucer:${user.id}` }); } /* * ============================================================ * BUTTONS * ============================================================ */ function buildMainMenuButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbSaucerSlots" ) .setLabel( "Slots" ) .setEmoji( "🎰" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "crumbSaucerHeist" ) .setLabel( "Vault Heist" ) .setEmoji( "🏦" ) .setStyle( ButtonStyle.Danger ), new ButtonBuilder() .setCustomId( "crumbSaucerBlackjack" ) .setLabel( "Blackjack" ) .setEmoji( "🃏" ) .setStyle( ButtonStyle.Success ) ) ]; } 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 ) ) ]; } function buildResultButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbSaucerSlots" ) .setLabel( "Play Again" ) .setEmoji( "🎰" ) .setStyle( ButtonStyle.Primary ), 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(); /* * Winning middle row. */ if ( outcome.type === "triple" ) { grid[1] = [ outcome.symbol, outcome.symbol, outcome.symbol ]; return grid; } /* * Pair. */ if ( outcome.type === "pair" ) { const symbol = randomItem( SLOT_SYMBOLS ); let other; do { other = randomItem( SLOT_SYMBOLS ); } while ( other === symbol ); /* * Randomize the location of the odd symbol. */ const oddPosition = Math.floor( Math.random() * 3 ); grid[1] = [ symbol, symbol, symbol ]; grid[1][ oddPosition ] = other; return grid; } /* * Loss: * ensure all three center symbols differ. */ 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!"; } } /* * ============================================================ * OWNER RESOLUTION * ============================================================ */ function getMenuOwner( interaction ) { const footer = interaction.message .embeds[0] ?.footer ?.text; const [ type, userId ] = footer ?.split(":") ?? []; if ( type !== "crumbSaucer" ) { return null; } return userId; } function getSlotsOwner( interaction ) { const footer = interaction.message .embeds[0] ?.footer ?.text; const [ type, userId ] = footer ?.split(":") ?? []; if ( type !== "crumbSaucerSlots" ) { return null; } return userId; } function sleep(ms) { return new Promise( resolve => setTimeout( resolve, ms ) ); } module.exports = { showCrumbSaucer, showSlots, playSlots };