From b9a887259a969864b5672f9a7327d049b63fa944 Mon Sep 17 00:00:00 2001 From: DeSqBlocki Date: Tue, 18 Aug 2026 19:45:43 +0200 Subject: [PATCH] boos crumble snapshot --- buttons/crumbSaucer/back.js | 18 + buttons/crumbSaucer/heist.js | 224 ++++ buttons/crumbSaucer/slots.js | 18 + buttons/crumbSaucer/slots10.js | 19 + buttons/crumbSaucer/slots100.js | 19 + buttons/crumbSaucer/slots1000.js | 19 + buttons/crumbSaucer/slots50.js | 19 + buttons/crumbSaucer/slots500.js | 19 + commands/applications/cookies.js | 1797 ++++++++++++-------------- commands/applications/crumbSaucer.js | 23 + utils/crumbSaucer.js | 1175 +++++++++++++++++ 11 files changed, 2412 insertions(+), 938 deletions(-) create mode 100644 buttons/crumbSaucer/back.js create mode 100644 buttons/crumbSaucer/heist.js create mode 100644 buttons/crumbSaucer/slots.js create mode 100644 buttons/crumbSaucer/slots10.js create mode 100644 buttons/crumbSaucer/slots100.js create mode 100644 buttons/crumbSaucer/slots1000.js create mode 100644 buttons/crumbSaucer/slots50.js create mode 100644 buttons/crumbSaucer/slots500.js create mode 100644 commands/applications/crumbSaucer.js create mode 100644 utils/crumbSaucer.js diff --git a/buttons/crumbSaucer/back.js b/buttons/crumbSaucer/back.js new file mode 100644 index 0000000..3e57471 --- /dev/null +++ b/buttons/crumbSaucer/back.js @@ -0,0 +1,18 @@ +const { + showCrumbSaucer +} = require( + "../../utils/crumbSaucer" +); + + +module.exports = { + name: + "crumbSaucerBack", + + + async execute(interaction) { + return showCrumbSaucer( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbSaucer/heist.js b/buttons/crumbSaucer/heist.js new file mode 100644 index 0000000..613ca4d --- /dev/null +++ b/buttons/crumbSaucer/heist.js @@ -0,0 +1,224 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} = require("discord.js"); + +const { + getCookieBalance, + temporaryEphemeralReply +} = require( + "../../utils/cookieEconomy" +); + + +module.exports = { + name: + "crumbSaucerHeist", + + + async execute(interaction) { + const footer = + interaction.message + .embeds[0] + ?.footer + ?.text; + + + const [ + type, + ownerId + ] = + footer + ?.split(":") + ?? + []; + + + if ( + type !== + "crumbSaucer" || + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "This isn't your Crumb Saucer session." + } + ); + } + + + const vaultId = + interaction.client.user.id; + + + const [ + vault, + player + ] = + await Promise.all([ + getCookieBalance( + vaultId + ), + + getCookieBalance( + interaction.user.id + ) + ]); + + + if ( + player < 1 + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "You need at least **1 Cookie** before attempting a vault heist." + } + ); + } + + + if ( + vault < 1 + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "The ShaiBot Vault is currently empty." + } + ); + } + + + const embed = + new EmbedBuilder() + .setTitle( + "🏦 ShaiBot Cookie Vault" + ) + .setDescription( + [ + `**Vault Balance:** ${vault.toLocaleString()} 🍪`, + `**Security:** ${getSecurityLabel(vault)}`, + "", + "Choose your approach.", + "", + "🥷 **Sneak**", + "High chance • Small haul • Small fine", + "", + "💻 **Hack**", + "Medium chance • Medium haul • Medium fine", + "", + "💥 **Smash**", + "Low chance • Large haul • Large fine" + ].join("\n") + ) + .setFooter({ + text: + `cookieHeist:${interaction.user.id}` + }); + + + const heistButtons = + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "cookieHeistSneak" + ) + .setLabel( + "Sneak" + ) + .setEmoji( + "🥷" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "cookieHeistHack" + ) + .setLabel( + "Hack" + ) + .setEmoji( + "💻" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "cookieHeistSmash" + ) + .setLabel( + "Smash" + ) + .setEmoji( + "💥" + ) + .setStyle( + ButtonStyle.Danger + ) + ); + + + return interaction.update({ + embeds: [ + embed + ], + + components: [ + heistButtons + ] + }); + } +}; + + +function getSecurityLabel( + vault +) { + if ( + vault < + 1000 + ) { + return "🟢 Low"; + } + + + if ( + vault < + 10000 + ) { + return "🟡 Moderate"; + } + + + if ( + vault < + 50000 + ) { + return "🟠 High"; + } + + + if ( + vault < + 250000 + ) { + return "🔴 Very High"; + } + + + return "🟣 Maximum"; +} \ No newline at end of file diff --git a/buttons/crumbSaucer/slots.js b/buttons/crumbSaucer/slots.js new file mode 100644 index 0000000..6c2d63d --- /dev/null +++ b/buttons/crumbSaucer/slots.js @@ -0,0 +1,18 @@ +const { + showSlots +} = require( + "../../utils/crumbSaucer" +); + + +module.exports = { + name: + "crumbSaucerSlots", + + + async execute(interaction) { + return showSlots( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbSaucer/slots10.js b/buttons/crumbSaucer/slots10.js new file mode 100644 index 0000000..0655654 --- /dev/null +++ b/buttons/crumbSaucer/slots10.js @@ -0,0 +1,19 @@ +const { + playSlots +} = require( + "../../utils/crumbSaucer" +); + + +module.exports = { + name: + "crumbSaucerSlots10", + + + async execute(interaction) { + return playSlots( + interaction, + 10 + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbSaucer/slots100.js b/buttons/crumbSaucer/slots100.js new file mode 100644 index 0000000..057b733 --- /dev/null +++ b/buttons/crumbSaucer/slots100.js @@ -0,0 +1,19 @@ +const { + playSlots +} = require( + "../../utils/crumbSaucer" +); + + +module.exports = { + name: + "crumbSaucerSlots100", + + + async execute(interaction) { + return playSlots( + interaction, + 100 + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbSaucer/slots1000.js b/buttons/crumbSaucer/slots1000.js new file mode 100644 index 0000000..defe80d --- /dev/null +++ b/buttons/crumbSaucer/slots1000.js @@ -0,0 +1,19 @@ +const { + playSlots +} = require( + "../../utils/crumbSaucer" +); + + +module.exports = { + name: + "crumbSaucerSlots1000", + + + async execute(interaction) { + return playSlots( + interaction, + 1000 + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbSaucer/slots50.js b/buttons/crumbSaucer/slots50.js new file mode 100644 index 0000000..cfc11f9 --- /dev/null +++ b/buttons/crumbSaucer/slots50.js @@ -0,0 +1,19 @@ +const { + playSlots +} = require( + "../../utils/crumbSaucer" +); + + +module.exports = { + name: + "crumbSaucerSlots50", + + + async execute(interaction) { + return playSlots( + interaction, + 50 + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbSaucer/slots500.js b/buttons/crumbSaucer/slots500.js new file mode 100644 index 0000000..c0f4ba8 --- /dev/null +++ b/buttons/crumbSaucer/slots500.js @@ -0,0 +1,19 @@ +const { + playSlots +} = require( + "../../utils/crumbSaucer" +); + + +module.exports = { + name: + "crumbSaucerSlots500", + + + async execute(interaction) { + return playSlots( + interaction, + 500 + ); + } +}; \ No newline at end of file diff --git a/commands/applications/cookies.js b/commands/applications/cookies.js index 88e81d9..2dd84fd 100644 --- a/commands/applications/cookies.js +++ b/commands/applications/cookies.js @@ -7,8 +7,7 @@ const { MessageFlags } = require("discord.js"); -const { randomUUID } = - require("node:crypto"); +const { randomUUID } = require("node:crypto"); const { getCollections, @@ -30,27 +29,25 @@ const { */ const COLLECT_COOLDOWN = - 60 * 60; + 60 * 60; // 1 hour const STEAL_COOLDOWN = - 15 * 60; - -const SLOT_MIN_BET = - 10; - -const SLOT_MAX_BET = - 1000; + 15 * 60; // 15 minutes const BOSS_SPAWN_COST = 500; const BOSS_JOIN_DURATION = - 30 * 60; + 30 * 60; // 30 minutes const BOSS_MINIMUM_PLAYERS = - 5; + 3; +/* + * Legacy Abra bonus + */ + const ABRA_ID = "378135175059603457"; @@ -59,70 +56,9 @@ const ABRA_MULTIPLIER = /* - * SLOT TABLE - * - * RTP ≈ 94% + * COMMAND */ -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 - } -]; - - -const SLOT_SYMBOLS = [ - "🍒", - "🍋", - "🍪", - "⭐", - "💎" -]; - - module.exports = { data: new SlashCommandBuilder() .setName("cookies") @@ -130,6 +66,10 @@ module.exports = { "The ShaiBot cookie economy." ) + /* + * /cookies collect + */ + .addSubcommand(subcommand => subcommand .setName("collect") @@ -138,6 +78,10 @@ module.exports = { ) ) + /* + * /cookies steal + */ + .addSubcommand(subcommand => subcommand .setName("steal") @@ -155,36 +99,9 @@ module.exports = { ) ) - .addSubcommand(subcommand => - subcommand - .setName("heist") - .setDescription( - "Attempt to breach the ShaiBot Cookie Vault." - ) - ) - - .addSubcommand(subcommand => - subcommand - .setName("slots") - .setDescription( - "Play the Shaiwase Cookie Slots." - ) - - .addIntegerOption(option => - option - .setName("bet") - .setDescription( - "How many Cookies to wager." - ) - .setRequired(true) - .setMinValue( - SLOT_MIN_BET - ) - .setMaxValue( - SLOT_MAX_BET - ) - ) - ) + /* + * /cookies boss spawn + */ .addSubcommandGroup(group => group @@ -202,27 +119,35 @@ module.exports = { ) ) + /* + * /cookies check + */ + .addSubcommand(subcommand => subcommand .setName("check") .setDescription( - "Check a Cookie balance." + "Check a cookie balance." ) .addUserOption(option => option .setName("target") .setDescription( - "The user whose Cookies you want to check." + "The user whose cookies you want to check." ) ) ) + /* + * /cookies give + */ + .addSubcommand(subcommand => subcommand .setName("give") .setDescription( - "Give some of your Cookies to another user." + "Give some of your cookies to another user." ) .addUserOption(option => @@ -238,32 +163,40 @@ module.exports = { option .setName("amount") .setDescription( - "How many Cookies to give." + "How many cookies to give." ) .setRequired(true) .setMinValue(1) ) ) + /* + * /cookies leaderboard + */ + .addSubcommand(subcommand => subcommand .setName("leaderboard") .setDescription( - "Show the richest Cookie collectors." + "Show the richest cookie collectors." ) ), async execute(interaction) { const group = - interaction.options - .getSubcommandGroup(false); + interaction.options.getSubcommandGroup( + false + ); const subcommand = - interaction.options - .getSubcommand(); + interaction.options.getSubcommand(); + /* + * Subcommand groups + */ + if ( group === "boss" && subcommand === "spawn" @@ -274,6 +207,10 @@ module.exports = { } + /* + * Normal subcommands + */ + switch (subcommand) { case "collect": return collect( @@ -285,16 +222,6 @@ module.exports = { interaction ); - case "heist": - return heist( - interaction - ); - - case "slots": - return slots( - interaction - ); - case "check": return check( interaction @@ -309,19 +236,32 @@ module.exports = { return leaderboard( interaction ); + + default: + return interaction.reply({ + content: + "Unknown cookies command.", + flags: + MessageFlags.Ephemeral + }); } } }; /* - * COLLECT + * ============================================================ + * /cookies collect + * ============================================================ */ async function collect(interaction) { const userId = interaction.user.id; + const guildId = + interaction.guildId; + const now = getUnixTime(); @@ -329,12 +269,15 @@ async function collect(interaction) { const cooldown = await getCooldown( userId, - interaction.guildId, + guildId, "collect" ); - if (cooldown > now) { + if ( + cooldown > + now + ) { return interaction.reply({ content: `The batch is still cooking. Come back 🍪`, @@ -351,6 +294,10 @@ async function collect(interaction) { ); + /* + * Legacy Abra bonus + */ + if ( userId === ABRA_ID @@ -369,12 +316,13 @@ async function collect(interaction) { ); - await setCooldown( - userId, - interaction.guildId, - "collect", - COLLECT_COOLDOWN - ); + const nextCollect = + await setCooldown( + userId, + guildId, + "collect", + COLLECT_COOLDOWN + ); const balance = @@ -394,13 +342,45 @@ async function collect(interaction) { interaction.user ) ) - .addFields({ - name: - "Your Vault", + .addFields( + { + name: + "Collected", - value: - `**${balance.toLocaleString()} Cookies**` - }); + value: + `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`, + + inline: + true + }, + + { + name: + "Your Vault", + + value: + `**${balance.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + "Next Batch", + + value: + ``, + + inline: + false + } + ) + .setThumbnail( + interaction.user.displayAvatarURL({ + size: 256 + }) + ); return interaction.reply({ @@ -412,7 +392,9 @@ async function collect(interaction) { /* - * NORMAL PLAYER STEAL + * ============================================================ + * /cookies steal + * ============================================================ */ async function steal(interaction) { @@ -426,6 +408,10 @@ async function steal(interaction) { ); + /* + * Validation + */ + if ( target.id === user.id @@ -440,15 +426,17 @@ async function steal(interaction) { /* - * ShaiBot itself is reserved for /cookies heist. + * ShaiBot itself is reserved for + * The Crumb Saucer Vault Heist. */ + if ( target.id === interaction.client.user.id ) { return interaction.reply({ content: - "The ShaiBot Vault requires a proper `/cookies heist`.", + "ShaiBot's vault requires a proper Vault Heist. Visit `/crumbsaucer`.", flags: MessageFlags.Ephemeral }); @@ -465,6 +453,10 @@ async function steal(interaction) { } + /* + * Cooldown + */ + const now = getUnixTime(); @@ -477,16 +469,23 @@ async function steal(interaction) { ); - if (cooldown > now) { + if ( + cooldown > + now + ) { return interaction.reply({ content: - `You can steal again .`, + `Lay low for a while. You can steal again .`, flags: MessageFlags.Ephemeral }); } + /* + * Balances + */ + const [ yourCookies, theirCookies @@ -502,7 +501,10 @@ async function steal(interaction) { ]); - if (yourCookies < 1) { + if ( + yourCookies < + 1 + ) { return interaction.reply({ content: "You need at least **1 Cookie** to attempt a robbery.", @@ -512,7 +514,10 @@ async function steal(interaction) { } - if (theirCookies < 1) { + if ( + theirCookies < + 1 + ) { return interaction.reply({ content: `${target} doesn't have any Cookies.`, @@ -522,6 +527,10 @@ async function steal(interaction) { } + /* + * Dynamic success chance + */ + const successChance = getStealChance( yourCookies, @@ -537,6 +546,12 @@ async function steal(interaction) { let embed; + /* + * ======================================================== + * SUCCESS + * ======================================================== + */ + if (success) { const { amount @@ -546,19 +561,51 @@ async function steal(interaction) { ); - await transferCookies( - target.id, - user.id, - amount - ); + try { + await transferCookies( + target.id, + user.id, + amount + ); + + } catch (error) { + if ( + error.message === + "INSUFFICIENT_COOKIES" + ) { + return interaction.reply({ + content: + `${target}'s Cookie balance changed before you could finish the robbery.`, + flags: + MessageFlags.Ephemeral + }); + } + + throw error; + } + + + const [ + yourBalanceAfter, + targetBalanceAfter + ] = + await Promise.all([ + getCookieBalance( + user.id + ), + + getCookieBalance( + target.id + ) + ]); const actualPercentage = ( amount / - theirCookies * - 100 - ); + theirCookies + ) * + 100; embed = @@ -573,6 +620,17 @@ async function steal(interaction) { ) ) .addFields( + { + name: + "Cookies Stolen", + + value: + `**${amount.toLocaleString()} 🍪**`, + + inline: + true + }, + { name: "Vault Taken", @@ -593,10 +651,45 @@ async function steal(interaction) { inline: true - } - ); + }, - } else { + { + name: + "Your Vault", + + value: + `**${yourBalanceAfter.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + `${target.displayName}'s Vault`, + + value: + `**${targetBalanceAfter.toLocaleString()} 🍪**`, + + inline: + true + } + ) + .setThumbnail( + target.displayAvatarURL({ + size: 256 + }) + ); + } + + + /* + * ======================================================== + * FAILURE + * ======================================================== + */ + + else { const penaltyPercentage = randomFloat( 0.01, @@ -619,14 +712,56 @@ async function steal(interaction) { ); - /* - * Normal steal failure pays the victim. - */ - await transferCookies( - user.id, - target.id, - penalty - ); + try { + /* + * Normal steal failures reward + * the victim. + */ + + await transferCookies( + user.id, + target.id, + penalty + ); + + } catch (error) { + if ( + error.message === + "INSUFFICIENT_COOKIES" + ) { + return interaction.reply({ + content: + "Your Cookie balance changed before the robbery could finish.", + flags: + MessageFlags.Ephemeral + }); + } + + throw error; + } + + + const [ + yourBalanceAfter, + targetBalanceAfter + ] = + await Promise.all([ + getCookieBalance( + user.id + ), + + getCookieBalance( + target.id + ) + ]); + + + const actualPenaltyPercentage = + ( + penalty / + yourCookies + ) * + 100; embed = @@ -641,6 +776,28 @@ async function steal(interaction) { ) ) .addFields( + { + name: + "Penalty", + + value: + `**${penalty.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + "Vault Lost", + + value: + `**${actualPenaltyPercentage.toFixed(1)}%**`, + + inline: + true + }, + { name: "Success Chance", @@ -648,13 +805,44 @@ async function steal(interaction) { value: `**${formatPercentage(successChance)}**`, + inline: + true + }, + + { + name: + "Your Vault", + + value: + `**${yourBalanceAfter.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + `${target.displayName}'s Vault`, + + value: + `**${targetBalanceAfter.toLocaleString()} 🍪**`, + inline: true } + ) + .setThumbnail( + target.displayAvatarURL({ + size: 256 + }) ); } + /* + * Apply cooldown + */ + const nextSteal = await setCooldown( user.id, @@ -669,7 +857,10 @@ async function steal(interaction) { "Next Robbery", value: - `` + ``, + + inline: + false }); @@ -682,416 +873,22 @@ async function steal(interaction) { /* - * HEIST ENTRY - */ - -async function heist(interaction) { - const vaultId = - interaction.client.user.id; - - - const [ - vault, - player - ] = - await Promise.all([ - getCookieBalance( - vaultId - ), - - getCookieBalance( - interaction.user.id - ) - ]); - - - if (player < 1) { - return interaction.reply({ - content: - "You need Cookies before attempting a vault heist.", - flags: - MessageFlags.Ephemeral - }); - } - - - if (vault < 1) { - return interaction.reply({ - content: - "The ShaiBot Vault is currently empty.", - flags: - MessageFlags.Ephemeral - }); - } - - - const security = - getSecurityLabel( - vault - ); - - - const embed = - new EmbedBuilder() - .setTitle( - "🏦 ShaiBot Cookie Vault" - ) - .setDescription( - [ - `**Vault:** ${vault.toLocaleString()} 🍪`, - `**Security:** ${security}`, - "", - "Choose your approach.", - "", - "🥷 **Sneak** — safer, smaller haul.", - "💻 **Hack** — balanced risk and reward.", - "💥 **Smash** — dangerous, but lucrative." - ].join("\n") - ) - .setFooter({ - text: - `cookieHeist:${interaction.user.id}` - }); - - - const row = - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId( - "cookieHeistSneak" - ) - .setLabel( - "Sneak" - ) - .setEmoji( - "🥷" - ) - .setStyle( - ButtonStyle.Primary - ), - - new ButtonBuilder() - .setCustomId( - "cookieHeistHack" - ) - .setLabel( - "Hack" - ) - .setEmoji( - "💻" - ) - .setStyle( - ButtonStyle.Secondary - ), - - new ButtonBuilder() - .setCustomId( - "cookieHeistSmash" - ) - .setLabel( - "Smash" - ) - .setEmoji( - "💥" - ) - .setStyle( - ButtonStyle.Danger - ) - ); - - - return interaction.reply({ - embeds: [ - embed - ], - - components: [ - row - ] - }); -} - - -/* - * SLOTS - */ - -async function slots(interaction) { - const bet = - interaction.options.getInteger( - "bet", - true - ); - - - 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 interaction.reply({ - content: - "You don't have enough Cookies for that bet.", - flags: - MessageFlags.Ephemeral - }); - } - - - /* - * The house needs enough BEFORE the wager - * to honor a 10x payout after receiving the bet. - */ - if ( - vaultBalance < - bet * 9 - ) { - return interaction.reply({ - content: - "The ShaiBot Vault doesn't currently have enough liquidity for that wager.", - flags: - MessageFlags.Ephemeral - }); - } - - - await transferCookies( - playerId, - vaultId, - bet - ); - - - const spinning = - new EmbedBuilder() - .setTitle( - "🎰 Shaiwase Cookie Slots" - ) - .setDescription( - buildSlotDisplay( - generateRandomGrid() - ) - ) - .addFields({ - name: - "Bet", - - value: - `**${bet.toLocaleString()} 🍪**` - }); - - - await interaction.reply({ - embeds: [ - spinning - ] - }); - - - await sleep(750); - - - await interaction.editReply({ - embeds: [ - EmbedBuilder - .from(spinning) - .setDescription( - buildSlotDisplay( - generateRandomGrid() - ) - ) - ] - }); - - - await sleep(750); - - - await interaction.editReply({ - embeds: [ - EmbedBuilder - .from(spinning) - .setDescription( - buildSlotDisplay( - generateRandomGrid() - ) - ) - ] - }); - - - await sleep(750); - - - const outcome = - rollSlotOutcome(); - - - const grid = - generateOutcomeGrid( - outcome - ); - - - const payout = - Math.floor( - bet * - outcome.multiplier - ); - - - if (payout > 0) { - await transferCookies( - vaultId, - playerId, - payout - ); - } - - - const [ - finalPlayer, - finalVault - ] = - await Promise.all([ - getCookieBalance( - playerId - ), - - getCookieBalance( - vaultId - ) - ]); - - - const profit = - payout - bet; - - - const finalEmbed = - new EmbedBuilder() - .setTitle( - "🎰 Shaiwase Cookie Slots" - ) - .setDescription( - buildSlotDisplay( - grid - ) - ) - .addFields( - { - name: - "Result", - - value: - getSlotResultName( - outcome - ) - }, - - { - 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: - `${finalPlayer.toLocaleString()} 🍪`, - - inline: - true - }, - - { - name: - "House Vault", - - value: - `${finalVault.toLocaleString()} 🍪`, - - inline: - true - } - ); - - - return interaction.editReply({ - embeds: [ - finalEmbed - ] - }); -} - - -/* - * BOSS SPAWN + * ============================================================ + * /cookies boss spawn + * ============================================================ */ async function spawnBoss(interaction) { + if (!interaction.guild) { + return interaction.reply({ + content: + "Cookie Bosses can only be summoned inside a server.", + flags: + MessageFlags.Ephemeral + }); + } + + const { db } = @@ -1105,7 +902,7 @@ async function spawnBoss(interaction) { /* - * One unresolved boss per guild. + * Only one unresolved Cookie Boss per guild. */ const existing = @@ -1119,8 +916,8 @@ async function spawnBoss(interaction) { state: { $in: [ "Joining", - "Active", "Starting", + "Active", "Resolving" ] } @@ -1149,37 +946,70 @@ async function spawnBoss(interaction) { ) { return interaction.reply({ content: - `You need **${BOSS_SPAWN_COST} Cookies** to summon the boss.`, + `You need **${BOSS_SPAWN_COST.toLocaleString()} Cookies** to summon the boss.`, flags: MessageFlags.Ephemeral }); } + /* + * Unique boss encounter. + */ + const eventId = randomUUID(); + + /* + * This virtual account holds all committed + * Cookie Boss currency until the event resolves. + */ + const escrowId = `boss:${eventId}`; + const now = getUnixTime(); + const joinUntil = now + BOSS_JOIN_DURATION; /* - * Spawner fee seeds the encounter pool. + * Spawner cost seeds the reward pool. */ - await transferCookies( - interaction.user.id, - escrowId, - BOSS_SPAWN_COST - ); + try { + await transferCookies( + interaction.user.id, + escrowId, + BOSS_SPAWN_COST + ); + } catch (error) { + if ( + error.message === + "INSUFFICIENT_COOKIES" + ) { + return interaction.reply({ + content: + "Your Cookie balance changed before the boss could be summoned.", + flags: + MessageFlags.Ephemeral + }); + } + + throw error; + } + + + /* + * Initial recruitment embed + */ const embed = new EmbedBuilder() @@ -1188,15 +1018,18 @@ async function spawnBoss(interaction) { ) .setDescription( [ - "*Drawn by the scent of an irresponsibly large Cookie Vault,", - "the Crumbbound Behemoth has lumbered into Shaiwase.*", + "*Drawn by the scent of an irresponsibly large Cookie Vault,*", + "*the Crumbbound Behemoth has lumbered into Shaiwase.*", "", `**Recruitment ends:** `, `**Minimum adventurers:** ${BOSS_MINIMUM_PLAYERS}`, + `**Participants:** 0/${BOSS_MINIMUM_PLAYERS}`, `**Reward Pool:** ${BOSS_SPAWN_COST.toLocaleString()} 🍪`, "", "Choose how many Cookies you want to commit to the fight.", - "Your entry tier determines your temporary HP and Power." + "Your tier determines your temporary HP and Power.", + "", + "If enough players join, the summoner may start the battle early." ].join("\n") ) .setFooter({ @@ -1205,6 +1038,10 @@ async function spawnBoss(interaction) { }); + /* + * Join tier buttons + */ + const joinRow = new ActionRowBuilder() .addComponents( @@ -1241,7 +1078,7 @@ async function spawnBoss(interaction) { "cookieBossJoinChampion" ) .setLabel( - "Champion — 1000" + "Champion — 1,000" ) .setEmoji( "🔥" @@ -1252,6 +1089,10 @@ async function spawnBoss(interaction) { ); + /* + * Summoner early-start button. + */ + const startRow = new ActionRowBuilder() .addComponents( @@ -1271,87 +1112,147 @@ async function spawnBoss(interaction) { ); - const message = - await interaction.reply({ - embeds: [ - embed - ], + let message; - components: [ - joinRow, - startRow - ], - fetchReply: - true + try { + message = + await interaction.reply({ + embeds: [ + embed + ], + + components: [ + joinRow, + startRow + ], + + fetchReply: + true + }); + + } catch (error) { + /* + * Refund spawn cost if Discord message creation fails. + */ + + try { + await transferCookies( + escrowId, + interaction.user.id, + BOSS_SPAWN_COST + ); + } catch (refundError) { + console.error( + "CRITICAL: Failed to refund boss spawn after message failure:", + refundError + ); + } + + throw error; + } + + + /* + * Persist encounter. + */ + + try { + await events.insertOne({ + eventType: + "CookieBoss", + + eventId, + + escrowId, + + guildId: + interaction.guildId, + + channelId: + interaction.channelId, + + messageId: + message.id, + + creatorId: + interaction.user.id, + + state: + "Joining", + + createdAt: + now, + + joinUntil, + + battleStartedAt: + null, + + battleEndsAt: + null, + + minimumParticipants: + BOSS_MINIMUM_PLAYERS, + + rewardPool: + BOSS_SPAWN_COST, + + boss: { + bossId: + "crumbbound-behemoth", + + name: + "The Crumbbound Behemoth", + + health: + 15000, + + maxHealth: + 15000, + + rage: + 0 + }, + + participants: + {} }); + } catch (error) { + /* + * If Mongo fails after the message was posted, + * remove the message and refund the creator. + */ - await events.insertOne({ - eventType: - "CookieBoss", + try { + await message.delete(); + } catch {} - eventId, - escrowId, + try { + await transferCookies( + escrowId, + interaction.user.id, + BOSS_SPAWN_COST + ); + } catch (refundError) { + console.error( + "CRITICAL: Failed to refund boss spawn after database failure:", + refundError + ); + } - guildId: - interaction.guildId, - channelId: - interaction.channelId, - - messageId: - message.id, - - creatorId: - interaction.user.id, - - state: - "Joining", - - createdAt: - now, - - joinUntil, - - battleStartedAt: - null, - - battleEndsAt: - null, - - minimumParticipants: - BOSS_MINIMUM_PLAYERS, - - rewardPool: - BOSS_SPAWN_COST, - - boss: { - bossId: - "crumbbound-behemoth", - - name: - "The Crumbbound Behemoth", - - health: - 15000, - - maxHealth: - 15000, - - rage: - 0 - }, - - participants: - {} - }); + throw error; + } } /* - * CHECK + * ============================================================ + * /cookies check + * ============================================================ */ async function check(interaction) { @@ -1369,13 +1270,27 @@ async function check(interaction) { ); + const isHouse = + target.id === + interaction.client.user.id; + + const embed = new EmbedBuilder() .setTitle( - "🍪 Cookie Vault" + isHouse + ? "🏦 ShaiBot Cookie Vault" + : "🍪 Cookie Vault" ) .setDescription( - `${target} has **${balance.toLocaleString()} Cookies**.` + isHouse + ? `The house currently holds **${balance.toLocaleString()} Cookies**.` + : `${target} currently has **${balance.toLocaleString()} Cookie${balance === 1 ? "" : "s"}**.` + ) + .setThumbnail( + target.displayAvatarURL({ + size: 256 + }) ); @@ -1388,19 +1303,23 @@ async function check(interaction) { /* - * GIVE + * ============================================================ + * /cookies give + * ============================================================ */ async function give(interaction) { const user = interaction.user; + const target = interaction.options.getUser( "target", true ); + const amount = interaction.options.getInteger( "amount", @@ -1421,10 +1340,14 @@ async function give(interaction) { } + /* + * Keep bot deposits tied to the game systems. + */ + if (target.bot) { return interaction.reply({ content: - "Use the Cookie games if you want to feed the house.", + "Bots don't accept direct Cookie transfers. Visit `/crumbsaucer` if you'd like to donate to the house involuntarily.", flags: MessageFlags.Ephemeral }); @@ -1455,13 +1378,56 @@ async function give(interaction) { } + const [ + yourBalance, + theirBalance + ] = + await Promise.all([ + getCookieBalance( + user.id + ), + + getCookieBalance( + target.id + ) + ]); + + const embed = new EmbedBuilder() .setTitle( "🍪 Cookies Shared" ) .setDescription( - `${user} gave ${target} **${amount.toLocaleString()} Cookies**.` + `${user} gave ${target} **${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**.` + ) + .addFields( + { + name: + "Your Vault", + + value: + `**${yourBalance.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + `${target.displayName}'s Vault`, + + value: + `**${theirBalance.toLocaleString()} 🍪**`, + + inline: + true + } + ) + .setThumbnail( + target.displayAvatarURL({ + size: 256 + }) ); @@ -1474,7 +1440,9 @@ async function give(interaction) { /* - * LEADERBOARD + * ============================================================ + * /cookies leaderboard + * ============================================================ */ async function leaderboard(interaction) { @@ -1485,10 +1453,16 @@ async function leaderboard(interaction) { /* - * Only Discord snowflake-style account IDs. + * Restrict to Discord snowflake-style user IDs. * - * This prevents boss escrow accounts from appearing. + * This prevents temporary boss escrow accounts + * such as: + * + * boss: + * + * from appearing on the leaderboard. */ + const results = await cookies .find({ @@ -1498,19 +1472,39 @@ async function leaderboard(interaction) { }, cookies: { - $gt: 0 + $gt: + 0 } }) .sort({ - cookies: -1 + cookies: + -1 }) - .limit(10) + .limit(15) .toArray(); + /* + * Exclude ShaiBot itself from the player + * leaderboard. + */ + + const playerResults = + results + .filter( + record => + record.userId !== + interaction.client.user.id + ) + .slice( + 0, + 10 + ); + + const description = - results.length - ? results + playerResults.length + ? playerResults .map( ( record, @@ -1528,6 +1522,12 @@ async function leaderboard(interaction) { : "Nobody has collected any Cookies yet."; + const houseBalance = + await getCookieBalance( + interaction.client.user.id + ); + + const embed = new EmbedBuilder() .setTitle( @@ -1536,9 +1536,27 @@ async function leaderboard(interaction) { .setDescription( description ) + .addFields({ + name: + "🏦 ShaiBot House Vault", + + value: + `**${houseBalance.toLocaleString()} 🍪**` + }) .setTimestamp(); + if ( + interaction.guild?.iconURL() + ) { + embed.setThumbnail( + interaction.guild.iconURL({ + size: 256 + }) + ); + } + + return interaction.reply({ embeds: [ embed @@ -1548,7 +1566,9 @@ async function leaderboard(interaction) { /* + * ============================================================ * STEAL HELPERS + * ============================================================ */ function getStealChance( @@ -1565,13 +1585,23 @@ function getStealChance( 0.50; + /* + * Wealthier targets become harder to rob. + */ + const targetPenalty = Math.log10( - targetCookies + 1 - ) * + targetCookies + + 1 + ) + * 0.06; + /* + * Compare attacker wealth to target wealth. + */ + const wealthRatio = attackerCookies / Math.max( @@ -1580,23 +1610,38 @@ function getStealChance( ); + /* + * Poorer attackers receive a modest + * catch-up bonus. + */ + const povertyBonus = - wealthRatio < 1 + wealthRatio < + 1 ? ( 1 - wealthRatio - ) * + ) + * 0.18 : 0; + /* + * Rich attackers targeting poorer users + * receive an anti-bullying penalty. + */ + const bullyPenalty = - wealthRatio > 1 + wealthRatio > + 1 ? Math.min( Math.log10( - wealthRatio + 1 - ) * + wealthRatio + + 1 + ) + * 0.12, 0.20 @@ -1605,19 +1650,22 @@ function getStealChance( : 0; + const chance = + BASE_CHANCE + - + targetPenalty + + + povertyBonus + - + bullyPenalty; + + return Math.max( MIN_CHANCE, Math.min( MAX_CHANCE, - - BASE_CHANCE - - - targetPenalty - + - povertyBonus - - - bullyPenalty + chance ) ); } @@ -1634,16 +1682,19 @@ function getStealAmount( getStealPercentage(); + const calculatedAmount = + Math.floor( + targetCookies * + percentage + ); + + const amount = Math.max( 1, Math.min( - Math.floor( - targetCookies * - percentage - ), - + calculatedAmount, targetCookies, MAX_STEAL_AMOUNT ) @@ -1662,27 +1713,59 @@ function getStealPercentage() { Math.random(); - if (roll < 0.50) + /* + * 50%: + * 1–3% + */ + + if ( + roll < + 0.50 + ) { return randomFloat( 0.01, 0.03 ); + } - if (roll < 0.80) + /* + * 30%: + * 3–5% + */ + + if ( + roll < + 0.80 + ) { return randomFloat( 0.03, 0.05 ); + } - if (roll < 0.95) + /* + * 15%: + * 5–8% + */ + + if ( + roll < + 0.95 + ) { return randomFloat( 0.05, 0.08 ); + } + /* + * 5% jackpot: + * 8–10% + */ + return randomFloat( 0.08, 0.10 @@ -1691,267 +1774,116 @@ function getStealPercentage() { /* - * SLOT HELPERS - */ - -function rollSlotOutcome() { - const total = - SLOT_OUTCOMES.reduce( - ( - sum, - outcome - ) => - sum + - outcome.weight, - - 0 - ); - - - let roll = - Math.random() * - total; - - - 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 - ); - - - grid[1] = [ - symbol, - symbol, - other - ]; - - return grid; - } - - - /* - * Guaranteed non-pair middle line. - */ - - 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 getSlotResultName( - outcome -) { - if ( - outcome.type === - "loss" - ) { - return "No match"; - } - - - 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 "Triple"; - } -} - - -function getSecurityLabel( - vault -) { - if (vault < 1000) - return "LOW"; - - if (vault < 10000) - return "MODERATE"; - - if (vault < 50000) - return "HIGH"; - - if (vault < 250000) - return "VERY HIGH"; - - return "MAXIMUM"; -} - - -/* - * TEXT + * ============================================================ + * COLLECT RESPONSES + * ============================================================ */ function randomCollectResponse( amount, user ) { - return randomItem([ - `${user}, I made you **${amount} Cookies** 🍪`, - `${user}, here's **${amount} Cookies**. I ate the rest.`, - `${user}, your **${amount} Cookies** are fresh out of the oven.`, - `A wild baker appeared and handed ${user} **${amount} Cookies**.`, - `${user}, Abra insists these **${amount} Cookies** are totally legitimate.` - ]); + const cookies = + `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; + + + const responses = [ + `${user}, I made you ${cookies} 🍪`, + `${user}, here's your ${cookies} 🍪 I ate the rest, sorry >.<`, + `Only ${cookies} 🍪 for you today ${user}... Abra stole the rest :c`, + `${user}, would you like a glass of milk with your ${cookies}? 🥛`, + `${cookies} 🍪 Careful, ${user}! They're still warm :3`, + `${cookies} 🍪 Best batch yet!`, + `${user}, these ${cookies} I bequeath unto thine own self upon this day.`, + `${user}, your ${cookies} are fresh out of the oven. Try not to burn your tongue!`, + `${user}, I bribed the Cookie Monster to spare these ${cookies} just for you.`, + `${user}, Santa came early! You got ${cookies}. Milk not included.`, + `${user}, your ${cookies} are artisanal, gluten-free, and slightly cursed.`, + `A wild baker appeared! They hand ${user} ${cookies} 🍪`, + `${user}, Abra said these ${cookies} are "totally not stolen"... 🤔`, + `${user}, here's your ${cookies} — watch out, they may be booby-trapped.` + ]; + + + return randomItem( + responses + ); } +/* + * ============================================================ + * STEAL SUCCESS RESPONSES + * ============================================================ + */ + function randomStealSuccessResponse( amount, target ) { - return randomItem([ - `You stole **${amount.toLocaleString()} Cookies** from ${target}!`, - `Clean getaway. ${target} is **${amount.toLocaleString()} Cookies** poorer.`, - `The Cookie Vault was breached for **${amount.toLocaleString()} Cookies**.` - ]); + const cookies = + `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; + + + const responses = [ + `You've successfully stolen ${cookies} from ${target}!`, + `Ninja mode activated. You snatched ${cookies} from ${target} without leaving a crumb behind.`, + `Abra taught you well — ${cookies} from ${target} are now yours!`, + `Like a phantom in the night, you took ${cookies} from ${target}.`, + `You just pulled the sweetest heist: ${cookies} from ${target}!`, + `Mission complete. ${target} is ${cookies} poorer.`, + `You distracted ${target} with a dance and swiped ${cookies}.`, + `Clean getaway! ${target} won't notice the missing ${cookies} until it's too late.`, + `The Cookie Vault has been breached. ${cookies} taken from ${target}.`, + `You emerged from the shadows carrying ${cookies} from ${target}.` + ]; + + + return randomItem( + responses + ); } +/* + * ============================================================ + * STEAL FAILURE RESPONSES + * ============================================================ + */ + function randomStealFailResponse( amount, target ) { - return randomItem([ - `${target} caught you and took **${amount.toLocaleString()} Cookies**.`, - `The robbery failed. ${target} gained **${amount.toLocaleString()} Cookies**.`, - `Cookie Police confiscated **${amount.toLocaleString()} Cookies** and handed them to ${target}.` - ]); + const cookies = + `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; + + + const responses = [ + `You were caught red-handed by ${target} and lost ${cookies} as a penalty!`, + `Fail! ${target} caught you mid-bite — you dropped ${cookies}.`, + `Not your best moment... You lost ${cookies} to ${target} in the struggle.`, + `Stealth level: 0. You handed ${cookies} to ${target} as an apology.`, + `The Cookie jar was booby-trapped! You gave ${cookies} to ${target} while running away.`, + `Ouch. The guard dog barked, and now ${target} has your ${cookies}.`, + `You tripped over your own shoelace — ${target} picked up ${cookies}.`, + `Caught by the Cookie Police! ${cookies} confiscated and given to ${target}.`, + `Your pockets had holes... ${target} found the ${cookies} you dropped.` + ]; + + + return randomItem( + responses + ); } +/* + * ============================================================ + * LEADERBOARD + * ============================================================ + */ + function getLeaderboardMedal( index ) { @@ -1968,15 +1900,4 @@ function getLeaderboardMedal( default: return `**${index + 1}.**`; } -} - - -function sleep(ms) { - return new Promise( - resolve => - setTimeout( - resolve, - ms - ) - ); } \ No newline at end of file diff --git a/commands/applications/crumbSaucer.js b/commands/applications/crumbSaucer.js new file mode 100644 index 0000000..f9582db --- /dev/null +++ b/commands/applications/crumbSaucer.js @@ -0,0 +1,23 @@ +const { + SlashCommandBuilder +} = require("discord.js"); + +const { + showCrumbSaucer +} = require("../../utils/crumbSaucer"); + + +module.exports = { + data: new SlashCommandBuilder() + .setName("crumbsaucer") + .setDescription( + "Visit the Crumb Saucer." + ), + + + async execute(interaction) { + return showCrumbSaucer( + interaction + ); + } +}; \ No newline at end of file diff --git a/utils/crumbSaucer.js b/utils/crumbSaucer.js new file mode 100644 index 0000000..cf64c57 --- /dev/null +++ b/utils/crumbSaucer.js @@ -0,0 +1,1175 @@ +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.", + "", + "*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 + ) + ) + ]; +} + + +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 +}; \ No newline at end of file