const { EmbedBuilder } = require("discord.js"); const { getCookieBalance, transferCookies, claimCooldown, temporaryEphemeralReply, randomFloat, formatPercentage } = require("./cookieEconomy"); /* * ============================================================ * CONFIG * ============================================================ */ const HEIST_COOLDOWN = 60 * 60; // 1 hour /* * Each approach has a different risk/reward profile. * * Sneak: * High success chance * Small reward * Small failure penalty * * Hack: * Medium success chance * Medium reward * Medium failure penalty * * Smash: * Low success chance * Large reward * Large failure penalty */ const HEIST_METHODS = { Sneak: { baseChance: 0.65, minStealPercent: 0.01, maxStealPercent: 0.03, maxStealAmount: 1500, minPenaltyPercent: 0.01, maxPenaltyPercent: 0.04 }, Hack: { baseChance: 0.45, minStealPercent: 0.03, maxStealPercent: 0.06, maxStealAmount: 3500, minPenaltyPercent: 0.03, maxPenaltyPercent: 0.07 }, Smash: { baseChance: 0.25, minStealPercent: 0.06, maxStealPercent: 0.12, maxStealAmount: 7500, minPenaltyPercent: 0.06, maxPenaltyPercent: 0.12 } }; /* * ============================================================ * RESOLVE HEIST * ============================================================ */ async function resolveCookieHeist( interaction, methodName ) { /* * The original /cookies heist command stores * the player ID inside the embed footer: * * cookieHeist:123456789 * * This prevents somebody else from pressing * another player's heist buttons. */ const footer = interaction.message .embeds[0] ?.footer ?.text; const [ type, ownerId ] = footer ?.split(":") ?? []; if ( type !== "cookieHeist" || ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This isn't your heist. Start your own with `/cookies heist`." } ); } /* * Validate selected method. */ const method = HEIST_METHODS[ methodName ]; if (!method) { return temporaryEphemeralReply( interaction, { content: "That heist approach doesn't exist." } ); } /* * ======================================================== * CLAIM COOLDOWN * ======================================================== * * This is atomic so rapid double-clicks can't * resolve the same heist multiple times. */ const cooldown = await claimCooldown( interaction.user.id, interaction.guildId, "heist", HEIST_COOLDOWN ); if (!cooldown.claimed) { return temporaryEphemeralReply( interaction, { content: `You can attempt another vault heist .` } ); } /* * ======================================================== * LOCK THE HEIST MESSAGE * ======================================================== * * Remove all buttons immediately. */ await interaction.update({ embeds: [ new EmbedBuilder() .setTitle( "🏦 Vault Heist in Progress..." ) .setDescription( getHeistProgressText( methodName ) ) ], components: [] }); /* * ======================================================== * GET BALANCES * ======================================================== */ const playerId = interaction.user.id; const vaultId = interaction.client.user.id; const [ playerCookies, vaultCookies ] = await Promise.all([ getCookieBalance( playerId ), getCookieBalance( vaultId ) ]); /* * Something may have changed between opening * the heist menu and pressing the button. */ if ( playerCookies < 1 || vaultCookies < 1 ) { return interaction.editReply({ embeds: [ new EmbedBuilder() .setTitle( "🏦 Heist Aborted" ) .setDescription( "There weren't enough Cookies available to resolve the heist." ) ], components: [] }); } /* * Small dramatic pause. * * Only one edit happens after this, so this is * comfortably within Discord rate limits. */ await sleep( 1200 ); /* * ======================================================== * VAULT DIFFICULTY * ======================================================== * * The richer ShaiBot's vault becomes, * the harder it is to rob. * * Penalty is capped at 15 percentage points. */ const securityPenalty = getVaultSecurityPenalty( vaultCookies ); const successChance = Math.max( 0.10, method.baseChance - securityPenalty ); const success = Math.random() < successChance; /* * ======================================================== * SUCCESS * ======================================================== */ if (success) { /* * Roll percentage of the current vault. */ const percent = randomFloat( method.minStealPercent, method.maxStealPercent ); /* * Apply both: * * percentage roll * AND * hard maximum * * This prevents a massive vault from producing * absurd payouts. */ const amount = Math.max( 1, Math.min( Math.floor( vaultCookies * percent ), method.maxStealAmount, vaultCookies ) ); /* * ShaiBot pays the robber. */ await transferCookies( vaultId, playerId, amount ); const remaining = await getCookieBalance( vaultId ); const actualPercentage = ( amount / vaultCookies ) * 100; const embed = new EmbedBuilder() .setTitle( "💥 Vault Breached!" ) .setDescription( getSuccessText( methodName, interaction.user, amount ) ) .addFields( { name: "Approach", value: methodName, inline: true }, { name: "Success Chance", value: `**${formatPercentage(successChance)}**`, inline: true }, { name: "Vault Taken", value: `**${actualPercentage.toFixed(2)}%**`, inline: true }, { name: "Cookies Stolen", value: `**${amount.toLocaleString()} 🍪**`, inline: true }, { name: "Vault Remaining", value: `**${remaining.toLocaleString()} 🍪**`, inline: true }, { name: "Next Heist", value: ``, inline: true } ); return interaction.editReply({ embeds: [ embed ], components: [] }); } /* * ======================================================== * FAILURE * ======================================================== * * Failure fines the player. * * Unlike normal player stealing, the fine goes * directly into ShaiBot's vault. */ const penaltyPercent = randomFloat( method.minPenaltyPercent, method.maxPenaltyPercent ); const penalty = Math.max( 1, Math.min( Math.floor( playerCookies * penaltyPercent ), playerCookies ) ); await transferCookies( playerId, vaultId, penalty ); const [ playerAfter, vaultAfter ] = await Promise.all([ getCookieBalance( playerId ), getCookieBalance( vaultId ) ]); const embed = new EmbedBuilder() .setTitle( "🚨 HEIST FAILED" ) .setDescription( getFailureText( methodName, interaction.user, penalty ) ) .addFields( { name: "Approach", value: methodName, inline: true }, { name: "Success Chance", value: `**${formatPercentage(successChance)}**`, inline: true }, { name: "Fine", value: `**${penalty.toLocaleString()} 🍪**`, inline: true }, { name: "Your Vault", value: `**${playerAfter.toLocaleString()} 🍪**`, inline: true }, { name: "House Vault", value: `**${vaultAfter.toLocaleString()} 🍪**`, inline: true }, { name: "Next Heist", value: ``, inline: true } ); return interaction.editReply({ embeds: [ embed ], components: [] }); } /* * ============================================================ * VAULT SECURITY * ============================================================ */ function getVaultSecurityPenalty( vaultCookies ) { /* * Logarithmic scaling means the vault gets harder * as it becomes richer without becoming impossible. * * Examples approximately: * * 100 -> 5% * 1,000 -> 7.5% * 10,000 -> 10% * 100,000 -> 12.5% * 1,000,000 -> 15% */ return Math.min( Math.log10( vaultCookies + 1 ) * 0.025, 0.15 ); } /* * ============================================================ * FLAVOR TEXT * ============================================================ */ function getHeistProgressText( methodName ) { switch ( methodName ) { case "Sneak": return [ "🥷 You slip through the service entrance...", "", "The Cookie Vault guards haven't noticed you yet." ].join("\n"); case "Hack": return [ "💻 You connect to the vault terminal...", "", "Several extremely suspicious progress bars appear." ].join("\n"); case "Smash": return [ "💥 You charge directly at the vault...", "", "Subtlety has been abandoned." ].join("\n"); default: return "The heist is underway..."; } } function getSuccessText( methodName, user, amount ) { const amountText = `**${amount.toLocaleString()} Cookies**`; switch ( methodName ) { case "Sneak": return ( `🥷 ${user} slipped through security and quietly escaped with ${amountText}.` ); case "Hack": return ( `💻 ${user} convinced the vault terminal that ${amountText} belonged to them.` ); case "Smash": return ( `💥 ${user} smashed through the Cookie Vault and escaped with ${amountText}!` ); default: return ( `${user} escaped with ${amountText}.` ); } } function getFailureText( methodName, user, penalty ) { const penaltyText = `**${penalty.toLocaleString()} Cookies**`; switch ( methodName ) { case "Sneak": return [ `🚨 ${user} stepped directly on the world's loudest Cookie wrapper.`, "", `The ShaiBot Vault collected a ${penaltyText} fine.` ].join("\n"); case "Hack": return [ `🚨 ${user} entered the password incorrectly one too many times.`, "", `The ShaiBot Vault collected a ${penaltyText} security fee.` ].join("\n"); case "Smash": return [ `🚨 ${user} discovered that the vault door is significantly stronger than expected.`, "", `Repairs cost ${penaltyText}. Naturally, you're paying.` ].join("\n"); default: return ( `The heist failed and cost ${user} ${penaltyText}.` ); } } /* * ============================================================ * UTIL * ============================================================ */ function sleep(ms) { return new Promise( resolve => setTimeout( resolve, ms ) ); } module.exports = { resolveCookieHeist };